Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 126 additions & 7 deletions cron/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1261,6 +1261,7 @@ def create_job(
workdir: Optional[str] = None,
no_agent: bool = False,
attach_to_session: Optional[bool] = None,
profile: Optional[str] = None,
) -> Dict[str, Any]:
"""
Create a new cron job.
Expand Down Expand Up @@ -1426,6 +1427,7 @@ def create_job(
"origin": origin, # Tracks where job was created for "origin" delivery
"enabled_toolsets": normalized_toolsets,
"workdir": normalized_workdir,
"profile": str(profile).strip() if isinstance(profile, str) and profile.strip() else None,
}
# Only persist attach_to_session when explicitly set, so existing jobs and
# the common case stay byte-identical (absent key => fall back to the
Expand Down Expand Up @@ -1531,6 +1533,11 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
updates["workdir"] = _normalize_workdir(_wd)

previous_inference_axes = _normalized_inference_axes(job)

# Normalize profile — empty string clears the field.
if "profile" in updates:
_p = updates["profile"]
updates["profile"] = str(_p).strip() if isinstance(_p, str) and _p.strip() else None
updated = _apply_skill_fields({**job, **updates})
schedule_changed = "schedule" in updates
inference_fields_changed = bool(
Expand Down Expand Up @@ -1738,8 +1745,19 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None,

# Check if we've hit the repeat limit
if times is not None and times > 0 and completed >= times:
# Remove the job (limit reached)
jobs.pop(i)
# Limit reached: retain the record as a terminal
# completion instead of popping it. Deleting the job
# here discarded the last_status / last_error /
# last_delivery_error written above — a finished
# one-shot vanished from `cronjob list` with no
# inspectable outcome, and a failed delivery was
# invisible. Mirror the terminal shape of the
# next_run_at-is-None branch below; the retention
# sweep prunes these after
# COMPLETED_ONESHOT_RETENTION_DAYS.
job["enabled"] = False
job["state"] = "completed"
job["next_run_at"] = None
save_jobs(jobs)
return

Expand Down Expand Up @@ -1857,13 +1875,29 @@ def claim_dispatch(job_id: str) -> bool:
return True # infinite — always dispatch
completed = repeat.get("completed", 0)
if completed >= times:
# Already dispatched the max number of times (e.g. a prior
# tick claimed then died before mark_job_run could remove it).
# Clean up so it stops appearing as due on every tick.
# Already dispatched the max number of times.
if job.get("last_run_at") is not None:
# A prior run completed normally (e.g. mark_job_run raced
# with this tick). Retain the terminal record — same shape
# as mark_job_run's repeat-limit branch — instead of
# deleting the job and its final status/delivery error.
job["enabled"] = False
job["state"] = "completed"
job["next_run_at"] = None
save_jobs(jobs)
logger.info(
"Job '%s': dispatch limit reached (%d/%d) — marking completed",
job.get("name", job.get("id", "?")),
completed,
times,
)
return False
# A prior tick claimed the dispatch then died before the run
# completed (#73973) — a genuinely wedged claim. Remove it so
# it stops appearing as due, and leave an operator-visible
# diagnostic instead of vanishing silently.
jobs.pop(i)
save_jobs(jobs)
# If the claimed run never completed (#73973), leave an
# operator-visible diagnostic instead of vanishing silently.
_write_wedged_oneshot_diagnostic(job)
logger.info(
"Job '%s': dispatch limit reached (%d/%d) — removing",
Expand Down Expand Up @@ -2049,6 +2083,82 @@ def claim_job_for_fire(job_id: str, *, claim_ttl_seconds: int = 300) -> bool:
return False


# Completed one-shot job records are retained in jobs.json (final status +
# delivery error stay inspectable via `cronjob list`) instead of being deleted
# at completion, then pruned by _sweep_completed_oneshots once they age out.
COMPLETED_ONESHOT_RETENTION_DAYS = 7


def _completed_oneshot_retention_days() -> float:
"""Resolve the completed one-shot retention window from config.

