fix(cron): tell the user TUI/CLI cron jobs are local-only at create time (#51683)

deliver=origin (or omitted) from a TUI or classic-CLI session produces a
job with origin=null, because those sessions never populate the
HERMES_SESSION_PLATFORM/CHAT_ID context vars that _origin_from_env reads.
The scheduler then resolves no delivery target and skips delivery — the
job runs and saves output to last_output, but nothing reaches the user
and they only find out by polling cronjob(action='list') (#51568).

This is by design (local sessions have no live-delivery channel), so the
fix surfaces it instead of silently dropping the intent:

- cronjob create now appends an informational notice to its result when
  a created job resolves to zero delivery targets and the user did not
  explicitly ask for deliver='local'. The check uses the scheduler's own
  _resolve_delivery_targets so it accounts for origin, home channels,
  'all', and explicit platform targets — no false positives.
- PLATFORM_HINTS gains a 'tui' entry (the TUI had none) and the 'cli'
  hint now states that cron jobs from these sessions are local-only and
  that deliver must target a gateway-connected platform to notify the
  user. This stops the agent promising a delivery that never happens.

No scheduler/delivery behavior change; no new env var; cron isolation
invariant untouched.
This commit is contained in:
Teknium 2026-06-23 23:27:48 -07:00 committed by GitHub
parent a39283bf09
commit 8e7e104521
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 147 additions and 2 deletions

View file

@ -709,7 +709,24 @@ PLATFORM_HINTS = {
"(those are only intercepted on messaging platforms like Telegram, "
"Discord, Slack, etc.; on the CLI they render as literal text). "
"When referring to a file you created or changed, just state its "
"absolute path in plain text; the user can open it from there."
"absolute path in plain text; the user can open it from there. "
"Cron jobs scheduled from this session are LOCAL-ONLY: their output is "
"saved (viewable via cronjob action='list') but is NOT delivered back "
"into this terminal — there is no live-delivery channel here. If the "
"user wants to be notified when a job runs, the job's `deliver` must "
"target a gateway-connected messaging platform (e.g. deliver='telegram' "
"or 'all'). Do not promise the user that a deliver='origin' or "
"default-deliver cron job will message them in this session."
),
"tui": (
"You are running in the Hermes terminal UI (TUI). "
"Cron jobs scheduled from this session are LOCAL-ONLY: their output is "
"saved (viewable via cronjob action='list') but is NOT delivered back "
"into this TUI session — there is no live-delivery channel here. If the "
"user wants to be notified when a job runs, the job's `deliver` must "
"target a gateway-connected messaging platform (e.g. deliver='telegram' "
"or 'all'). Do not promise the user that a deliver='origin' or "
"default-deliver cron job will message them in this session."
),
"sms": (
"You are communicating via SMS. Keep responses concise and use plain text "

View file

@ -990,9 +990,18 @@ class TestPromptBuilderConstants:
assert "discord" in PLATFORM_HINTS
assert "cron" in PLATFORM_HINTS
assert "cli" in PLATFORM_HINTS
assert "tui" in PLATFORM_HINTS
assert "api_server" in PLATFORM_HINTS
assert "webui" in PLATFORM_HINTS
def test_cli_and_tui_hints_flag_local_only_cron(self):
"""#51568 — cron jobs from CLI/TUI sessions don't deliver back into
the session, so the agent must be told up front not to promise it."""
for key in ("cli", "tui"):
hint = PLATFORM_HINTS[key]
assert "LOCAL-ONLY" in hint
assert "deliver" in hint
def test_whatsapp_cloud_hint_mentions_24h_window(self):
"""The Cloud API's 24-hour conversation window is a hard rule the
agent should know about. Phase 5 (template fallback) was deferred,

View file

@ -503,3 +503,81 @@ class TestResolveModelOverride:
)
assert provider == "custom:cliproxy"
assert model == "gpt-5.4"
class TestLocalDeliveryNotice:
"""#51568 — TUI/CLI cron jobs are local-only; surface that at create time
so the agent doesn't promise a delivery that never happens."""
@pytest.fixture(autouse=True)
def _setup_cron_dir(self, tmp_path, monkeypatch):
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")
# Default: no session origin (the TUI/CLI condition).
for var in (
"HERMES_SESSION_PLATFORM",
"HERMES_SESSION_CHAT_ID",
"HERMES_SESSION_THREAD_ID",
"HERMES_SESSION_CHAT_NAME",
):
monkeypatch.delenv(var, raising=False)
from gateway.session_context import clear_session_vars, set_session_vars
tokens = set_session_vars() # reset ContextVars to empty
yield
clear_session_vars(tokens)
def test_omitted_deliver_no_origin_emits_notice(self):
created = json.loads(
cronjob(action="create", prompt="Output the time", schedule="every 2m")
)
assert created["success"] is True
# Omitted deliver from a session with no origin downgrades to local.
assert created["deliver"] == "local"
assert "local-only cron job" in created["message"]
assert "deliver='telegram'" in created["message"]
def test_explicit_origin_no_origin_emits_notice(self):
created = json.loads(
cronjob(
action="create", prompt="x", schedule="every 2m", deliver="origin"
)
)
assert created["deliver"] == "origin"
assert "local-only cron job" in created["message"]
def test_explicit_local_no_notice(self):
# The user explicitly asked for local — no surprise to flag.
created = json.loads(
cronjob(
action="create", prompt="x", schedule="every 2m", deliver="local"
)
)
assert created["deliver"] == "local"
assert "local-only cron job" not in created["message"]
def test_explicit_platform_target_no_notice(self):
# An explicit platform:chat target resolves to a real delivery target.
created = json.loads(
cronjob(
action="create",
prompt="x",
schedule="every 2m",
deliver="telegram:123",
)
)
assert created["deliver"] == "telegram:123"
assert "local-only cron job" not in created["message"]
def test_gateway_origin_no_notice(self, monkeypatch):
# With a captured gateway origin, omitted deliver becomes origin and
# resolves to that chat — nothing to warn about.
from gateway.session_context import set_session_vars
set_session_vars(platform="telegram", chat_id="999")
created = json.loads(
cronjob(action="create", prompt="x", schedule="every 2m")
)
assert created["deliver"] == "origin"
assert "local-only cron job" not in created["message"]

View file

@ -298,6 +298,43 @@ def _origin_from_env() -> Optional[Dict[str, str]]:
return None
def _local_delivery_notice(job: Dict[str, Any], user_deliver: Optional[str]) -> Optional[str]:
"""Return an informational notice when a created job won't deliver anywhere.
TUI/CLI sessions cannot be captured as a cron ``origin`` (no
``HERMES_SESSION_PLATFORM``/``CHAT_ID`` is set for them), so a
``deliver="origin"`` request or an omitted ``deliver`` that defaults to
origin-or-local produces a job that runs and saves output to
``last_output`` but is never delivered back into the session. This is by
design (there is no live-delivery channel for local sessions), but silently
dropping the user's "tell me when it runs" intent is the trap reported in
#51568. Surface it at create time so the agent can relay it instead of
promising a delivery that never happens.
Returns ``None`` when the user explicitly asked for ``local`` (no surprise),
or when the job resolves to a real delivery target.
"""
# An explicit local request is exactly what the user asked for — no notice.
if (user_deliver or "").strip().lower() == "local":
return None
try:
from cron.scheduler import _resolve_delivery_targets
if _resolve_delivery_targets(job):
return None # Will actually deliver somewhere — nothing to flag.
except Exception:
# If resolution can't be evaluated, fall back to the origin signal.
if job.get("origin"):
return None
return (
"This is a local-only cron job: its output is saved (view it with "
"cronjob(action='list')) but will NOT be delivered back into this "
"session — CLI/TUI sessions have no live-delivery channel. To be "
"notified when it runs, recreate or update the job with deliver set to "
"a gateway-connected platform, e.g. deliver='telegram' or deliver='all'."
)
def _repeat_display(job: Dict[str, Any]) -> str:
times = (job.get("repeat") or {}).get("times")
completed = (job.get("repeat") or {}).get("completed", 0)
@ -607,6 +644,10 @@ def cronjob(
no_agent=_no_agent,
)
_notify_provider_jobs_changed_safe()
_create_message = f"Cron job '{job['name']}' created."
_local_notice = _local_delivery_notice(job, _normalize_deliver_param(deliver))
if _local_notice:
_create_message = f"{_create_message} {_local_notice}"
return json.dumps(
{
"success": True,
@ -619,7 +660,7 @@ def cronjob(
"deliver": job.get("deliver", "local"),
"next_run_at": job["next_run_at"],
"job": _format_job(job),
"message": f"Cron job '{job['name']}' created.",
"message": _create_message,
},
indent=2,
)