mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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.
This commit is contained in:
parent
0e235947b9
commit
852c7f3be3
7 changed files with 551 additions and 9 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue