feat(cron): continuable cron jobs — thread-preferred continuation with DM-mirror fallback - #52250
Merged
Conversation
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.
Contributor
🔎 Lint report:
|
| 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.
Collaborator
Salvage of #51077 (@victor-kyriazakos) rebased onto current |
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
create_handoff_thread, the brief is routed in, and the thread-keyed session is seeded so an in-thread reply continues with full context.create_handoff_threadreturnsNone, so the brief is mirrored into the origin DM session — one code path, theNonereturn 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-jobattach_to_sessionopt-in (overrides global).gateway/mirror.py: newroleparam (default"assistant"— backward-compatible); cron passesrole="user"so the brief lands as a labelled user turn, alternation-safe on replay.hermes_cli/config.py:cron.mirror_delivery: falsedefault.Footprint
No new core tool, no new adapter method, no new
HERMES_*env var. Reusescreate_handoff_thread(itsNonereturn is the fallback signal) andmirror_to_session. Mirrors the existing_process_handoffopen-thread-or-seed pattern.Invariants
Validation
tests/cron/test_scheduler.py(hermetic runner)merge-base origin/main HEAD..origin/main)Cherry-picked from #51077, @victor-kyriazakos's authorship preserved across all 5 commits (incl. the alternation fix-up
bc245f2).Infographic