Files
hermes-agent/tests/cron/test_cron_empty_payload.py
ethernet 890bbbda1f Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts
#	apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts
#	apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts
#	apps/desktop/e2e/bot-mode-roster-localized.spec.ts
#	apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts
#	apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts
#	apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts
#	apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts
#	apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts
#	apps/desktop/e2e/bot-roster-user-sections.spec.ts
#	apps/desktop/e2e/bot-routines-pane-narrow.spec.ts
#	apps/desktop/e2e/bot-row-open-recent-session.spec.ts
#	apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts
#	apps/desktop/e2e/group-composer-auto-grow.spec.ts
#	apps/desktop/e2e/group-create-gate-remote-roster.spec.ts
#	apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts
#	apps/desktop/e2e/hosted-room-backend-continuity.spec.ts
#	apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts
#	apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts
#	apps/desktop/e2e/worktree-branch-status.spec.ts
#	apps/desktop/electron/backend-probes.test.ts
#	apps/desktop/electron/connection-apply.test.ts
#	apps/desktop/electron/desktop-electron-pin.test.ts
#	apps/desktop/electron/desktop-uninstall.test.ts
#	apps/desktop/electron/gateway-file-download-transport.test.ts
#	apps/desktop/electron/gateway-stop-before-update.test.ts
#	apps/desktop/electron/github-api-auth.test.ts
#	apps/desktop/electron/registry-primary-profile-scope.test.ts
#	apps/desktop/electron/update-api-check.test.ts
#	apps/desktop/electron/update-handoff-marker.test.ts
#	apps/desktop/electron/venv-blocker-scan.test.ts
#	apps/desktop/scripts/after-extract.test.mjs
#	apps/desktop/scripts/local-pack-publish.test.mjs
#	apps/desktop/scripts/tasks-scroll.test.mjs
#	apps/desktop/src/app/settings/model-settings.test.tsx
#	apps/desktop/src/app/updates-overlay.blockers.test.tsx
#	apps/desktop/src/components/desktop-install-overlay.test.tsx
#	apps/desktop/src/lib/update-copy.test.ts
#	scripts/ci/check_os_marker_fakes.py
#	tests-js/desktop-mac-usage-descriptions.test.ts
#	tests-js/node-engine-alignment.test.ts
#	tests/agent/lsp/test_install_and_lint_fixes.py
#	tests/agent/test_command_token_source.py
#	tests/agent/test_compression_boundary_hook.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/agent/test_custom_provider_ca_probes.py
#	tests/agent/test_endpoint_blackhole.py
#	tests/agent/test_estimator_parity.py
#	tests/agent/test_in_place_compaction.py
#	tests/agent/test_moa_loop_mode.py
#	tests/agent/test_model_metadata.py
#	tests/agent/test_skill_session_platform_gate.py
#	tests/agent/test_skill_utils.py
#	tests/agent/test_ssl_ca_guard.py
#	tests/computer_use/test_doctor.py
#	tests/cron/test_codex_execution_paths.py
#	tests/cron/test_cron_bot_chat_delivery.py
#	tests/cron/test_cron_script.py
#	tests/cron/test_media_delivery_parity.py
#	tests/cron/test_misfire_catchup.py
#	tests/cron/test_parallel_pool.py
#	tests/cron/test_recurring_eagain_redispatch.py
#	tests/gateway/test_choice_picker.py
#	tests/gateway/test_control_socket_windows_live.py
#	tests/gateway/test_dingtalk.py
#	tests/gateway/test_feishu.py
#	tests/gateway/test_feishu_onboard.py
#	tests/gateway/test_gateway_shutdown.py
#	tests/gateway/test_matrix.py
#	tests/gateway/test_model_command_custom_providers.py
#	tests/gateway/test_reasoning_command.py
#	tests/gateway/test_runtime_footer.py
#	tests/gateway/test_session.py
#	tests/gateway/test_session_hygiene.py
#	tests/gateway/test_status.py
#	tests/gateway/test_teams.py
#	tests/gateway/test_turn_lease.py
#	tests/gateway/test_whatsapp_connect.py
#	tests/hermes_cli/test_approvals_command.py
#	tests/hermes_cli/test_auth_store_lock_concurrent.py
#	tests/hermes_cli/test_backup.py
#	tests/hermes_cli/test_banner_git_state.py
#	tests/hermes_cli/test_certifi_repair.py
#	tests/hermes_cli/test_cmd_update.py
#	tests/hermes_cli/test_compat_manifest_targets.py
#	tests/hermes_cli/test_computer_use_cli.py
#	tests/hermes_cli/test_cpr_local_leak.py
#	tests/hermes_cli/test_dashboard_auth_gate.py
#	tests/hermes_cli/test_dashboard_procs_kill_grace.py
#	tests/hermes_cli/test_desktop_lifecycle_windows_live.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_doctor_command_install.py
#	tests/hermes_cli/test_fleet_config_migration_windows_live.py
#	tests/hermes_cli/test_gateway.py
#	tests/hermes_cli/test_gateway_platform_gating.py
#	tests/hermes_cli/test_gateway_restart_loop.py
#	tests/hermes_cli/test_gateway_task_probe.py
#	tests/hermes_cli/test_gateway_wsl.py
#	tests/hermes_cli/test_gui_command.py
#	tests/hermes_cli/test_install_cua_driver.py
#	tests/hermes_cli/test_kanban_db.py
#	tests/hermes_cli/test_lazy_command_exports.py
#	tests/hermes_cli/test_lazy_refresh_venv_repair.py
#	tests/hermes_cli/test_linux_desktop_entry.py
#	tests/hermes_cli/test_local_runtime.py
#	tests/hermes_cli/test_local_runtime_updates.py
#	tests/hermes_cli/test_managed_uv.py
#	tests/hermes_cli/test_mcp_reload_confirm_gate.py
#	tests/hermes_cli/test_nous_subscription.py
#	tests/hermes_cli/test_npm_engine.py
#	tests/hermes_cli/test_personality_none.py
#	tests/hermes_cli/test_pet_toggle.py
#	tests/hermes_cli/test_plan_reconciliation_windows_live.py
#	tests/hermes_cli/test_plugin_event_bus.py
#	tests/hermes_cli/test_plugin_manifest_v2.py
#	tests/hermes_cli/test_plugin_packs.py
#	tests/hermes_cli/test_plugins_cmd.py
#	tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py
#	tests/hermes_cli/test_process_identity.py
#	tests/hermes_cli/test_profiles.py
#	tests/hermes_cli/test_profiles_sidebar_cache.py
#	tests/hermes_cli/test_pty_bridge.py
#	tests/hermes_cli/test_resolve_turn_limit.py
#	tests/hermes_cli/test_serve_runtime_inventory.py
#	tests/hermes_cli/test_session_vacuum_config.py
#	tests/hermes_cli/test_set_config_value.py
#	tests/hermes_cli/test_signal_handler_kanban_worker.py
#	tests/hermes_cli/test_slash_confirm_windows.py
#	tests/hermes_cli/test_stale_pid_guard.py
#	tests/hermes_cli/test_startup_fast_guards.py
#	tests/hermes_cli/test_status.py
#	tests/hermes_cli/test_telegram_managed_bot.py
#	tests/hermes_cli/test_tools_config.py
#	tests/hermes_cli/test_update_apply_shallow_count.py
#	tests/hermes_cli/test_update_autostash.py
#	tests/hermes_cli/test_update_concurrent_quarantine.py
#	tests/hermes_cli/test_update_fetch_failure_classifier.py
#	tests/hermes_cli/test_update_fleet_probe_resume_token.py
#	tests/hermes_cli/test_update_handoff_backend_reap.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_update_head_moved_gate.py
#	tests/hermes_cli/test_update_host_obligation.py
#	tests/hermes_cli/test_update_import_guard.py
#	tests/hermes_cli/test_update_interrupted_recovery.py
#	tests/hermes_cli/test_update_inventory.py
#	tests/hermes_cli/test_update_launchd_unloaded_gateway.py
#	tests/hermes_cli/test_update_missing_configured_deps.py
#	tests/hermes_cli/test_update_modified_notice.py
#	tests/hermes_cli/test_update_multiplex_migration_hook.py
#	tests/hermes_cli/test_update_no_gateway_restart.py
#	tests/hermes_cli/test_update_orphan_backend_reap.py
#	tests/hermes_cli/test_update_parked_branch_guard.py
#	tests/hermes_cli/test_update_post_pull_syntax_guard.py
#	tests/hermes_cli/test_update_receipt.py
#	tests/hermes_cli/test_update_self_lock.py
#	tests/hermes_cli/test_update_shim_fail_closed.py
#	tests/hermes_cli/test_update_shim_self_lock.py
#	tests/hermes_cli/test_update_sqlite_remediation.py
#	tests/hermes_cli/test_update_stale_dashboard.py
#	tests/hermes_cli/test_update_stale_virtualenv.py
#	tests/hermes_cli/test_update_venv_health.py
#	tests/hermes_cli/test_update_venv_ownership_preflight.py
#	tests/hermes_cli/test_update_wedged_gateway.py
#	tests/hermes_cli/test_update_yes_flag.py
#	tests/hermes_cli/test_update_zip_two_phase.py
#	tests/hermes_cli/test_urllib_security.py
#	tests/hermes_cli/test_ux_messages_auth_config.py
#	tests/hermes_cli/test_ux_messages_startup.py
#	tests/hermes_cli/test_venv_holder_classifier.py
#	tests/hermes_cli/test_verify_console_scripts.py
#	tests/hermes_cli/test_verify_core_dependencies.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_cli/test_web_server_console_ws.py
#	tests/hermes_cli/test_web_server_ws_ping.py
#	tests/hermes_cli/test_web_ui_build.py
#	tests/hermes_state/test_fts_rebuild_admission.py
#	tests/hermes_state/test_hermes_state.py
#	tests/plugins/memory/test_memory_lazy_install.py
#	tests/plugins/test_google_meet_plugin.py
#	tests/plugins/test_langfuse_plugin.py
#	tests/plugins/test_security_guidance_plugin.py
#	tests/plugins/test_transform_llm_output_hook.py
#	tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_contributor_map.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/skills/test_competitor_news_monitor_skill.py
#	tests/skills/test_document_to_action_items_skill.py
#	tests/skills/test_google_workspace_setup.py
#	tests/skills/test_google_workspace_setup_deps.py
#	tests/skills/test_grounded_citations_skill.py
#	tests/skills/test_ip_as_logo_skill.py
#	tests/skills/test_live_dashboard_skill.py
#	tests/skills/test_mcp_oauth_remote_gateway_skill.py
#	tests/skills/test_office_document_skills.py
#	tests/skills/test_openclaw_migration.py
#	tests/skills/test_product_price_monitor_skill.py
#	tests/skills/test_scrollcraft_skill.py
#	tests/skills/test_setup_wizard_generator_skill.py
#	tests/skills/test_weekly_review_planning_skill.py
#	tests/test_engines_satisfiable.py
#	tests/test_fast_safe_load.py
#	tests/test_hermes_bootstrap.py
#	tests/test_hermes_constants.py
#	tests/test_hermes_logging.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_model_tools_async_bridge.py
#	tests/test_packaging_build_guard.py
#	tests/test_packaging_metadata.py
#	tests/test_yaml_indent_consistency.py
#	tests/tools/test_approval_timeout_overflow.py
#	tests/tools/test_base_environment.py
#	tests/tools/test_bot_mode_dm.py
#	tests/tools/test_browser_chromium_check.py
#	tests/tools/test_browser_hardening.py
#	tests/tools/test_browser_homebrew_paths.py
#	tests/tools/test_browser_npx_warmup.py
#	tests/tools/test_browser_orphan_reaper.py
#	tests/tools/test_browser_real_profile.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_clipboard.py
#	tests/tools/test_code_execution.py
#	tests/tools/test_code_execution_modes.py
#	tests/tools/test_code_execution_windows_env.py
#	tests/tools/test_computer_use.py
#	tests/tools/test_delegate_liveness_timeout.py
#	tests/tools/test_execute_code_approval_cluster.py
#	tests/tools/test_execution_flag_detection.py
#	tests/tools/test_fal_common.py
#	tests/tools/test_file_operations.py
#	tests/tools/test_file_tools.py
#	tests/tools/test_file_tools_cwd_resolution.py
#	tests/tools/test_file_tools_live.py
#	tests/tools/test_lazy_deps.py
#	tests/tools/test_lazy_deps_durable_target.py
#	tests/tools/test_lazy_deps_managed.py
#	tests/tools/test_local_env_blocklist.py
#	tests/tools/test_local_tempdir.py
#	tests/tools/test_macos_protected_search.py
#	tests/tools/test_mcp_npx_cached_bin.py
#	tests/tools/test_oneshot_completion_linger.py
#	tests/tools/test_process_registry.py
#	tests/tools/test_read_file_schema_gating.py
#	tests/tools/test_skill_improvements.py
#	tests/tools/test_skills_sync.py
#	tests/tools/test_termux_api_detection.py
#	tests/tools/test_tirith_security.py
#	tests/tools/test_transcription_tools.py
#	tests/tools/test_tts_streaming.py
#	tests/tools/test_wake_word.py
#	tests/tui_gateway/test_compute_host_borrowed_lease.py
#	tests/tui_gateway/test_compute_host_turn_protocol.py
#	tests/tui_gateway/test_isolated_orphan_activity.py
#	tests/tui_gateway/test_protocol.py
#	tests/tui_gateway/test_slash_worker_profile_home.py
#	tests/tui_gateway/test_subprocess_encoding.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	ui-tui/src/__tests__/terminalParity.test.ts
#	ui-tui/src/__tests__/termuxComposerLayout.test.ts
#	ui-tui/src/__tests__/textInputFastEcho.test.ts
2026-09-23 07:02:44 -04:00

