Skip to content

feat(cron): continuable cron jobs — thread-preferred continuation with DM-mirror fallback - #52250

Merged
teknium1 merged 5 commits into
mainfrom
hermes/hermes-f240a996
Jun 25, 2026
Merged

feat(cron): continuable cron jobs — thread-preferred continuation with DM-mirror fallback#52250
teknium1 merged 5 commits into
mainfrom
hermes/hermes-f240a996

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

Salvage of #51077 (@victor-kyriazakos) onto current main. Cron jobs become optionally continuable — reply to a cron brief and the agent has it in context instead of asking "what is Task #2?". Opt-in, default OFF — the historical isolation guarantee is preserved unless a job or operator turns it on.

Thread-preferred, scoped to the job's origin chat:

  • Thread-capable platforms (Telegram topics, Discord/Slack threads): a dedicated thread is opened per delivery via the shipped create_handoff_thread, the brief is routed in, and the thread-keyed session is seeded so an in-thread reply continues with full context.
  • DM-only platforms (WhatsApp/Signal/SMS): create_handoff_thread returns None, so the brief is mirrored into the origin DM session — one code path, the None return is the capability probe.

Changes

  • cron/scheduler.py: gate resolution (_cron_mirror_delivery_enabled), origin-scoping (_target_matches_origin), thread-open + session-seed helpers, wired into _deliver_result.
  • cron/jobs.py + tools/cronjob_tools.py: per-job attach_to_session opt-in (overrides global).
  • gateway/mirror.py: new role param (default "assistant" — backward-compatible); cron passes role="user" so the brief lands as a labelled user turn, alternation-safe on replay.
  • hermes_cli/config.py: cron.mirror_delivery: false default.
  • docs + tests.

Footprint

No new core tool, no new adapter method, no new HERMES_* env var. Reuses create_handoff_thread (its None return is the fallback signal) and mirror_to_session. Mirrors the existing _process_handoff open-thread-or-seed pattern.

Invariants

  • Origin-scoped: fan-out / broadcast / home-channel-fallback targets are never threaded or mirrored.
  • Cache- and alternation-safe: brief appended at a turn boundary as a user turn, never mid-loop, never mutates the cached system prompt.
  • Best-effort: a mirror/seed failure never fails a delivery that already succeeded.

Validation

Result
tests/cron/test_scheduler.py (hermetic runner) 186/186 passed
E2E (real imports, temp HERMES_HOME): gate resolution + origin-scoping + config default + mirror role backward-compat all pass
Stale-base check (merge-base origin/main HEAD..origin/main) 0

Cherry-picked from #51077, @victor-kyriazakos's authorship preserved across all 5 commits (incl. the alternation fix-up bc245f2).

Infographic

continuable-cron-jobs-n64-questworld

Adds an opt-in path so a cron job's delivered output is also appended to
the TARGET chat's gateway session transcript (as an assistant turn), so a
user reply to a recurring delivery (daily brief, reminder) is answered with
the delivery in context instead of 'what is that?' amnesia.

- Reuses the shipped gateway.mirror.mirror_to_session — the same primitive
  interactive send_message mirroring already uses. No messaging-toolset
  change (cron still can't call send_message; this rides delivery).
- Gated: per-job attach_to_session overrides global cron.mirror_delivery
  (config.yaml). Default OFF — historical isolation preserved byte-for-byte.
- Mirrors the CLEAN agent output, not the cron header/footer wrapper.
- Alternation/cache-safe: append lands at a turn boundary, never mid-loop,
  never mutates the cached system prompt. Cold-start (no target session)
  is a silent no-op; mirror errors never fail a successful delivery.
- Surfaced on the cronjob tool (attach_to_session) + config schema.

Driven by enterprise cron-as-control-plane use case. 10 new tests; full
cron + cronjob-tool suites pass (600).
The cron->session mirror now fires ONLY for the delivery target that
equals the job's origin (platform+chat_id[+thread_id]). A job created
from a live gateway chat stamps that chat as origin, and that session is
guaranteed to exist (it is the conversation the user scheduled the job
in). Fan-out / broadcast / home-channel-fallback targets are never
mirrored: they are not a continuation of a conversation and may have no
session at all.

This makes the prior 'cold-start session seeding' concern a non-case by
construction: when the mirror semantically applies the session exists;
when none exists the target was never the origin, so we no-op.

Adds _target_matches_origin() + origin-scoping tests (exact match,
other-chat/other-platform/no-origin rejection, thread scoping, fan-out
mirrors only the origin target).
Multi-participant parity with interactive send_message, which passes
HERMES_SESSION_USER_ID to gateway.mirror.mirror_to_session so the mirror
lands in the exact participant's session.

- cronjob_tools._origin_from_env now captures user_id from the session
  context at job-create time (alongside platform/chat_id/thread_id).
- _maybe_mirror_cron_delivery forwards user_id to mirror_to_session.
- _deliver_result threads origin.user_id through for the origin target.

Effect: in a per-user-isolated group chat (group_sessions_per_user=True,
the default), the mirror resolves to the member who scheduled the job
instead of conservatively no-op'ing on ambiguous candidates. DMs and
shared group/thread sessions are unaffected (single candidate). Default
still OFF.

Tests: helper forwards user_id; E2E _deliver_result forwards origin
user_id. 17/17 in TestCronDeliveryMirror; 527 cron tests pass (4 failures
pre-existing: croniter-not-installed + TZ, identical on baseline).
…ror DM fallback)

Continuable cron jobs (attach_to_session / cron.mirror_delivery, default
OFF) now prefer a dedicated thread on thread-capable platforms, falling
back to origin-DM mirroring where threads don't exist.