``cron.completed_retention_days`` (number, default
``COMPLETED_ONESHOT_RETENTION_DAYS``). A non-positive value disables the
sweep, retaining completed one-shot records indefinitely.
"""
try:
from hermes_cli.config import load_config
cfg = load_config() or {}
cron_cfg = cfg.get("cron", {}) if isinstance(cfg, dict) else {}
return float(
cron_cfg.get(
"completed_retention_days", COMPLETED_ONESHOT_RETENTION_DAYS
)
)
except Exception:
return float(COMPLETED_ONESHOT_RETENTION_DAYS)


def _sweep_completed_oneshots(raw_jobs: List[Dict[str, Any]], now: datetime) -> bool:
"""Prune terminal ``state == "completed"`` one-shot records past retention.

Mutates *raw_jobs* in place; returns True when anything was removed (the
caller persists). Only one-shot (``schedule.kind == "once"``) records in
the terminal completed state are candidates; recurring jobs and non-
terminal one-shots are never touched. Age is measured from
``last_run_at`` — a completed record without a parseable ``last_run_at``
is kept (never guess a record into deletion).
"""
retention_days = _completed_oneshot_retention_days()
if retention_days <= 0:
return False
cutoff = now - timedelta(days=retention_days)
removed = False
for rj in list(raw_jobs):
try:
if rj.get("state") != "completed":
continue
schedule = rj.get("schedule")
kind = schedule.get("kind") if isinstance(schedule, dict) else None
if kind != "once":
continue
last_run = rj.get("last_run_at")
if not isinstance(last_run, str):
continue
try:
last_run_dt = _ensure_aware(datetime.fromisoformat(last_run))
except Exception:
continue
if last_run_dt >= cutoff:
continue
raw_jobs.remove(rj)
removed = True
logger.info(
"Job '%s': pruning completed one-shot record "
"(finished %s, retention %.1f days)",
rj.get("name", rj.get("id", "?")),
last_run,
retention_days,
)
except Exception:
logger.debug(
"Retention sweep skipped malformed job record %r",
rj.get("id", "?"),
exc_info=True,
)
return removed


def get_due_jobs() -> List[Dict[str, Any]]:
"""Get all jobs that are due to run now.

Expand Down Expand Up @@ -2168,6 +2278,15 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]:
# (derived from HERMES_CRON_TIMEOUT). See _oneshot_run_claim_ttl_seconds.
_run_claim_ttl = _oneshot_run_claim_ttl_seconds()

# Retention sweep: completed one-shots are retained (so their final
# status / delivery error stay inspectable via `cronjob list`) instead of
# being deleted on completion, but they must not accumulate in jobs.json
# forever. Prune terminal one-shot records older than the retention
# window each scan.
if _sweep_completed_oneshots(raw_jobs, now):
needs_save = True
jobs = [j for j in jobs if any(rj.get("id") == j.get("id") for rj in raw_jobs)]

for job in jobs:
# Per-job containment (structural guard): one malformed or
# unexpected job record must never abort the whole scan. The id /
Expand Down
6 changes: 6 additions & 0 deletions hermes_cli/cli_commands_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1446,6 +1446,7 @@ def _parse_flags(tokens):
"all": False,
"prompt": None,
"schedule": None,
"profile": None,
"positionals": [],
}
i = 0
Expand Down Expand Up @@ -1485,6 +1486,9 @@ def _parse_flags(tokens):
elif token == "--schedule" and i + 1 < len(tokens):
opts["schedule"] = tokens[i + 1]
i += 2
elif token == "--profile" and i + 1 < len(tokens):
opts["profile"] = tokens[i + 1]
i += 2
else:
opts["positionals"].append(token)
i += 1
Expand Down Expand Up @@ -1577,6 +1581,7 @@ def _parse_flags(tokens):
deliver=opts["deliver"],
repeat=opts["repeat"],
skills=skills or None,
profile=opts["profile"],
)
if result.get("success"):
print(f"(^_^)b Created job: {result['job_id']}")
Expand Down Expand Up @@ -1623,6 +1628,7 @@ def _parse_flags(tokens):
deliver=opts["deliver"],
repeat=opts["repeat"],
skills=final_skills,
profile=opts["profile"],
)
if result.get("success"):
job = result["job"]
Expand Down
9 changes: 9 additions & 0 deletions hermes_cli/cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ def cron_list(show_all: bool = False):
workdir = job.get("workdir")
if workdir:
print(f" Workdir: {workdir}")
profile_name = job.get("profile")
if profile_name:
print(f" Profile: {profile_name}")