459 lines
17 KiB
Python

"""Regression tests for empty-payload cron jobs (incident a5e29e688dc0).
A job whose effective runnable payload is nothing — blank/whitespace prompt,
no script, no skills — used to be creatable, updatable into existence, and
would wake the LLM with an empty instruction on every fire.
Covers:
* ``create_job`` / ``update_job`` rejecting the empty shape (merged record).
* Valid shapes staying valid: no_agent+script, agent script-only, prompt-only,
skills-only.
* ``scheduler.run_job`` failing closed on a legacy/hand-edited record before
constructing the agent, and pausing it so it stops being scheduled.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
@pytest.fixture
def hermes_env(tmp_path, monkeypatch):
"""Isolate HERMES_HOME for each test so jobs/scripts don't leak."""
home = tmp_path / ".hermes"
home.mkdir()
(home / "scripts").mkdir()
(home / "cron").mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
import importlib
import hermes_constants
importlib.reload(hermes_constants)
import cron.jobs
importlib.reload(cron.jobs)
import cron.scheduler
importlib.reload(cron.scheduler)
return home
# ---------------------------------------------------------------------------
# create_job validation
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("prompt", [None, "", " ", "\n\t "])
def test_create_job_rejects_empty_payload(hermes_env, prompt):
from cron.jobs import create_job
with pytest.raises(ValueError, match="nothing to run"):
create_job(prompt=prompt, schedule="every 5m")
def test_create_job_rejects_blank_script_and_blank_skills(hermes_env):
from cron.jobs import create_job
with pytest.raises(ValueError, match="nothing to run"):
create_job(prompt=" ", schedule="every 5m", script=" ", skills=["", " "])
def test_create_job_no_agent_error_still_wins(hermes_env):
"""no_agent=True without a script keeps its own, more specific message."""
from cron.jobs import create_job
with pytest.raises(ValueError, match="no_agent=True requires a script"):
create_job(prompt=None, schedule="every 5m", no_agent=True)
# ---------------------------------------------------------------------------
# Valid shapes stay valid
# ---------------------------------------------------------------------------
def test_valid_shapes_are_accepted(hermes_env):
from cron.jobs import create_job
(hermes_env / "scripts" / "w.sh").write_text("echo hi\n")
no_agent_job = create_job(
prompt=None, schedule="every 5m", script="w.sh", no_agent=True, deliver="local"
)
assert no_agent_job["no_agent"] is True
script_only_agent_job = create_job(
prompt=None, schedule="every 5m", script="w.sh", deliver="local"
)
assert script_only_agent_job["script"] == "w.sh"
assert script_only_agent_job["no_agent"] is False
prompt_job = create_job(prompt="check the news", schedule="every 5m", deliver="local")
assert prompt_job["prompt"] == "check the news"
skills_job = create_job(
prompt=None, schedule="every 5m", skills=["daily-report"], deliver="local"
)
assert skills_job["skills"] == ["daily-report"]
legacy_skill_job = create_job(
prompt=" ", schedule="every 5m", skill="daily-report", deliver="local"
)
assert legacy_skill_job["skill"] == "daily-report"
# ---------------------------------------------------------------------------
# update_job validation — the MERGED record is what counts
# ---------------------------------------------------------------------------
def test_update_job_rejects_clearing_the_only_payload(hermes_env):
from cron.jobs import create_job, get_job, update_job
job = create_job(prompt="check the news", schedule="every 5m", deliver="local")
with pytest.raises(ValueError, match="nothing to run"):
update_job(job["id"], {"prompt": " "})
# Nothing was persisted.
assert get_job(job["id"])["prompt"] == "check the news"
def test_update_job_rejects_toggling_no_agent_on_without_a_script(hermes_env):
"""create_job enforces no_agent ⇒ script; update_job must too."""
from cron.jobs import create_job, get_job, update_job
job = create_job(prompt="check the news", schedule="every 5m", deliver="local")
with pytest.raises(ValueError, match="no_agent=True requires a script"):
update_job(job["id"], {"no_agent": True})
assert get_job(job["id"])["no_agent"] is False
@pytest.mark.parametrize("blank", [None, "", " "])
def test_update_job_rejects_removing_script_from_a_no_agent_job(hermes_env, blank):
from cron.jobs import create_job, get_job, update_job
(hermes_env / "scripts" / "w.sh").write_text("echo hi\n")
job = create_job(
prompt=None, schedule="every 5m", script="w.sh", no_agent=True, deliver="local"
)
with pytest.raises(ValueError, match="no_agent=True requires a script"):
update_job(job["id"], {"script": blank})
assert get_job(job["id"])["script"] == "w.sh"
def test_update_job_rejects_swapping_script_for_prompt_on_a_no_agent_job(hermes_env):
"""A prompt does not rescue no_agent — there is no agent to read it."""
from cron.jobs import create_job, update_job
(hermes_env / "scripts" / "w.sh").write_text("echo hi\n")
job = create_job(
prompt=None, schedule="every 5m", script="w.sh", no_agent=True, deliver="local"
)
with pytest.raises(ValueError, match="no_agent=True requires a script"):
update_job(job["id"], {"script": "", "prompt": "do it yourself"})
def test_update_job_allows_dropping_script_when_no_agent_is_turned_off(hermes_env):
"""Both fields in one update: the merged record is a valid prompt job."""
from cron.jobs import create_job, get_job, update_job
(hermes_env / "scripts" / "w.sh").write_text("echo hi\n")
job = create_job(
prompt=None, schedule="every 5m", script="w.sh", no_agent=True, deliver="local"
)
update_job(job["id"], {"no_agent": False, "script": None, "prompt": "check the news"})
stored = get_job(job["id"])
assert stored["no_agent"] is False
assert stored["prompt"] == "check the news"
def test_update_job_allows_swapping_the_script_of_a_no_agent_job(hermes_env):
from cron.jobs import create_job, get_job, update_job
(hermes_env / "scripts" / "w.sh").write_text("echo hi\n")
job = create_job(
prompt=None, schedule="every 5m", script="w.sh", no_agent=True, deliver="local"
)
update_job(job["id"], {"script": "other.sh"})
assert get_job(job["id"])["script"] == "other.sh"
def test_update_job_allows_clearing_prompt_when_script_remains(hermes_env):
"""The merged record still has a script — that is a valid agent job."""
from cron.jobs import create_job, get_job, update_job
(hermes_env / "scripts" / "w.sh").write_text("echo hi\n")
job = create_job(
prompt="summarize this", schedule="every 5m", script="w.sh", deliver="local"
)
update_job(job["id"], {"prompt": ""})
assert get_job(job["id"])["script"] == "w.sh"
def test_update_job_allows_swapping_prompt_for_skill(hermes_env):
from cron.jobs import create_job, get_job, update_job
job = create_job(prompt="check the news", schedule="every 5m", deliver="local")
update_job(job["id"], {"prompt": "", "skills": ["daily-report"]})
stored = get_job(job["id"])
assert stored["skills"] == ["daily-report"]
assert not (stored["prompt"] or "").strip()
def test_pause_job_still_works_on_an_already_empty_job(hermes_env):
"""Bookkeeping updates must not be blocked, or the fix can't pause the job."""
from cron.jobs import get_job, pause_job
job = _legacy_empty_job(hermes_env)
paused = pause_job(job["id"], reason="empty payload")
assert paused is not None
stored = get_job(job["id"])
assert stored["enabled"] is False
assert stored["state"] == "paused"
# ---------------------------------------------------------------------------
# run_job runtime guard
# ---------------------------------------------------------------------------
def _legacy_empty_job(hermes_env):
"""Plant a jobs.json record predating the create/update guard.
Built via ``create_job`` (so every derived field — schedule, repeat,
snapshots — is realistic) then hand-blanked on disk, which is exactly how
such a record comes into existence today.
"""
from cron.jobs import create_job, load_jobs, save_jobs
job = create_job(prompt="used to say something", schedule="every 5m", deliver="local")
jobs = load_jobs()
for stored in jobs:
if stored["id"] == job["id"]:
stored["prompt"] = " "
save_jobs(jobs)
return dict(job, prompt=" ")
def test_run_job_fails_closed_and_never_builds_an_agent(hermes_env):
import cron.scheduler as scheduler
job = _legacy_empty_job(hermes_env)
class _Boom:
def __init__(self, *a, **kw): # pragma: no cover - must never run
raise AssertionError("AIAgent must not be constructed for an empty job")
with patch("run_agent.AIAgent", _Boom):
success, doc, final, error = scheduler.run_job(job)
assert success is False
assert "nothing to run" in error
assert "auto-paused" in doc
# Not silent: the user gets told why the job stopped.
assert "auto-paused" in final
def test_run_one_job_does_not_resurrect_the_paused_job(hermes_env):
"""The real caller runs post-run bookkeeping (mark_job_run) after run_job
returns. That must not undo the pause, or the job re-fires every tick."""
import cron.scheduler as scheduler
from cron.jobs import get_due_jobs, get_job
job = _legacy_empty_job(hermes_env)
scheduler.run_one_job(job)
stored = get_job(job["id"])
assert stored["enabled"] is False
assert stored["state"] == "paused"
# The whole point: it must never come up as due again.
assert [j["id"] for j in get_due_jobs()] == []
def _legacy_no_agent_scriptless_job(hermes_env, script_value=None):
"""Plant a no_agent job whose script went missing after creation."""
from cron.jobs import create_job, load_jobs, save_jobs
(hermes_env / "scripts" / "w.sh").write_text("echo hi\n")
job = create_job(
prompt=None, schedule="every 5m", script="w.sh", no_agent=True, deliver="local"
)
jobs = load_jobs()
for stored in jobs:
if stored["id"] == job["id"]:
stored["script"] = script_value
save_jobs(jobs)
return dict(job, script=script_value)
@pytest.mark.parametrize("script_value", [None, "", " "])
def test_run_job_pauses_a_legacy_no_agent_job_without_a_script(hermes_env, script_value):
"""Erroring alone left it enabled, so it re-fired every tick."""
import cron.scheduler as scheduler
from cron.jobs import get_job
job = _legacy_no_agent_scriptless_job(hermes_env, script_value)
success, doc, final, error = scheduler.run_job(job)
assert success is False
assert "no_agent=True requires a script" in error
assert "auto-paused" in doc
assert "auto-paused" in final
stored = get_job(job["id"])
assert stored["enabled"] is False
assert stored["state"] == "paused"
assert "no_agent=True requires a script" in (stored["paused_reason"] or "")
def test_run_one_job_does_not_resurrect_the_paused_no_agent_job(hermes_env):
"""Post-run bookkeeping must not put it back in the due queue."""
import cron.scheduler as scheduler
from cron.jobs import get_due_jobs, get_job
job = _legacy_no_agent_scriptless_job(hermes_env)
scheduler.run_one_job(job)
stored = get_job(job["id"])
assert stored["enabled"] is False
assert stored["state"] == "paused"
assert [j["id"] for j in get_due_jobs()] == []
def test_run_job_does_not_block_a_valid_no_agent_job(hermes_env):
"""The guard sits after the no_agent short-circuit, which must still run."""
import cron.scheduler as scheduler
script = hermes_env / "scripts" / "w.sh"
script.write_text("echo hello\n")
job = dict(_legacy_empty_job(hermes_env), script="w.sh", no_agent=True)
success, doc, final, error = scheduler.run_job(job)
assert success is True
assert error is None
assert "hello" in final
# ---------------------------------------------------------------------------
# The real destructive shape: incident t_36f4e9c8, 2026-08-03 10:47-10:53 KST,
# session 20260803_102050_e0c3dd74, messages 275947-276085.
#
# The model re-sent the ENTIRE cronjob schema with type-default empties while
# only meaning to change `deliver`. The update branch's `is not None` sentinel
# read every one of those empties as an explicit clear, so 43 jobs lost prompt,
# name, skills, script, enabled_toolsets, workdir and context_from in one pass.
#
# These tests replay that literal argument set through the tool boundary --
# not through update_job() -- because that is the layer the incident went
# through and the layer where the empties become "edits".
# ---------------------------------------------------------------------------
# Verbatim keys from message 275947 (job b80587becc0a), job_id substituted.
DESTRUCTIVE_UPDATE_ARGS = {
"action": "update",
"prompt": "",
"schedule": "0 10 * * *",
"name": "",
"repeat": 0,
"deliver": "whatsapp",
"skills": [],
"script": "",
"no_agent": False,
"context_from": [],
"enabled_toolsets": [],
"workdir": "",
"attach_to_session": False,
}
def _cronjob(**kwargs):
import json as _json
from tools.cronjob_tools import cronjob
return _json.loads(cronjob(**kwargs))
def test_tool_update_rejects_the_2026_08_03_destructive_shape(hermes_env):
"""The exact call that wiped 43 jobs must now fail closed."""
from cron.jobs import create_job, get_job
job = create_job(
prompt="Check for agent-browser updates with a 14-day safety buffer.",
schedule="0 10 * * *",
name="agent-browser safe auto-update",
skills=["agent-browser"],
deliver="local",
)
before = dict(get_job(job["id"]))
result = _cronjob(job_id=job["id"], **DESTRUCTIVE_UPDATE_ARGS)
assert result["success"] is False
assert "nothing to run" in result["error"]
# Atomicity: a guard that raises after a partial save is worse than none.
after = get_job(job["id"])
for field in ("prompt", "name", "skills", "skill", "script",
"enabled_toolsets", "workdir", "context_from",
"schedule", "deliver", "enabled", "state"):
assert after.get(field) == before.get(field), f"{field} was clobbered"
def test_tool_update_rejects_the_destructive_shape_on_a_script_job(hermes_env):
"""script:"" + prompt:"" + skills:[] empties an agent script job too."""
from cron.jobs import create_job, get_job
(hermes_env / "scripts" / "w.sh").write_text("echo hi\n")
job = create_job(
prompt=None, schedule="0 2 * * *", script="w.sh",
name="Daily Wiki Backup", deliver="local",
)
before = dict(get_job(job["id"]))
result = _cronjob(job_id=job["id"], **dict(DESTRUCTIVE_UPDATE_ARGS,
schedule="0 2 * * *"))
assert result["success"] is False
assert "nothing to run" in result["error"]
assert get_job(job["id"]) == before
def test_tool_update_rejects_the_destructive_shape_on_a_no_agent_job(hermes_env):
"""no_agent job: the more specific no_agent diagnosis reports first."""
from cron.jobs import create_job, get_job
(hermes_env / "scripts" / "w.sh").write_text("echo hi\n")
job = create_job(
prompt=None, schedule="0 8 * * *", script="w.sh", no_agent=True,
name="Lil'Log RSS ingest watchdog", deliver="local",
)
before = dict(get_job(job["id"]))
result = _cronjob(job_id=job["id"], **dict(DESTRUCTIVE_UPDATE_ARGS,
schedule="0 8 * * *",
no_agent=True))
assert result["success"] is False
assert "script" in result["error"]
assert get_job(job["id"]) == before
def test_tool_update_blank_name_is_a_no_op(hermes_env):
"""`name` is identity, not payload — no empty-payload guard covers it.
A job that keeps a script survives the payload guard, so without this the
same bulk-empty update still silently erases the name (which is exactly
what made 43 corrupted jobs unidentifiable in jobs.json).
"""
from cron.jobs import create_job, get_job
(hermes_env / "scripts" / "w.sh").write_text("echo hi\n")
job = create_job(
prompt=None, schedule="0 2 * * *", script="w.sh",
name="Daily Wiki Backup", deliver="local",
)
result = _cronjob(job_id=job["id"], action="update", name="", deliver="local")
assert result["success"] is True
assert get_job(job["id"])["name"] == "Daily Wiki Backup"
def test_tool_update_still_renames_when_a_real_name_is_given(hermes_env):
from cron.jobs import create_job, get_job
job = create_job(prompt="check the news", schedule="every 5m",
name="old", deliver="local")
result = _cronjob(job_id=job["id"], action="update", name="new")
assert result["success"] is True
assert get_job(job["id"])["name"] == "new"