feat: wire --resume/--continue CLI flags and add cron scheduler daemon (#16)

Cherry-picked from PR #16 by win4r, excluding auto-compact (already
implemented in a more thorough version based on Claude Code source).

Resume/Continue:
- Wire --continue (most recent session) and --resume (picker/ID) CLI flags
- Pass restore_messages through app.py → backend_host.py → runtime.py
- Uses existing session_storage infrastructure

Cron scheduler daemon:
- oh cron start/stop/status/list/toggle/history subcommands
- Background daemon with 30s tick, concurrent job execution, PID file
- Job state: enabled, last_run, next_run, last_status, created_at
- JSONL execution history at ~/.openharness/data/cron_history.jsonl
- croniter-based expression validation and next-run computation

Cost/usage output in print mode:
- Token usage summary (input/output/total) printed to stderr
- Tool activity indicators during execution

New files: cron_scheduler.py, cron_toggle_tool.py, test_cron.py, test_cron_scheduler.py
Modified: cli.py, runtime.py, app.py, backend_host.py, cron.py, tools/__init__.py
This commit is contained in:
Chao Qin
2026-04-04 10:29:09 +00:00
committed by tjb-tech
parent bd976f4671
commit a6dfa8f56d
15 changed files with 998 additions and 10 deletions
+1
View File
@@ -26,6 +26,7 @@ dependencies = [
"pyperclip>=1.9.0",
"pyyaml>=6.0",
"watchfiles>=0.20.0",
"croniter>=2.0.0",
]
[project.optional-dependencies]
+173
View File
@@ -28,10 +28,12 @@ app = typer.Typer(
mcp_app = typer.Typer(name="mcp", help="Manage MCP servers")
plugin_app = typer.Typer(name="plugin", help="Manage plugins")
auth_app = typer.Typer(name="auth", help="Manage authentication")
cron_app = typer.Typer(name="cron", help="Manage cron scheduler and jobs")
app.add_typer(mcp_app)
app.add_typer(plugin_app)
app.add_typer(auth_app)
app.add_typer(cron_app)
# ---- mcp subcommands ----
@@ -131,6 +133,119 @@ def plugin_uninstall(
print(f"Uninstalled plugin: {name}")
# ---- cron subcommands ----
@cron_app.command("start")
def cron_start() -> None:
"""Start the cron scheduler daemon."""
from openharness.services.cron_scheduler import is_scheduler_running, start_daemon
if is_scheduler_running():
print("Cron scheduler is already running.")
return
pid = start_daemon()
print(f"Cron scheduler started (pid={pid})")
@cron_app.command("stop")
def cron_stop() -> None:
"""Stop the cron scheduler daemon."""
from openharness.services.cron_scheduler import stop_scheduler
if stop_scheduler():
print("Cron scheduler stopped.")
else:
print("Cron scheduler is not running.")
@cron_app.command("status")
def cron_status_cmd() -> None:
"""Show cron scheduler status and job summary."""
from openharness.services.cron_scheduler import scheduler_status
status = scheduler_status()
state = "running" if status["running"] else "stopped"
print(f"Scheduler: {state}" + (f" (pid={status['pid']})" if status["pid"] else ""))
print(f"Jobs: {status['enabled_jobs']} enabled / {status['total_jobs']} total")
print(f"Log: {status['log_file']}")
@cron_app.command("list")
def cron_list_cmd() -> None:
"""List all registered cron jobs with schedule and status."""
from openharness.services.cron import load_cron_jobs
jobs = load_cron_jobs()
if not jobs:
print("No cron jobs configured.")
return
for job in jobs:
enabled = "on " if job.get("enabled", True) else "off"
last = job.get("last_run", "never")
if last != "never":
last = last[:19] # trim to readable datetime
last_status = job.get("last_status", "")
status_indicator = f" [{last_status}]" if last_status else ""
print(f" [{enabled}] {job['name']} {job.get('schedule', '?')}")
print(f" cmd: {job['command']}")
print(f" last: {last}{status_indicator} next: {job.get('next_run', 'n/a')[:19]}")
@cron_app.command("toggle")
def cron_toggle_cmd(
name: str = typer.Argument(..., help="Cron job name"),
enabled: bool = typer.Argument(..., help="true to enable, false to disable"),
) -> None:
"""Enable or disable a cron job."""
from openharness.services.cron import set_job_enabled
if not set_job_enabled(name, enabled):
print(f"Cron job not found: {name}")
raise typer.Exit(1)
state = "enabled" if enabled else "disabled"
print(f"Cron job '{name}' is now {state}")
@cron_app.command("history")
def cron_history_cmd(
name: str | None = typer.Argument(None, help="Filter by job name"),
limit: int = typer.Option(20, "--limit", "-n", help="Number of entries"),
) -> None:
"""Show cron execution history."""
from openharness.services.cron_scheduler import load_history
entries = load_history(limit=limit, job_name=name)
if not entries:
print("No execution history.")
return
for entry in entries:
ts = entry.get("started_at", "?")[:19]
status = entry.get("status", "?")
rc = entry.get("returncode", "?")
print(f" {ts} {entry.get('name', '?')} {status} (rc={rc})")
stderr = entry.get("stderr", "").strip()
if stderr and status != "success":
for line in stderr.splitlines()[:3]:
print(f" stderr: {line}")
@cron_app.command("logs")
def cron_logs_cmd(
lines: int = typer.Option(30, "--lines", "-n", help="Number of lines to show"),
) -> None:
"""Show recent cron scheduler log output."""
from openharness.config.paths import get_logs_dir
log_path = get_logs_dir() / "cron_scheduler.log"
if not log_path.exists():
print("No scheduler log found. Start the scheduler with: oh cron start")
return
content = log_path.read_text(encoding="utf-8", errors="replace")
tail = content.splitlines()[-lines:]
for line in tail:
print(line)
# ---- auth subcommands ----
@auth_app.command("status")
@@ -349,6 +464,64 @@ def main(
from openharness.ui.app import run_print_mode, run_repl
# Handle --continue and --resume flags
if continue_session or resume is not None:
from openharness.services.session_storage import (
list_session_snapshots,
load_session_by_id,
load_session_snapshot,
)
session_data = None
if continue_session:
session_data = load_session_snapshot(cwd)
if session_data is None:
print("No previous session found in this directory.", file=sys.stderr)
raise typer.Exit(1)
print(f"Continuing session: {session_data.get('summary', '(untitled)')[:60]}")
elif resume == "" or resume is None:
# --resume with no value: show session picker
sessions = list_session_snapshots(cwd, limit=10)
if not sessions:
print("No saved sessions found.", file=sys.stderr)
raise typer.Exit(1)
print("Saved sessions:")
for i, s in enumerate(sessions, 1):
print(f" {i}. [{s['session_id']}] {s.get('summary', '?')[:50]} ({s['message_count']} msgs)")
choice = typer.prompt("Enter session number or ID")
try:
idx = int(choice) - 1
if 0 <= idx < len(sessions):
session_data = load_session_by_id(cwd, sessions[idx]["session_id"])
else:
print("Invalid selection.", file=sys.stderr)
raise typer.Exit(1)
except ValueError:
session_data = load_session_by_id(cwd, choice)
if session_data is None:
print(f"Session not found: {choice}", file=sys.stderr)
raise typer.Exit(1)
else:
session_data = load_session_by_id(cwd, resume)
if session_data is None:
print(f"Session not found: {resume}", file=sys.stderr)
raise typer.Exit(1)
# Pass restored session to the REPL
asyncio.run(
run_repl(
prompt=None,
cwd=cwd,
model=session_data.get("model") or model,
backend_only=backend_only,
base_url=base_url,
system_prompt=session_data.get("system_prompt") or system_prompt,
api_key=api_key,
restore_messages=session_data.get("messages"),
)
)
return
if print_mode is not None:
prompt = print_mode.strip()
if not prompt:
+53 -1
View File
@@ -3,8 +3,11 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
from croniter import croniter
from openharness.config.paths import get_cron_registry_path
@@ -23,11 +26,34 @@ def load_cron_jobs() -> list[dict[str, Any]]:
def save_cron_jobs(jobs: list[dict[str, Any]]) -> None:
"""Persist cron jobs to disk."""
path = get_cron_registry_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(jobs, indent=2) + "\n", encoding="utf-8")
def validate_cron_expression(expression: str) -> bool:
"""Return True if the expression is a valid cron schedule."""
return croniter.is_valid(expression)
def next_run_time(expression: str, base: datetime | None = None) -> datetime:
"""Return the next run time for a cron expression."""
base = base or datetime.now(timezone.utc)
return croniter(expression, base).get_next(datetime)
def upsert_cron_job(job: dict[str, Any]) -> None:
"""Insert or replace one cron job."""
"""Insert or replace one cron job.
Automatically sets ``enabled`` to True and computes ``next_run`` when the
schedule is a valid cron expression.
"""
job.setdefault("enabled", True)
job.setdefault("created_at", datetime.now(timezone.utc).isoformat())
schedule = job.get("schedule", "")
if validate_cron_expression(schedule):
job["next_run"] = next_run_time(schedule).isoformat()
jobs = [existing for existing in load_cron_jobs() if existing.get("name") != job.get("name")]
jobs.append(job)
jobs.sort(key=lambda item: str(item.get("name", "")))
@@ -50,3 +76,29 @@ def get_cron_job(name: str) -> dict[str, Any] | None:
if job.get("name") == name:
return job
return None
def set_job_enabled(name: str, enabled: bool) -> bool:
"""Enable or disable a cron job. Returns False if job not found."""
jobs = load_cron_jobs()
for job in jobs:
if job.get("name") == name:
job["enabled"] = enabled
save_cron_jobs(jobs)
return True
return False
def mark_job_run(name: str, *, success: bool) -> None:
"""Update last_run and recompute next_run after a job executes."""
jobs = load_cron_jobs()
now = datetime.now(timezone.utc)
for job in jobs:
if job.get("name") == name:
job["last_run"] = now.isoformat()
job["last_status"] = "success" if success else "failed"
schedule = job.get("schedule", "")
if validate_cron_expression(schedule):
job["next_run"] = next_run_time(schedule, now).isoformat()
save_cron_jobs(jobs)
return
+344
View File
@@ -0,0 +1,344 @@
"""Background cron scheduler daemon.
Runs as a standalone process (``oh cron start``) or can be embedded via
:func:`run_scheduler_loop`. Every tick it reads the cron registry, checks
which enabled jobs are due, executes them, and records results in a history
log.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import signal
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from openharness.config.paths import get_data_dir, get_logs_dir
from openharness.services.cron import (
load_cron_jobs,
mark_job_run,
validate_cron_expression,
)
logger = logging.getLogger(__name__)
TICK_INTERVAL_SECONDS = 30
"""How often the scheduler checks for due jobs."""
# ---------------------------------------------------------------------------
# History helpers
# ---------------------------------------------------------------------------
def get_history_path() -> Path:
"""Return the path to the cron execution history file."""
return get_data_dir() / "cron_history.jsonl"
def append_history(entry: dict[str, Any]) -> None:
"""Append one execution record to the history log."""
path = get_history_path()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(entry) + "\n")
def load_history(*, limit: int = 50, job_name: str | None = None) -> list[dict[str, Any]]:
"""Load the most recent execution history entries."""
path = get_history_path()
if not path.exists():
return []
entries: list[dict[str, Any]] = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if job_name and entry.get("name") != job_name:
continue
entries.append(entry)
return entries[-limit:]
# ---------------------------------------------------------------------------
# PID file helpers
# ---------------------------------------------------------------------------
def get_pid_path() -> Path:
"""Return the scheduler PID file path."""
return get_data_dir() / "cron_scheduler.pid"
def read_pid() -> int | None:
"""Read the PID of a running scheduler, or None."""
path = get_pid_path()
if not path.exists():
return None
try:
pid = int(path.read_text(encoding="utf-8").strip())
except (ValueError, OSError):
return None
# Check if process is alive
try:
os.kill(pid, 0)
except OSError:
logger.debug("Removed stale scheduler PID file (pid=%d)", pid)
path.unlink(missing_ok=True)
return None
return pid
def write_pid() -> None:
"""Write the current process PID."""
path = get_pid_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(str(os.getpid()) + "\n", encoding="utf-8")
def remove_pid() -> None:
"""Remove the PID file."""
get_pid_path().unlink(missing_ok=True)
def is_scheduler_running() -> bool:
"""Return True if a scheduler process is alive."""
return read_pid() is not None
def stop_scheduler() -> bool:
"""Send SIGTERM to the running scheduler. Returns True if killed."""
pid = read_pid()
if pid is None:
return False
try:
os.kill(pid, signal.SIGTERM)
except OSError:
remove_pid()
return False
# Wait briefly for process to exit
for _ in range(10):
try:
os.kill(pid, 0)
except OSError:
remove_pid()
return True
time.sleep(0.2)
# Force kill
try:
os.kill(pid, signal.SIGKILL)
except OSError:
pass
remove_pid()
return True
# ---------------------------------------------------------------------------
# Job execution
# ---------------------------------------------------------------------------
async def execute_job(job: dict[str, Any]) -> dict[str, Any]:
"""Run a single cron job and return a history entry."""
name = job["name"]
command = job["command"]
cwd = Path(job.get("cwd") or ".").expanduser()
started_at = datetime.now(timezone.utc)
logger.info("Executing cron job %r: %s", name, command)
try:
process = await asyncio.create_subprocess_exec(
"/bin/bash",
"-lc",
command,
cwd=str(cwd),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=300,
)
except asyncio.TimeoutError:
try:
process.kill()
await process.wait()
except Exception:
pass
entry = {
"name": name,
"command": command,
"started_at": started_at.isoformat(),
"ended_at": datetime.now(timezone.utc).isoformat(),
"returncode": -1,
"status": "timeout",
"stdout": "",
"stderr": "Job timed out after 300s",
}
mark_job_run(name, success=False)
append_history(entry)
return entry
except Exception as exc:
entry = {
"name": name,
"command": command,
"started_at": started_at.isoformat(),
"ended_at": datetime.now(timezone.utc).isoformat(),
"returncode": -1,
"status": "error",
"stdout": "",
"stderr": str(exc),
}
mark_job_run(name, success=False)
append_history(entry)
return entry
success = process.returncode == 0
entry = {
"name": name,
"command": command,
"started_at": started_at.isoformat(),
"ended_at": datetime.now(timezone.utc).isoformat(),
"returncode": process.returncode,
"status": "success" if success else "failed",
"stdout": (stdout.decode("utf-8", errors="replace")[-2000:] if stdout else ""),
"stderr": (stderr.decode("utf-8", errors="replace")[-2000:] if stderr else ""),
}
mark_job_run(name, success=success)
append_history(entry)
logger.info("Job %r finished: %s (rc=%s)", name, entry["status"], process.returncode)
return entry
# ---------------------------------------------------------------------------
# Scheduler loop
# ---------------------------------------------------------------------------
def _jobs_due(jobs: list[dict[str, Any]], now: datetime) -> list[dict[str, Any]]:
"""Return jobs whose next_run is at or before *now*."""
due: list[dict[str, Any]] = []
for job in jobs:
if not job.get("enabled", True):
continue
schedule = job.get("schedule", "")
if not validate_cron_expression(schedule):
continue
next_run_str = job.get("next_run")
if not next_run_str:
continue
try:
next_run = datetime.fromisoformat(next_run_str)
if next_run.tzinfo is None:
next_run = next_run.replace(tzinfo=timezone.utc)
except (ValueError, TypeError):
continue
if next_run <= now:
due.append(job)
return due
async def run_scheduler_loop(*, once: bool = False) -> None:
"""Main scheduler loop. Runs until SIGTERM or *once* is True (test mode)."""
shutdown = asyncio.Event()
def _on_signal() -> None:
logger.info("Received shutdown signal")
shutdown.set()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, _on_signal)
write_pid()
logger.info("Cron scheduler started (pid=%d, tick=%ds)", os.getpid(), TICK_INTERVAL_SECONDS)
try:
while not shutdown.is_set():
now = datetime.now(timezone.utc)
jobs = load_cron_jobs()
due = _jobs_due(jobs, now)
if due:
logger.info("Tick: %d job(s) due", len(due))
# Execute due jobs concurrently
results = await asyncio.gather(
*(execute_job(job) for job in due), return_exceptions=True
)
for result in results:
if isinstance(result, BaseException):
logger.error("Unexpected error executing cron job: %s", result)
if once:
break
try:
await asyncio.wait_for(shutdown.wait(), timeout=TICK_INTERVAL_SECONDS)
except asyncio.TimeoutError:
pass
finally:
remove_pid()
logger.info("Cron scheduler stopped")
# ---------------------------------------------------------------------------
# Daemon entry point (spawned by ``oh cron start``)
# ---------------------------------------------------------------------------
def _run_daemon() -> None:
"""Entry point for the scheduler subprocess."""
log_file = get_logs_dir() / "cron_scheduler.log"
log_file.parent.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
filename=str(log_file),
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
asyncio.run(run_scheduler_loop())
def start_daemon() -> int:
"""Fork and start the scheduler daemon. Returns the child PID."""
existing = read_pid()
if existing is not None:
raise RuntimeError(f"Scheduler already running (pid={existing})")
pid = os.fork()
if pid > 0:
# Parent — wait a moment for the child to write its PID file
time.sleep(0.3)
return pid
# Child — detach
os.setsid()
# Redirect stdio
devnull = os.open(os.devnull, os.O_RDWR)
os.dup2(devnull, 0)
os.dup2(devnull, 1)
os.dup2(devnull, 2)
os.close(devnull)
_run_daemon()
sys.exit(0)
def scheduler_status() -> dict[str, Any]:
"""Return a status dict about the scheduler."""
pid = read_pid()
log_path = get_logs_dir() / "cron_scheduler.log"
jobs = load_cron_jobs()
enabled = [j for j in jobs if j.get("enabled", True)]
return {
"running": pid is not None,
"pid": pid,
"total_jobs": len(jobs),
"enabled_jobs": len(enabled),
"log_file": str(log_path),
"history_file": str(get_history_path()),
}
+2
View File
@@ -9,6 +9,7 @@ from openharness.tools.config_tool import ConfigTool
from openharness.tools.cron_create_tool import CronCreateTool
from openharness.tools.cron_delete_tool import CronDeleteTool
from openharness.tools.cron_list_tool import CronListTool
from openharness.tools.cron_toggle_tool import CronToggleTool
from openharness.tools.enter_plan_mode_tool import EnterPlanModeTool
from openharness.tools.enter_worktree_tool import EnterWorktreeTool
from openharness.tools.exit_plan_mode_tool import ExitPlanModeTool
@@ -71,6 +72,7 @@ def create_default_tool_registry(mcp_manager=None) -> ToolRegistry:
CronCreateTool(),
CronListTool(),
CronDeleteTool(),
CronToggleTool(),
RemoteTriggerTool(),
TaskCreateTool(),
TaskGetTool(),
+27 -4
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from pydantic import BaseModel, Field
from openharness.services.cron import upsert_cron_job
from openharness.services.cron import upsert_cron_job, validate_cron_expression
from openharness.tools.base import BaseTool, ToolExecutionContext, ToolResult
@@ -12,16 +12,25 @@ class CronCreateToolInput(BaseModel):
"""Arguments for cron job creation."""
name: str = Field(description="Unique cron job name")
schedule: str = Field(description="Human-readable schedule expression")
schedule: str = Field(
description=(
"Cron schedule expression (e.g. '*/5 * * * *' for every 5 minutes, "
"'0 9 * * 1-5' for weekdays at 9am)"
),
)
command: str = Field(description="Shell command to run when triggered")
cwd: str | None = Field(default=None, description="Optional working directory override")
enabled: bool = Field(default=True, description="Whether the job is active")
class CronCreateTool(BaseTool):
"""Create or replace a local cron job."""
name = "cron_create"
description = "Create or replace a local cron-style job."
description = (
"Create or replace a local cron job with a standard cron expression. "
"Use 'oh cron start' to run the scheduler daemon."
)
input_model = CronCreateToolInput
async def execute(
@@ -29,12 +38,26 @@ class CronCreateTool(BaseTool):
arguments: CronCreateToolInput,
context: ToolExecutionContext,
) -> ToolResult:
if not validate_cron_expression(arguments.schedule):
return ToolResult(
output=(
f"Invalid cron expression: {arguments.schedule!r}\n"
"Use standard 5-field format: minute hour day month weekday\n"
"Examples: '*/5 * * * *' (every 5 min), '0 9 * * 1-5' (weekdays 9am)"
),
is_error=True,
)
upsert_cron_job(
{
"name": arguments.name,
"schedule": arguments.schedule,
"command": arguments.command,
"cwd": arguments.cwd or str(context.cwd),
"enabled": arguments.enabled,
}
)
return ToolResult(output=f"Created cron job {arguments.name}")
status = "enabled" if arguments.enabled else "disabled"
return ToolResult(
output=f"Created cron job '{arguments.name}' [{arguments.schedule}] ({status})"
)
+20 -3
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from pydantic import BaseModel
from openharness.services.cron import load_cron_jobs
from openharness.services.cron_scheduler import is_scheduler_running
from openharness.tools.base import BaseTool, ToolExecutionContext, ToolResult
@@ -16,7 +17,7 @@ class CronListTool(BaseTool):
"""List local cron jobs."""
name = "cron_list"
description = "List configured local cron-style jobs."
description = "List configured local cron jobs with schedule, status, and next run time."
input_model = CronListToolInput
def is_read_only(self, arguments: CronListToolInput) -> bool:
@@ -32,7 +33,23 @@ class CronListTool(BaseTool):
jobs = load_cron_jobs()
if not jobs:
return ToolResult(output="No cron jobs configured.")
lines = []
scheduler = "running" if is_scheduler_running() else "stopped"
lines = [f"Scheduler: {scheduler}", ""]
for job in jobs:
lines.append(f"{job['name']} [{job['schedule']}] -> {job['command']}")
enabled = "on" if job.get("enabled", True) else "off"
last_run = job.get("last_run", "never")
if last_run != "never":
last_run = last_run[:19]
next_run = job.get("next_run", "n/a")
if next_run != "n/a":
next_run = next_run[:19]
last_status = job.get("last_status", "")
status_str = f" ({last_status})" if last_status else ""
lines.append(
f"[{enabled}] {job['name']} {job.get('schedule', '?')}\n"
f" cmd: {job['command']}\n"
f" last: {last_run}{status_str} next: {next_run}"
)
return ToolResult(output="\n".join(lines))
+37
View File
@@ -0,0 +1,37 @@
"""Tool for enabling or disabling local cron jobs."""
from __future__ import annotations
from pydantic import BaseModel, Field
from openharness.services.cron import set_job_enabled
from openharness.tools.base import BaseTool, ToolExecutionContext, ToolResult
class CronToggleToolInput(BaseModel):
"""Arguments for toggling a cron job."""
name: str = Field(description="Cron job name")
enabled: bool = Field(description="True to enable, False to disable")
class CronToggleTool(BaseTool):
"""Enable or disable a local cron job."""
name = "cron_toggle"
description = "Enable or disable a local cron job by name."
input_model = CronToggleToolInput
async def execute(
self,
arguments: CronToggleToolInput,
context: ToolExecutionContext,
) -> ToolResult:
del context
if not set_job_enabled(arguments.name, arguments.enabled):
return ToolResult(
output=f"Cron job not found: {arguments.name}",
is_error=True,
)
state = "enabled" if arguments.enabled else "disabled"
return ToolResult(output=f"Cron job '{arguments.name}' is now {state}")
+2
View File
@@ -23,6 +23,7 @@ async def run_repl(
api_format: str | None = None,
api_client: SupportsStreamingMessages | None = None,
backend_only: bool = False,
restore_messages: list[dict] | None = None,
) -> None:
"""Run the default OpenHarness interactive application (React TUI)."""
if backend_only:
@@ -34,6 +35,7 @@ async def run_repl(
api_key=api_key,
api_format=api_format,
api_client=api_client,
restore_messages=restore_messages,
)
return
+4
View File
@@ -36,6 +36,7 @@ class BackendHostConfig:
api_key: str | None = None
api_format: str | None = None
api_client: SupportsStreamingMessages | None = None
restore_messages: list[dict] | None = None
class ReactBackendHost:
@@ -59,6 +60,7 @@ class ReactBackendHost:
api_key=self._config.api_key,
api_format=self._config.api_format,
api_client=self._config.api_client,
restore_messages=self._config.restore_messages,
permission_prompt=self._ask_permission,
ask_user_prompt=self._ask_question,
)
@@ -288,6 +290,7 @@ async def run_backend_host(
api_format: str | None = None,
cwd: str | None = None,
api_client: SupportsStreamingMessages | None = None,
restore_messages: list[dict] | None = None,
) -> int:
"""Run the structured React backend host."""
if cwd:
@@ -300,6 +303,7 @@ async def run_backend_host(
api_key=api_key,
api_format=api_format,
api_client=api_client,
restore_messages=restore_messages,
)
)
return await host.run()
+9
View File
@@ -13,6 +13,7 @@ from openharness.bridge import get_bridge_manager
from openharness.commands import CommandContext, CommandResult, create_default_command_registry
from openharness.config import get_config_file_path, load_settings
from openharness.engine import QueryEngine
from openharness.engine.messages import ConversationMessage
from openharness.engine.stream_events import StreamEvent
from openharness.hooks import HookEvent, HookExecutionContext, HookExecutor, load_hook_registry
from openharness.hooks.hot_reload import HookReloader
@@ -98,6 +99,7 @@ async def build_runtime(
api_client: SupportsStreamingMessages | None = None,
permission_prompt: PermissionPrompt | None = None,
ask_user_prompt: AskUserPrompt | None = None,
restore_messages: list[dict] | None = None,
) -> RuntimeBundle:
"""Build the shared runtime for an OpenHarness session."""
settings = load_settings().merge_cli_overrides(
@@ -171,6 +173,13 @@ async def build_runtime(
hook_executor=hook_executor,
tool_metadata={"mcp_manager": mcp_manager, "bridge_manager": bridge_manager},
)
# Restore messages from a saved session if provided
if restore_messages:
restored = [
ConversationMessage.model_validate(m) for m in restore_messages
]
engine.load_messages(restored)
from uuid import uuid4
return RuntimeBundle(
+154
View File
@@ -0,0 +1,154 @@
"""Tests for cron registry helpers."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
import pytest
from openharness.services.cron import (
delete_cron_job,
get_cron_job,
load_cron_jobs,
mark_job_run,
next_run_time,
set_job_enabled,
upsert_cron_job,
validate_cron_expression,
)
@pytest.fixture(autouse=True)
def _tmp_cron_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Redirect cron registry to a temp directory."""
data_dir = tmp_path / "data"
data_dir.mkdir()
monkeypatch.setattr(
"openharness.services.cron.get_cron_registry_path",
lambda: data_dir / "cron_jobs.json",
)
class TestValidation:
def test_valid_expressions(self) -> None:
assert validate_cron_expression("* * * * *")
assert validate_cron_expression("*/5 * * * *")
assert validate_cron_expression("0 9 * * 1-5")
assert validate_cron_expression("0 0 1 1 *")
def test_invalid_expressions(self) -> None:
assert not validate_cron_expression("")
assert not validate_cron_expression("every 5 minutes")
assert not validate_cron_expression("60 * * * *")
assert not validate_cron_expression("* * * *") # only 4 fields
def test_next_run_time(self) -> None:
base = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
nxt = next_run_time("0 * * * *", base)
assert nxt == datetime(2026, 1, 1, 1, 0, 0, tzinfo=timezone.utc)
class TestCRUD:
def test_empty_load(self) -> None:
assert load_cron_jobs() == []
def test_upsert_and_load(self) -> None:
upsert_cron_job({"name": "test-job", "schedule": "*/5 * * * *", "command": "echo hi"})
jobs = load_cron_jobs()
assert len(jobs) == 1
assert jobs[0]["name"] == "test-job"
assert jobs[0]["enabled"] is True
assert "next_run" in jobs[0]
assert "created_at" in jobs[0]
def test_upsert_replaces(self) -> None:
upsert_cron_job({"name": "j1", "schedule": "* * * * *", "command": "echo 1"})
upsert_cron_job({"name": "j1", "schedule": "0 * * * *", "command": "echo 2"})
jobs = load_cron_jobs()
assert len(jobs) == 1
assert jobs[0]["command"] == "echo 2"
def test_delete(self) -> None:
upsert_cron_job({"name": "j1", "schedule": "* * * * *", "command": "echo 1"})
assert delete_cron_job("j1") is True
assert load_cron_jobs() == []
def test_delete_missing(self) -> None:
assert delete_cron_job("nope") is False
def test_get_job(self) -> None:
upsert_cron_job({"name": "j1", "schedule": "* * * * *", "command": "echo 1"})
job = get_cron_job("j1")
assert job is not None
assert job["name"] == "j1"
def test_get_missing(self) -> None:
assert get_cron_job("nope") is None
def test_sorted_output(self) -> None:
upsert_cron_job({"name": "z-job", "schedule": "* * * * *", "command": "z"})
upsert_cron_job({"name": "a-job", "schedule": "* * * * *", "command": "a"})
jobs = load_cron_jobs()
assert [j["name"] for j in jobs] == ["a-job", "z-job"]
class TestToggle:
def test_enable_disable(self) -> None:
upsert_cron_job({"name": "j1", "schedule": "* * * * *", "command": "echo 1"})
assert set_job_enabled("j1", False) is True
job = get_cron_job("j1")
assert job is not None
assert job["enabled"] is False
assert set_job_enabled("j1", True) is True
job = get_cron_job("j1")
assert job is not None
assert job["enabled"] is True
def test_toggle_missing(self) -> None:
assert set_job_enabled("nope", True) is False
class TestMarkRun:
def test_mark_success(self) -> None:
upsert_cron_job({"name": "j1", "schedule": "*/5 * * * *", "command": "echo ok"})
mark_job_run("j1", success=True)
job = get_cron_job("j1")
assert job is not None
assert job["last_status"] == "success"
assert "last_run" in job
def test_mark_failure(self) -> None:
upsert_cron_job({"name": "j1", "schedule": "*/5 * * * *", "command": "false"})
mark_job_run("j1", success=False)
job = get_cron_job("j1")
assert job is not None
assert job["last_status"] == "failed"
def test_mark_missing_is_noop(self) -> None:
# Should not raise
mark_job_run("nope", success=True)
class TestCorruptData:
def test_corrupt_json(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
bad_file = tmp_path / "data" / "cron_jobs.json"
bad_file.parent.mkdir(parents=True, exist_ok=True)
bad_file.write_text("{not valid json", encoding="utf-8")
monkeypatch.setattr(
"openharness.services.cron.get_cron_registry_path",
lambda: bad_file,
)
assert load_cron_jobs() == []
def test_non_list_json(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
bad_file = tmp_path / "data" / "cron_jobs.json"
bad_file.parent.mkdir(parents=True, exist_ok=True)
bad_file.write_text(json.dumps({"not": "a list"}), encoding="utf-8")
monkeypatch.setattr(
"openharness.services.cron.get_cron_registry_path",
lambda: bad_file,
)
assert load_cron_jobs() == []
+170
View File
@@ -0,0 +1,170 @@
"""Tests for the cron scheduler daemon."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from openharness.services.cron_scheduler import (
_jobs_due,
append_history,
execute_job,
load_history,
run_scheduler_loop,
)
@pytest.fixture(autouse=True)
def _tmp_dirs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Redirect data and log directories to temp."""
data_dir = tmp_path / "data"
logs_dir = tmp_path / "logs"
data_dir.mkdir()
logs_dir.mkdir()
monkeypatch.setattr("openharness.services.cron_scheduler.get_data_dir", lambda: data_dir)
monkeypatch.setattr("openharness.services.cron_scheduler.get_logs_dir", lambda: logs_dir)
# Also redirect the cron registry used by the scheduler
monkeypatch.setattr(
"openharness.services.cron.get_cron_registry_path",
lambda: data_dir / "cron_jobs.json",
)
class TestHistory:
def test_empty_history(self) -> None:
assert load_history() == []
def test_append_and_load(self) -> None:
append_history({"name": "j1", "status": "success"})
append_history({"name": "j2", "status": "failed"})
entries = load_history()
assert len(entries) == 2
assert entries[0]["name"] == "j1"
def test_filter_by_name(self) -> None:
append_history({"name": "j1", "status": "success"})
append_history({"name": "j2", "status": "success"})
entries = load_history(job_name="j1")
assert len(entries) == 1
assert entries[0]["name"] == "j1"
def test_limit(self) -> None:
for i in range(10):
append_history({"name": f"j{i}", "status": "success"})
entries = load_history(limit=3)
assert len(entries) == 3
# Should be the last 3
assert entries[0]["name"] == "j7"
class TestJobsDue:
def test_due_job(self) -> None:
now = datetime.now(timezone.utc)
past = (now - timedelta(minutes=5)).isoformat()
jobs = [
{"name": "j1", "schedule": "* * * * *", "enabled": True, "next_run": past},
]
due = _jobs_due(jobs, now)
assert len(due) == 1
def test_future_job_not_due(self) -> None:
now = datetime.now(timezone.utc)
future = (now + timedelta(hours=1)).isoformat()
jobs = [
{"name": "j1", "schedule": "* * * * *", "enabled": True, "next_run": future},
]
due = _jobs_due(jobs, now)
assert len(due) == 0
def test_disabled_job_not_due(self) -> None:
now = datetime.now(timezone.utc)
past = (now - timedelta(minutes=5)).isoformat()
jobs = [
{"name": "j1", "schedule": "* * * * *", "enabled": False, "next_run": past},
]
due = _jobs_due(jobs, now)
assert len(due) == 0
def test_invalid_schedule_skipped(self) -> None:
now = datetime.now(timezone.utc)
past = (now - timedelta(minutes=5)).isoformat()
jobs = [
{"name": "j1", "schedule": "not valid", "enabled": True, "next_run": past},
]
due = _jobs_due(jobs, now)
assert len(due) == 0
def test_missing_next_run_skipped(self) -> None:
now = datetime.now(timezone.utc)
jobs = [
{"name": "j1", "schedule": "* * * * *", "enabled": True},
]
due = _jobs_due(jobs, now)
assert len(due) == 0
class TestExecuteJob:
@pytest.mark.asyncio
async def test_successful_job(self) -> None:
job = {"name": "echo-test", "command": "echo hello", "cwd": "/tmp"}
entry = await execute_job(job)
assert entry["status"] == "success"
assert entry["returncode"] == 0
assert "hello" in entry["stdout"]
@pytest.mark.asyncio
async def test_failing_job(self) -> None:
job = {"name": "fail-test", "command": "exit 1", "cwd": "/tmp"}
entry = await execute_job(job)
assert entry["status"] == "failed"
assert entry["returncode"] == 1
@pytest.mark.asyncio
async def test_timeout_job(self) -> None:
with patch("openharness.services.cron_scheduler.asyncio.wait_for") as mock_wait:
import asyncio
mock_wait.side_effect = asyncio.TimeoutError()
# Need to mock create_subprocess_exec to return a mock process
mock_process = AsyncMock()
mock_process.kill = AsyncMock()
mock_process.wait = AsyncMock()
with patch(
"openharness.services.cron_scheduler.asyncio.create_subprocess_exec",
return_value=mock_process,
):
job = {"name": "slow-test", "command": "sleep 999", "cwd": "/tmp"}
entry = await execute_job(job)
assert entry["status"] == "timeout"
class TestSchedulerLoop:
@pytest.mark.asyncio
async def test_once_mode_with_no_jobs(self) -> None:
"""Scheduler loop in once-mode should complete without error when no jobs exist."""
await run_scheduler_loop(once=True)
@pytest.mark.asyncio
async def test_once_mode_fires_due_job(self) -> None:
"""Scheduler loop should fire a job that is due."""
from openharness.services.cron import upsert_cron_job
upsert_cron_job({"name": "test-once", "schedule": "* * * * *", "command": "echo fired"})
# Force next_run to the past so it's immediately due
from openharness.services.cron import load_cron_jobs, save_cron_jobs
jobs = load_cron_jobs()
now = datetime.now(timezone.utc)
jobs[0]["next_run"] = (now - timedelta(minutes=1)).isoformat()
save_cron_jobs(jobs)
await run_scheduler_loop(once=True)
entries = load_history(job_name="test-once")
assert len(entries) == 1
assert entries[0]["status"] == "success"
+1 -1
View File
@@ -225,7 +225,7 @@ async def test_cron_and_remote_trigger_tools(tmp_path: Path, monkeypatch):
context = ToolExecutionContext(cwd=tmp_path)
create_result = await CronCreateTool().execute(
CronCreateToolInput(name="nightly", schedule="daily", command="printf 'CRON_OK'"),
CronCreateToolInput(name="nightly", schedule="0 0 * * *", command="printf 'CRON_OK'"),
context,
)
assert create_result.is_error is False
+1 -1
View File
@@ -224,7 +224,7 @@ async def test_notebook_and_cron_flow_across_registry(tmp_path: Path, monkeypatc
assert "flow ok" in (tmp_path / "nb" / "demo.ipynb").read_text(encoding="utf-8")
await cron_create.execute(
cron_create.input_model(name="flow", schedule="daily", command="printf 'FLOW_CRON_OK'"),
cron_create.input_model(name="flow", schedule="0 0 * * *", command="printf 'FLOW_CRON_OK'"),
context,
)
list_result = await cron_list.execute(cron_list.input_model(), context)