# Execution history
last_status = job.get("last_status")
Expand Down Expand Up @@ -352,6 +355,7 @@ def cron_create(args):
model=getattr(args, "model", None),
provider=getattr(args, "model_provider", None),
no_agent=getattr(args, "no_agent", False) or None,
profile=getattr(args, "profile", None),
)
if not result.get("success"):
print(color(f"Failed to create job: {result.get('error', 'unknown error')}", Colors.RED))
Expand All @@ -368,6 +372,8 @@ def cron_create(args):
print(" Mode: no-agent (script stdout delivered directly)")
if job_data.get("workdir"):
print(f" Workdir: {job_data['workdir']}")
if job_data.get("profile"):
print(f" Profile: {job_data['profile']}")
print(f" Next run: {result['next_run_at']}")
_warn_if_gateway_not_running()
return 0
Expand Down Expand Up @@ -417,6 +423,7 @@ def cron_edit(args):
model=getattr(args, "model", None),
provider=getattr(args, "model_provider", None),
no_agent=getattr(args, "no_agent", None),
profile=getattr(args, "profile", None),
)
if not result.get("success"):
print(color(f"Failed to update job: {result.get('error', 'unknown error')}", Colors.RED))
Expand All @@ -436,6 +443,8 @@ def cron_edit(args):
print(" Mode: no-agent (script stdout delivered directly)")
if updated.get("workdir"):
print(f" Workdir: {updated['workdir']}")
if updated.get("profile"):
print(f" Profile: {updated['profile']}")
return 0


Expand Down
25 changes: 22 additions & 3 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,9 +566,25 @@ def _resolve_sudo_user_profile_env(name: str) -> str | None:
return None
return None

def _inside_cron_edit(index: int) -> bool:
"""True once argv reaches ``cron edit`` sub-subcommand.

``cron edit`` has its own ``--profile`` argument that sets the job's
profile field — it does NOT mean "switch the active profile context".
So we must NOT consume ``--profile`` after ``cron edit``.
"""
try:
cron_idx = argv.index("cron", 0, index)
argv.index("edit", cron_idx + 1, index)
except ValueError:
return False
return True

# 1. Check for explicit -p / --profile flag. Historically this worked even
# after the subcommand (`hermes chat -p coder`), so keep scanning broadly.
# The exception is command-argv passthrough regions such as `mcp add --args`.
# The exception is command-argv passthrough regions such as `mcp add --args`
# and ``cron edit`` which uses ``--profile`` as a job field.
# Exempt ``cron edit`` — ``--profile`` there sets the job's profile field.
value_flags = {
"-z", "--oneshot",
"-m", "--model",
Expand All @@ -586,12 +602,15 @@ def _resolve_sudo_user_profile_env(name: str) -> str | None:
break
if arg == "--args" and _inside_mcp_add_args(i):
break
if arg in {"--profile", "-p"} and i + 1 < len(argv):
# Don't consume --profile after ``cron edit`` — it sets the job's
# profile field, not the active Hermes profile context.
if arg in {"--profile", "-p"} and i + 1 < len(argv) and not _inside_cron_edit(i):
profile_name = argv[i + 1]
consume = 2
profile_index = i
break
if arg.startswith("--profile="):
# Also handle ``--profile=value`` form — same cron edit exception.
if arg.startswith("--profile=") and not _inside_cron_edit(i):
profile_name = arg.split("=", 1)[1]
consume = 1
profile_index = i
Expand Down
10 changes: 9 additions & 1 deletion hermes_cli/subcommands/cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None:
)
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).",
help="Absolute path for the job to run from (injects AGENTS.md etc. and sets terminal cwd). Omit to preserve old behaviour (no project context files).",
)
cron_create.add_argument(
"--profile",
help="Assign this job to a specific profile. The job's cron data (db, output) stays in the profile's home.",
)
cron_create.add_argument(
"--model",
Expand Down Expand Up @@ -160,6 +164,10 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None:
dest="model_provider",
help="Inference provider paired with --model. Pass empty string to clear.",
)
cron_edit.add_argument(
"--profile",
help="Assign this job to a specific profile. The job's cron data (db, output) stays in the profile's home. Pass empty string to clear or omit to keep current.",
)

# lifecycle actions
cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job")
Expand Down
Loading
Loading