From 852c7f3be34bdaddfb42d27f7c516c1301d0a55f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 24 Apr 2026 05:07:01 -0700 Subject: [PATCH] feat(cron): per-job workdir for project-aware cron runs (#15110) Cron jobs can now specify a per-job working directory. When set, the job runs as if launched from that directory: AGENTS.md / CLAUDE.md / .cursorrules from that dir are injected into the system prompt, and the terminal / file / code-exec tools use it as their cwd (via TERMINAL_CWD). When unset, old behaviour is preserved (no project context files, tools use the scheduler's cwd). Requested by @bluthcy. ## Mechanism - cron/jobs.py: create_job / update_job accept 'workdir'; validated to be an absolute existing directory at create/update time. - cron/scheduler.py run_job: if job.workdir is set, point TERMINAL_CWD at it and flip skip_context_files to False before building the agent. Restored in finally on every exit path. - cron/scheduler.py tick: workdir jobs run sequentially (outside the thread pool) because TERMINAL_CWD is process-global. Workdir-less jobs still run in the parallel pool unchanged. - tools/cronjob_tools.py + hermes_cli/cron.py + hermes_cli/main.py: expose 'workdir' via the cronjob tool and 'hermes cron create/edit --workdir ...'. Empty string on edit clears the field. ## Validation - tests/cron/test_cron_workdir.py (21 tests): normalize, create, update, JSON round-trip via cronjob tool, tick partition (workdir jobs run on the main thread, not the pool), run_job env toggle + restore in finally. - Full targeted suite (tests/cron/, test_cronjob_tools.py, test_cron.py, test_config_cwd_bridge.py, test_worktree.py): 314/314 passed. - Live smoke: hermes cron create --workdir $(pwd) works; relative path rejected; list shows 'Workdir:'; edit --workdir '' clears. --- cron/jobs.py | 51 +++ cron/scheduler.py | 67 +++- hermes_cli/cron.py | 9 + hermes_cli/main.py | 8 + tests/cron/test_cron_workdir.py | 380 +++++++++++++++++++++++ tools/cronjob_tools.py | 13 + website/docs/user-guide/features/cron.md | 32 ++ 7 files changed, 551 insertions(+), 9 deletions(-) create mode 100644 tests/cron/test_cron_workdir.py diff --git a/cron/jobs.py b/cron/jobs.py index 4d34b1534..158f53654 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -371,6 +371,39 @@ def save_jobs(jobs: List[Dict[str, Any]]): raise +def _normalize_workdir(workdir: Optional[str]) -> Optional[str]: + """Normalize and validate a cron job workdir. + + Rules: + - Empty / None → None (feature off, preserves old behaviour). + - ``~`` is expanded. Relative paths are rejected — cron jobs run detached + from any shell cwd, so relative paths have no stable meaning. + - The path must exist and be a directory at create/update time. We do + NOT re-check at run time (a user might briefly unmount the dir; the + scheduler will just fall back to old behaviour with a logged warning). + + Returns the absolute path string, or None when disabled. + Raises ValueError on invalid input. + """ + if workdir is None: + return None + raw = str(workdir).strip() + if not raw: + return None + expanded = Path(raw).expanduser() + if not expanded.is_absolute(): + raise ValueError( + f"Cron workdir must be an absolute path (got {raw!r}). " + f"Cron jobs run detached from any shell cwd, so relative paths are ambiguous." + ) + resolved = expanded.resolve() + if not resolved.exists(): + raise ValueError(f"Cron workdir does not exist: {resolved}") + if not resolved.is_dir(): + raise ValueError(f"Cron workdir is not a directory: {resolved}") + return str(resolved) + + def create_job( prompt: str, schedule: str, @@ -385,6 +418,7 @@ def create_job( base_url: Optional[str] = None, script: Optional[str] = None, enabled_toolsets: Optional[List[str]] = None, + workdir: Optional[str] = None, ) -> Dict[str, Any]: """ Create a new cron job. @@ -407,6 +441,12 @@ def create_job( enabled_toolsets: Optional list of toolset names to restrict the agent to. When set, only tools from these toolsets are loaded, reducing token overhead. When omitted, all default tools are loaded. + workdir: Optional absolute path. When set, the job runs as if launched + from that directory: AGENTS.md / CLAUDE.md / .cursorrules from + that directory are injected into the system prompt, and the + terminal/file/code_exec tools use it as their working directory + (via TERMINAL_CWD). When unset, the old behaviour is preserved + (no context files injected, tools use the scheduler's cwd). Returns: The created job dict @@ -439,6 +479,7 @@ def create_job( normalized_script = normalized_script or None normalized_toolsets = [str(t).strip() for t in enabled_toolsets if str(t).strip()] if enabled_toolsets else None normalized_toolsets = normalized_toolsets or None + normalized_workdir = _normalize_workdir(workdir) label_source = (prompt or (normalized_skills[0] if normalized_skills else None)) or "cron job" job = { @@ -471,6 +512,7 @@ def create_job( "deliver": deliver, "origin": origin, # Tracks where job was created for "origin" delivery "enabled_toolsets": normalized_toolsets, + "workdir": normalized_workdir, } jobs = load_jobs() @@ -504,6 +546,15 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]] if job["id"] != job_id: continue + # Validate / normalize workdir if present in updates. Empty string or + # None both mean "clear the field" (restore old behaviour). + if "workdir" in updates: + _wd = updates["workdir"] + if _wd in (None, "", False): + updates["workdir"] = None + else: + updates["workdir"] = _normalize_workdir(_wd) + updated = _apply_skill_fields({**job, **updates}) schedule_changed = "schedule" in updates diff --git a/cron/scheduler.py b/cron/scheduler.py index d051a7ab3..0355d2475 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -795,6 +795,30 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: chat_name=origin.get("chat_name", "") if origin else "", ) + # Per-job working directory. When set (and validated at create/update + # time), we point TERMINAL_CWD at it so: + # - build_context_files_prompt() picks up AGENTS.md / CLAUDE.md / + # .cursorrules from the job's project dir, AND + # - the terminal, file, and code-exec tools run commands from there. + # + # tick() serializes workdir-jobs outside the parallel pool, so mutating + # os.environ["TERMINAL_CWD"] here is safe for those jobs. For workdir-less + # jobs we leave TERMINAL_CWD untouched — preserves the original behaviour + # (skip_context_files=True, tools use whatever cwd the scheduler has). + _job_workdir = (job.get("workdir") or "").strip() or None + if _job_workdir and not Path(_job_workdir).is_dir(): + # Directory was removed between create-time validation and now. Log + # and drop back to old behaviour rather than crashing the job. + logger.warning( + "Job '%s': configured workdir %r no longer exists — running without it", + job_id, _job_workdir, + ) + _job_workdir = None + _prior_terminal_cwd = os.environ.get("TERMINAL_CWD", "_UNSET_") + if _job_workdir: + os.environ["TERMINAL_CWD"] = _job_workdir + logger.info("Job '%s': using workdir %s", job_id, _job_workdir) + try: # Re-read .env and config.yaml fresh every run so provider/key # changes take effect without a gateway restart. @@ -920,7 +944,10 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: enabled_toolsets=_resolve_cron_enabled_toolsets(job, _cfg), disabled_toolsets=["cronjob", "messaging", "clarify"], quiet_mode=True, - skip_context_files=True, # Don't inject SOUL.md/AGENTS.md from scheduler cwd + # When a workdir is configured, inject AGENTS.md / CLAUDE.md / + # .cursorrules from that directory; otherwise preserve the old + # behaviour (don't inject SOUL.md/AGENTS.md from the scheduler cwd). + skip_context_files=not bool(_job_workdir), skip_memory=True, # Cron system prompts would corrupt user representations platform="cron", session_id=_cron_session_id, @@ -1059,6 +1086,14 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: return False, output, "", error_msg finally: + # Restore TERMINAL_CWD to whatever it was before this job ran. We + # only ever mutate it when the job has a workdir; see the setup block + # at the top of run_job for the serialization guarantee. + if _job_workdir: + if _prior_terminal_cwd == "_UNSET_": + os.environ.pop("TERMINAL_CWD", None) + else: + os.environ["TERMINAL_CWD"] = _prior_terminal_cwd # Clean up ContextVar session/delivery state for this job. clear_session_vars(_ctx_tokens) if _session_db: @@ -1186,14 +1221,28 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int: mark_job_run(job["id"], False, str(e)) return False - # Run all due jobs concurrently, each in its own ContextVar copy - # so session/delivery state stays isolated per-thread. - with concurrent.futures.ThreadPoolExecutor(max_workers=_max_workers) as _tick_pool: - _futures = [] - for job in due_jobs: - _ctx = contextvars.copy_context() - _futures.append(_tick_pool.submit(_ctx.run, _process_job, job)) - _results = [f.result() for f in _futures] + # Partition due jobs: those with a per-job workdir mutate + # os.environ["TERMINAL_CWD"] inside run_job, which is process-global — + # so they MUST run sequentially to avoid corrupting each other. Jobs + # without a workdir leave env untouched and stay parallel-safe. + workdir_jobs = [j for j in due_jobs if (j.get("workdir") or "").strip()] + parallel_jobs = [j for j in due_jobs if not (j.get("workdir") or "").strip()] + + _results: list = [] + + # Sequential pass for workdir jobs. + for job in workdir_jobs: + _ctx = contextvars.copy_context() + _results.append(_ctx.run(_process_job, job)) + + # Parallel pass for the rest — same behaviour as before. + if parallel_jobs: + with concurrent.futures.ThreadPoolExecutor(max_workers=_max_workers) as _tick_pool: + _futures = [] + for job in parallel_jobs: + _ctx = contextvars.copy_context() + _futures.append(_tick_pool.submit(_ctx.run, _process_job, job)) + _results.extend(f.result() for f in _futures) return sum(_results) finally: diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index e0ab6007a..78639d465 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -93,6 +93,9 @@ def cron_list(show_all: bool = False): script = job.get("script") if script: print(f" Script: {script}") + workdir = job.get("workdir") + if workdir: + print(f" Workdir: {workdir}") # Execution history last_status = job.get("last_status") @@ -168,6 +171,7 @@ def cron_create(args): skill=getattr(args, "skill", None), skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)), script=getattr(args, "script", None), + workdir=getattr(args, "workdir", None), ) if not result.get("success"): print(color(f"Failed to create job: {result.get('error', 'unknown error')}", Colors.RED)) @@ -180,6 +184,8 @@ def cron_create(args): job_data = result.get("job", {}) if job_data.get("script"): print(f" Script: {job_data['script']}") + if job_data.get("workdir"): + print(f" Workdir: {job_data['workdir']}") print(f" Next run: {result['next_run_at']}") return 0 @@ -218,6 +224,7 @@ def cron_edit(args): repeat=getattr(args, "repeat", None), skills=final_skills, script=getattr(args, "script", None), + workdir=getattr(args, "workdir", None), ) if not result.get("success"): print(color(f"Failed to update job: {result.get('error', 'unknown error')}", Colors.RED)) @@ -233,6 +240,8 @@ def cron_edit(args): print(" Skills: none") if updated.get("script"): print(f" Script: {updated['script']}") + if updated.get("workdir"): + print(f" Workdir: {updated['workdir']}") return 0 diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 7dd2e1b2e..14cca2f03 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7429,6 +7429,10 @@ For more help on a command: "--script", help="Path to a Python script whose stdout is injected into the prompt each run", ) + cron_create.add_argument( + "--workdir", + help="Absolute path for the job to run from. Injects AGENTS.md / CLAUDE.md / .cursorrules from that directory and uses it as the cwd for terminal/file/code_exec tools. Omit to preserve old behaviour (no project context files).", + ) # cron edit cron_edit = cron_subparsers.add_parser( @@ -7467,6 +7471,10 @@ For more help on a command: "--script", help="Path to a Python script whose stdout is injected into the prompt each run. Pass empty string to clear.", ) + cron_edit.add_argument( + "--workdir", + help="Absolute path for the job to run from (injects AGENTS.md etc. and sets terminal cwd). Pass empty string to clear.", + ) # lifecycle actions cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job") diff --git a/tests/cron/test_cron_workdir.py b/tests/cron/test_cron_workdir.py new file mode 100644 index 000000000..03777dd47 --- /dev/null +++ b/tests/cron/test_cron_workdir.py @@ -0,0 +1,380 @@ +"""Tests for per-job workdir support in cron jobs. + +Covers: + - jobs.create_job: param plumbing, validation, default-None preserved + - jobs._normalize_workdir: absolute / relative / missing / file-not-dir + - jobs.update_job: set, clear, re-validate + - tools.cronjob_tools.cronjob: create + update JSON round-trip, schema + includes workdir, _format_job exposes it when set + - scheduler.tick(): partitions workdir jobs off the thread pool, restores + TERMINAL_CWD in finally, honours the env override during run_job +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +@pytest.fixture() +def tmp_cron_dir(tmp_path, monkeypatch): + """Isolate cron job storage into a temp dir so tests don't stomp on real jobs.""" + monkeypatch.setattr("cron.jobs.CRON_DIR", tmp_path / "cron") + monkeypatch.setattr("cron.jobs.JOBS_FILE", tmp_path / "cron" / "jobs.json") + monkeypatch.setattr("cron.jobs.OUTPUT_DIR", tmp_path / "cron" / "output") + return tmp_path + + +# --------------------------------------------------------------------------- +# jobs._normalize_workdir +# --------------------------------------------------------------------------- + +class TestNormalizeWorkdir: + def test_none_returns_none(self): + from cron.jobs import _normalize_workdir + assert _normalize_workdir(None) is None + + def test_empty_string_returns_none(self): + from cron.jobs import _normalize_workdir + assert _normalize_workdir("") is None + assert _normalize_workdir(" ") is None + + def test_absolute_existing_dir_returns_resolved_str(self, tmp_path): + from cron.jobs import _normalize_workdir + result = _normalize_workdir(str(tmp_path)) + assert result == str(tmp_path.resolve()) + + def test_tilde_expands(self, tmp_path, monkeypatch): + from cron.jobs import _normalize_workdir + monkeypatch.setenv("HOME", str(tmp_path)) + result = _normalize_workdir("~") + assert result == str(tmp_path.resolve()) + + def test_relative_path_rejected(self): + from cron.jobs import _normalize_workdir + with pytest.raises(ValueError, match="absolute path"): + _normalize_workdir("some/relative/path") + + def test_missing_dir_rejected(self, tmp_path): + from cron.jobs import _normalize_workdir + missing = tmp_path / "does-not-exist" + with pytest.raises(ValueError, match="does not exist"): + _normalize_workdir(str(missing)) + + def test_file_not_dir_rejected(self, tmp_path): + from cron.jobs import _normalize_workdir + f = tmp_path / "file.txt" + f.write_text("hi") + with pytest.raises(ValueError, match="not a directory"): + _normalize_workdir(str(f)) + + +# --------------------------------------------------------------------------- +# jobs.create_job and update_job +# --------------------------------------------------------------------------- + +class TestCreateJobWorkdir: + def test_workdir_stored_when_set(self, tmp_cron_dir): + from cron.jobs import create_job, get_job + job = create_job( + prompt="hello", + schedule="every 1h", + workdir=str(tmp_cron_dir), + ) + stored = get_job(job["id"]) + assert stored["workdir"] == str(tmp_cron_dir.resolve()) + + def test_workdir_none_preserves_old_behaviour(self, tmp_cron_dir): + from cron.jobs import create_job, get_job + job = create_job(prompt="hello", schedule="every 1h") + stored = get_job(job["id"]) + # Field is present on the dict but None — downstream code checks + # truthiness to decide whether the feature is active. + assert stored.get("workdir") is None + + def test_create_rejects_invalid_workdir(self, tmp_cron_dir): + from cron.jobs import create_job + with pytest.raises(ValueError): + create_job( + prompt="hello", + schedule="every 1h", + workdir="not/absolute", + ) + + +class TestUpdateJobWorkdir: + def test_set_workdir_via_update(self, tmp_cron_dir): + from cron.jobs import create_job, get_job, update_job + job = create_job(prompt="x", schedule="every 1h") + update_job(job["id"], {"workdir": str(tmp_cron_dir)}) + assert get_job(job["id"])["workdir"] == str(tmp_cron_dir.resolve()) + + def test_clear_workdir_with_none(self, tmp_cron_dir): + from cron.jobs import create_job, get_job, update_job + job = create_job( + prompt="x", schedule="every 1h", workdir=str(tmp_cron_dir) + ) + update_job(job["id"], {"workdir": None}) + assert get_job(job["id"])["workdir"] is None + + def test_clear_workdir_with_empty_string(self, tmp_cron_dir): + from cron.jobs import create_job, get_job, update_job + job = create_job( + prompt="x", schedule="every 1h", workdir=str(tmp_cron_dir) + ) + update_job(job["id"], {"workdir": ""}) + assert get_job(job["id"])["workdir"] is None + + def test_update_rejects_invalid_workdir(self, tmp_cron_dir): + from cron.jobs import create_job, update_job + job = create_job(prompt="x", schedule="every 1h") + with pytest.raises(ValueError): + update_job(job["id"], {"workdir": "nope/relative"}) + + +# --------------------------------------------------------------------------- +# tools.cronjob_tools: end-to-end JSON round-trip +# --------------------------------------------------------------------------- + +class TestCronjobToolWorkdir: + def test_create_with_workdir_json_roundtrip(self, tmp_cron_dir): + from tools.cronjob_tools import cronjob + + result = json.loads( + cronjob( + action="create", + prompt="hi", + schedule="every 1h", + workdir=str(tmp_cron_dir), + ) + ) + assert result["success"] is True + assert result["job"]["workdir"] == str(tmp_cron_dir.resolve()) + + def test_create_without_workdir_hides_field_in_format(self, tmp_cron_dir): + from tools.cronjob_tools import cronjob + + result = json.loads( + cronjob( + action="create", + prompt="hi", + schedule="every 1h", + ) + ) + assert result["success"] is True + # _format_job omits the field when unset — reduces noise in agent output. + assert "workdir" not in result["job"] + + def test_update_clears_workdir_with_empty_string(self, tmp_cron_dir): + from tools.cronjob_tools import cronjob + + created = json.loads( + cronjob( + action="create", + prompt="hi", + schedule="every 1h", + workdir=str(tmp_cron_dir), + ) + ) + job_id = created["job_id"] + + updated = json.loads( + cronjob(action="update", job_id=job_id, workdir="") + ) + assert updated["success"] is True + assert "workdir" not in updated["job"] + + def test_schema_advertises_workdir(self): + from tools.cronjob_tools import CRONJOB_SCHEMA + assert "workdir" in CRONJOB_SCHEMA["parameters"]["properties"] + desc = CRONJOB_SCHEMA["parameters"]["properties"]["workdir"]["description"] + assert "absolute" in desc.lower() + + +# --------------------------------------------------------------------------- +# scheduler.tick(): workdir partition +# --------------------------------------------------------------------------- + +class TestTickWorkdirPartition: + """ + tick() must run workdir jobs sequentially (outside the ThreadPoolExecutor) + because run_job mutates os.environ["TERMINAL_CWD"], which is process-global. + We verify the partition without booting the real scheduler by patching the + pieces tick() calls. + """ + + def test_workdir_jobs_run_sequentially(self, tmp_path, monkeypatch): + import cron.scheduler as sched + + # Two "jobs" — one with workdir, one without. get_due_jobs returns both. + workdir_job = {"id": "a", "name": "A", "workdir": str(tmp_path)} + parallel_job = {"id": "b", "name": "B", "workdir": None} + + monkeypatch.setattr(sched, "get_due_jobs", lambda: [workdir_job, parallel_job]) + monkeypatch.setattr(sched, "advance_next_run", lambda *_a, **_kw: None) + + # Record call order / thread context. + import threading + calls: list[tuple[str, bool]] = [] + + def fake_run_job(job): + # Return a minimal tuple matching run_job's signature. + calls.append((job["id"], threading.current_thread().name)) + return True, "output", "response", None + + monkeypatch.setattr(sched, "run_job", fake_run_job) + monkeypatch.setattr(sched, "save_job_output", lambda _jid, _o: None) + monkeypatch.setattr(sched, "mark_job_run", lambda *_a, **_kw: None) + monkeypatch.setattr( + sched, "_deliver_result", lambda *_a, **_kw: None + ) + + n = sched.tick(verbose=False) + assert n == 2 + + ids = [c[0] for c in calls] + # Workdir jobs always come before parallel jobs. + assert ids.index("a") < ids.index("b") + + # The workdir job must run on the main thread (sequential pass). + main_thread_name = threading.current_thread().name + workdir_thread_name = next(t for jid, t in calls if jid == "a") + assert workdir_thread_name == main_thread_name + + +# --------------------------------------------------------------------------- +# scheduler.run_job: TERMINAL_CWD + skip_context_files wiring +# --------------------------------------------------------------------------- + +class TestRunJobTerminalCwd: + """ + run_job sets TERMINAL_CWD + flips skip_context_files=False when workdir + is set, and restores the prior TERMINAL_CWD in finally — even on error. + We stub AIAgent so no real API call happens. + """ + + @staticmethod + def _install_stubs(monkeypatch, observed: dict): + """Patch enough of run_job's deps that it executes without real creds.""" + import os + import sys + import cron.scheduler as sched + + class FakeAgent: + def __init__(self, **kwargs): + observed["skip_context_files"] = kwargs.get("skip_context_files") + observed["terminal_cwd_during_init"] = os.environ.get( + "TERMINAL_CWD", "_UNSET_" + ) + + def run_conversation(self, *_a, **_kw): + observed["terminal_cwd_during_run"] = os.environ.get( + "TERMINAL_CWD", "_UNSET_" + ) + return {"final_response": "done", "messages": []} + + def get_activity_summary(self): + return {"seconds_since_activity": 0.0} + + fake_mod = type(sys)("run_agent") + fake_mod.AIAgent = FakeAgent + monkeypatch.setitem(sys.modules, "run_agent", fake_mod) + + # Bypass the real provider resolver — it reads ~/.hermes and credentials. + from hermes_cli import runtime_provider as _rtp + monkeypatch.setattr( + _rtp, + "resolve_runtime_provider", + lambda **_kw: { + "provider": "test", + "api_key": "k", + "base_url": "http://test.local", + "api_mode": "chat_completions", + }, + ) + + # Stub scheduler helpers that would otherwise hit the filesystem / config. + monkeypatch.setattr(sched, "_build_job_prompt", lambda job, prerun_script=None: "hi") + monkeypatch.setattr(sched, "_resolve_origin", lambda job: None) + monkeypatch.setattr(sched, "_resolve_delivery_target", lambda job: None) + monkeypatch.setattr(sched, "_resolve_cron_enabled_toolsets", lambda job, cfg: None) + # Unlimited inactivity so the poll loop returns immediately. + monkeypatch.setenv("HERMES_CRON_TIMEOUT", "0") + + # run_job calls load_dotenv(~/.hermes/.env, override=True), which will + # happily clobber TERMINAL_CWD out from under us if the real user .env + # has TERMINAL_CWD set (common on dev boxes). Stub it out. + import dotenv + monkeypatch.setattr(dotenv, "load_dotenv", lambda *_a, **_kw: True) + + def test_workdir_sets_and_restores_terminal_cwd( + self, tmp_path, monkeypatch + ): + import os + import cron.scheduler as sched + + # Make sure the test's TERMINAL_CWD starts at a known non-workdir value. + # Use monkeypatch.setenv so it's restored on teardown regardless of + # whatever other tests in this xdist worker have left behind. + monkeypatch.setenv("TERMINAL_CWD", "/original/cwd") + + observed: dict = {} + self._install_stubs(monkeypatch, observed) + + job = { + "id": "abc", + "name": "wd-job", + "workdir": str(tmp_path), + "schedule_display": "manual", + } + + success, _output, response, error = sched.run_job(job) + assert success is True, f"run_job failed: error={error!r} response={response!r}" + + # AIAgent was built with skip_context_files=False (feature ON). + assert observed["skip_context_files"] is False + # TERMINAL_CWD was pointing at the job workdir while the agent ran. + assert observed["terminal_cwd_during_init"] == str(tmp_path.resolve()) + assert observed["terminal_cwd_during_run"] == str(tmp_path.resolve()) + + # And it was restored to the original value in finally. + assert os.environ["TERMINAL_CWD"] == "/original/cwd" + + def test_no_workdir_leaves_terminal_cwd_untouched(self, monkeypatch): + """When workdir is absent, run_job must not touch TERMINAL_CWD at all — + whatever value was present before the call should be present after. + + We don't assert on the *content* of TERMINAL_CWD (other tests in the + same xdist worker may leave it set to something like '.'); we just + check it's unchanged by run_job. + """ + import os + import cron.scheduler as sched + + # Pin TERMINAL_CWD to a sentinel via monkeypatch so we control both + # the before-value and the after-value regardless of cross-test state. + monkeypatch.setenv("TERMINAL_CWD", "/cron-test-sentinel") + before = os.environ["TERMINAL_CWD"] + + observed: dict = {} + self._install_stubs(monkeypatch, observed) + + job = { + "id": "xyz", + "name": "no-wd-job", + "workdir": None, + "schedule_display": "manual", + } + + success, *_ = sched.run_job(job) + assert success is True + + # Feature is OFF — skip_context_files stays True. + assert observed["skip_context_files"] is True + # TERMINAL_CWD saw the same value during init as it had before. + assert observed["terminal_cwd_during_init"] == before + # And after run_job completes, it's still the sentinel (nothing + # overwrote or cleared it). + assert os.environ["TERMINAL_CWD"] == before diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index b6aacf54e..88a28611e 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -217,6 +217,8 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: result["script"] = job["script"] if job.get("enabled_toolsets"): result["enabled_toolsets"] = job["enabled_toolsets"] + if job.get("workdir"): + result["workdir"] = job["workdir"] return result @@ -237,6 +239,7 @@ def cronjob( reason: Optional[str] = None, script: Optional[str] = None, enabled_toolsets: Optional[List[str]] = None, + workdir: Optional[str] = None, task_id: str = None, ) -> str: """Unified cron job management tool.""" @@ -275,6 +278,7 @@ def cronjob( base_url=_normalize_optional_job_value(base_url, strip_trailing_slash=True), script=_normalize_optional_job_value(script), enabled_toolsets=enabled_toolsets or None, + workdir=_normalize_optional_job_value(workdir), ) return json.dumps( { @@ -366,6 +370,10 @@ def cronjob( updates["script"] = _normalize_optional_job_value(script) if script else None if enabled_toolsets is not None: updates["enabled_toolsets"] = enabled_toolsets or None + if workdir is not None: + # Empty string clears the field (restores old behaviour); + # otherwise pass raw — update_job() validates / normalizes. + updates["workdir"] = _normalize_optional_job_value(workdir) or None if repeat is not None: # Normalize: treat 0 or negative as None (infinite) normalized_repeat = None if repeat <= 0 else repeat @@ -470,6 +478,10 @@ Important safety rule: cron-run sessions should not recursively schedule more cr "items": {"type": "string"}, "description": "Optional list of toolset names to restrict the job's agent to (e.g. [\"web\", \"terminal\", \"file\", \"delegation\"]). When set, only tools from these toolsets are loaded, significantly reducing input token overhead. When omitted, all default tools are loaded. Infer from the job's prompt — e.g. use \"web\" if it calls web_search, \"terminal\" if it runs scripts, \"file\" if it reads files, \"delegation\" if it calls delegate_task. On update, pass an empty array to clear." }, + "workdir": { + "type": "string", + "description": "Optional absolute path to run the job from. When set, AGENTS.md / CLAUDE.md / .cursorrules from that directory are injected into the system prompt, and the terminal/file/code_exec tools use it as their working directory — useful for running a job inside a specific project repo. Must be an absolute path that exists. When unset (default), preserves the original behaviour: no project context files, tools use the scheduler's cwd. On update, pass an empty string to clear. Jobs with workdir run sequentially (not parallel) to keep per-job directories isolated." + }, }, "required": ["action"] } @@ -515,6 +527,7 @@ registry.register( reason=args.get("reason"), script=args.get("script"), enabled_toolsets=args.get("enabled_toolsets"), + workdir=args.get("workdir"), task_id=kw.get("task_id"), ))(), check_fn=check_cronjob_requirements, diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index 85269faad..6eb7580bf 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -86,6 +86,38 @@ cronjob( This is useful when you want a scheduled agent to inherit reusable workflows without stuffing the full skill text into the cron prompt itself. +## Running a job inside a project directory + +Cron jobs default to running detached from any repo — no `AGENTS.md`, `CLAUDE.md`, or `.cursorrules` is loaded, and the terminal / file / code-exec tools run from whatever working directory the gateway started in. Pass `--workdir` (CLI) or `workdir=` (tool call) to change that: + +```bash +# Standalone CLI +hermes cron create --schedule "every 1d at 09:00" \ + --workdir /home/me/projects/acme \ + --prompt "Audit open PRs, summarize CI health, and post to #eng" +``` + +```python +# From a chat, via the cronjob tool +cronjob( + action="create", + schedule="every 1d at 09:00", + workdir="/home/me/projects/acme", + prompt="Audit open PRs, summarize CI health, and post to #eng", +) +``` + +When `workdir` is set: + +- `AGENTS.md`, `CLAUDE.md`, and `.cursorrules` from that directory are injected into the system prompt (same discovery order as the interactive CLI) +- `terminal`, `read_file`, `write_file`, `patch`, `search_files`, and `execute_code` all use that directory as their working directory (via `TERMINAL_CWD`) +- The path must be an absolute directory that exists — relative paths and missing directories are rejected at create / update time +- Pass `--workdir ""` (or `workdir=""` via the tool) on edit to clear it and restore the old behaviour + +:::note Serialization +Jobs with a `workdir` run sequentially on the scheduler tick, not in the parallel pool. This is deliberate — `TERMINAL_CWD` is process-global, so two workdir jobs running at the same time would corrupt each other's cwd. Workdir-less jobs still run in parallel as before. +::: + ## Editing jobs You do not need to delete and recreate jobs just to change them.