- Thread-capable (Telegram topics, Discord/Slack threads): open a fresh
  thread for the job via the shipped adapter.create_handoff_thread,
  route the brief into it, and seed the thread-keyed session so the
  user's in-thread reply continues with full context. This is the
  'continuable cron opens its own thread' interface.
- DM-only (WhatsApp/Signal/SMS): create_handoff_thread returns None ->
  fall back to mirroring into the origin DM session (existing behaviour).

Reuses existing infrastructure end-to-end — no new adapter surface, no
provider-chain signature change:
- adapter.create_handoff_thread (already implemented per-platform,
  returns None on unsupported platforms = the fallback signal)
- the live SessionStore via adapter._session_store (already set on every
  adapter), reached without threading a new param through the frozen
  CronScheduler.start() contract
- gateway.mirror.mirror_to_session for the seed/append
- existing per-target delivery routing carries the new thread_id for free

Mirrors GatewayRunner._process_handoff's open-thread-or-fallback +
seed pattern, standalone for the cron delivery path. thread_seeded
guards against a double-mirror after seeding. Scoped to the origin
target only; fan-out/broadcast targets are never threaded or mirrored.

Config docs updated (cron.mirror_delivery) + cronjob tool
attach_to_session description reframed around continuable/thread-preferred.

Tests: +5 (thread id returned on thread platform; None on DM platform;
None without capability/loop; seed creates thread session + mirrors;
seed no-op on empty). 22/22 in TestCronDeliveryMirror; 532 cron tests
pass (4 failures pre-existing: croniter-not-installed + TZ).
…on-safe)

Addresses review on #51077 (kxee). The continuable-cron mirror reused
gateway.mirror.mirror_to_session, which writes role=assistant — re-
introducing the exact alternation violation #2313 (37a9979)
deliberately removed: a cron brief landing as assistant after the
agent's last turn yields assistant->assistant, which breaks strict-
alternation providers (OpenAI/OpenRouter) per issue #2221. The mirror/
mirror_source metadata is also dropped at the SQLite boundary, so the
[Delivered from cron] label is lost on replay.

This is an intentional, opt-in (default OFF) reversal of #2313's
'cron output does not belong in interactive history' for the reply-to-
cron use case — gated behind cron.mirror_delivery / attach_to_session.

Fixes:
- mirror_to_session gains a role param (default 'assistant' — interactive
  send_message mirror unchanged, it IS the agent speaking). Cron paths
  pass role='user' with a '[Cron delivery: <task>]' prefix so the brief
  collapses via repair_message_sequence's consecutive-user merge on every
  provider, and stays distinguishable on replay despite the metadata drop.
- thread_seeded: defer seeding + the flag until delivery into the new
  thread actually succeeds. Previously set pre-delivery, so an open-
  succeeds / deliver-fails case both stranded a seeded-but-unseen brief
  AND suppressed the DM-fallback mirror.
- seed mirror now passes user_id='system:cron' to resolve the exact
  thread-keyed session row it just created.
- dedupe the duplicate BasePlatformAdapter import in _deliver_result.
- trim oversized docstrings to non-obvious WHY (AGENTS.md).
- docs: document cron.mirror_delivery / attach_to_session in
  website/docs/user-guide/features/cron.md.
- test: assert the cron mirror writes role='user' with the label prefix.

204 cron+mirror tests pass.
@github-actions

Copy link
Copy Markdown
Contributor

🔎 Lint report: hermes/hermes-f240a996 vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 11250 on HEAD, 11247 on base (🆕 +3)

🆕 New issues (4):

Rule Count
unresolved-attribute 2
invalid-argument-type 1
invalid-assignment 1
First entries
tests/run_agent/test_credits_notices_toggle.py:76: [unresolved-attribute] unresolved-attribute: Unresolved attribute `_credits_session_start_micros` on type `AIAgent`
gateway/platforms/api_server.py:3296: [invalid-argument-type] invalid-argument-type: Argument to function `create_job` is incorrect: Expected `bool | None`, found `Unknown | LiteralString | dict[str, str]`
run_agent.py:2984: [unresolved-attribute] unresolved-attribute: Object of type `Self@get_credits_spent_micros` has no attribute `_credits_session_start_micros`
gateway/platforms/api_server.py:713: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to `def create_job(prompt: str | None, schedule: str, name: str | None = None, repeat: int | None = None, deliver: str | None = None, origin: dict[str, Any] | None = None, skill: str | None = None, skills: list[str] | None = None, model: str | None = None, provider: str | None = None, base_url: str | None = None, script: str | None = None, context_from: str | list[str] | None = None, enabled_toolsets: list[str] | None = None, workdir: str | None = None, no_agent: bool = False, attach_to_session: bool | None = None) -> dict[str, Any]`

✅ Fixed issues (2):

Rule Count
invalid-assignment 2
First entries
tests/run_agent/test_credits_notices_toggle.py:76: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to attribute `_credits_session_start_micros` of type `int`
gateway/platforms/api_server.py:713: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to `def create_job(prompt: str | None, schedule: str, name: str | None = None, repeat: int | None = None, deliver: str | None = None, origin: dict[str, Any] | None = None, skill: str | None = None, skills: list[str] | None = None, model: str | None = None, provider: str | None = None, base_url: str | None = None, script: str | None = None, context_from: str | list[str] | None = None, enabled_toolsets: list[str] | None = None, workdir: str | None = None, no_agent: bool = False) -> dict[str, Any]`

Unchanged: 5938 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@alt-glitch alt-glitch added type/feature New feature or request comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have labels Jun 25, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Salvage of #51077 (@victor-kyriazakos) rebased onto current main — this is the active version of the continuable-cron feature; #51077 is the predecessor. Same cron-delivery→session-awareness cluster as #50668 and #48056.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants