feat(cron): continuable cron jobs — thread-preferred continuation with DM-mirror fallback - #51077
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).
a32f334 to
397ba99
Compare
kshitijk4poor
left a comment
There was a problem hiding this comment.
Great PR — well-structured, opt-in, origin-scoped, and the thread-preferred + DM-fallback design mirrors the existing handoff pattern. All reuse claims check out (create_handoff_thread, mirror_to_session, safe_schedule_threadsafe, SessionSource, _session_store). CI green, 22 new tests pass.
However, this re-introduces the exact alternation violation that #2313 deliberately removed.
Background — issue #2221 and commit 37a9979
Cron deliveries used to be mirrored into gateway sessions as assistant-role messages. This caused consecutive assistant messages that violate role alternation (issue #2221). Teknium fixed this in #2313 by removing the mirror injection entirely — "Instead of fixing the role, remove the mirror injection entirely. Cron outputs already live in their own cron session and don't belong in the interactive conversation history." The existing test test_no_mirror_to_session_call (test_scheduler.py:879) enforces this: "Cron deliveries should NOT mirror into the gateway session."
This PR re-introduces that mirror, gated behind cron.mirror_delivery / attach_to_session (default OFF). The gate means the existing test still passes (no opt-in = no mirror), but the design decision it enforces is reversed when the feature is enabled.
Why the alternation violation persists
mirror_to_session writes role="assistant" to SQLite via _append_to_sqlite (mirror.py:157), which only passes role and content to SessionDB.append_message — the mirror: True and mirror_source metadata is dropped at the SQLite boundary. The gateway loads replay history from SQLite (load_transcript → get_messages_as_conversation), so the msg.get("mirror") check at run.py:814 that would prefix the content with [Delivered from <source>] always returns False on replay. The mirror message is indistinguishable from a real assistant turn.
repair_message_sequence (agent_runtime_helpers.py:347) merges consecutive user messages but does NOT merge consecutive assistant messages. The Anthropic adapter has _merge_consecutive_roles (anthropic_adapter.py:2069) that handles this, but the OpenAI/OpenRouter path does not.
Cron fires on a schedule, so the mirror lands right after the agent's last assistant turn (the common idle state). The next user reply sees assistant(real) → assistant(mirror, unlabeled) → user(reply) — two consecutive assistants, no label distinguishing the mirror from a real response. This causes silent empty responses on strict-alternation providers, triggering the empty-retry loop. This is the exact pattern described in issue #2221.
Fix direction
If the continuable-cron feature is wanted, the mirror should write as role="user" with a [Cron delivery: <task name>] prefix instead of role="assistant". The sequence becomes assistant(real) → user(cron brief) → user(reply) — repair_message_sequence already merges consecutive user messages, so this is alternation-safe for all providers. This also preserves the [Delivered from cron] distinguishability that the SQLite metadata-drop currently loses. This would require either a new helper or extending mirror_to_session with a role parameter.
Second issue — thread_seeded=True suppresses fallback mirror when live delivery fails
thread_seeded is set to True immediately after _seed_cron_thread_session returns (scheduler.py:1177), before the live adapter delivery attempt. If the live adapter delivery fails and falls through to the standalone path (which succeeds), _maybe_mirror_cron_delivery is called with enabled=mirror_this_target and not thread_seeded → enabled=False. The brief is visible in the new thread but has no session transcript — the exact "what is Task #2?" amnesia this PR exists to prevent.
Fix: set thread_seeded only after successful delivery, or re-enable the mirror on fallback.
Minor nits
- Duplicate import:
BasePlatformAdapterimported twice in_deliver_result— as_BPA(line 1080) and asBasePlatformAdapter(line 1085), 5 lines apart. Use one import. - Missing
user_idin seed mirror:_seed_cron_thread_sessioncallsmirror_to_sessionwithoutuser_id(scheduler.py:571-576), but the session was created withuser_id="system:cron". When multiple candidate sessions exist with distinct user_ids,_find_session_idreturns None and the mirror silently fails. Low probability for fresh threads, but worth fixing. - No docs update:
website/docs/user-guide/features/cron.mdshould documentmirror_delivery/attach_to_session. - Excessive docstrings: Several new functions have 30-50 line docstrings describing mechanics rather than non-obvious WHY. Per the AGENTS.md rubric, keep only non-obvious WHY comments.
What looks good
- Config key properly registered in
DEFAULT_CONFIG["cron"]. - No new tools, no toolset changes, no system prompt mutation, no hardcoded paths.
- All reused primitives exist on main and are used correctly.
- Default-off preserves existing behavior byte-for-byte.
- Origin-scoping is correct — fan-out targets are never mirrored.
user_idpassthrough mirrors thesend_messagepattern.- Test coverage is thorough (gate precedence, mirror calls/no-ops, cold-start, origin-scoping, fan-out, thread creation, session seeding).
…on-safe) Addresses review on NousResearch#51077 (kxee). The continuable-cron mirror reused gateway.mirror.mirror_to_session, which writes role=assistant — re- introducing the exact alternation violation NousResearch#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 NousResearch#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 NousResearch#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.
|
Thanks @kshitijk4poor — excellent catch, and you're right on every point. Verified against source + history before fixing. On the core concern (the #2313 reversal): confirmed. To be explicit about intent: this PR is a deliberate, opt-in, default-OFF reversal of #2313's "cron output doesn't belong in interactive history" — but only for the reply-to-cron use case, and only when Fixes pushed (
204 cron+mirror tests pass. One thing worth a maintainer eye beyond the mechanics: the principle of letting opt-in continuable cron land in interactive history is a product call that reverses half of #2313's rationale. The alternation half is now genuinely fixed (user-role); the "belongs in history at all" half is the deliberate, gated trade we're making for reply-to-cron. Flagging it explicitly so it's a conscious sign-off rather than something that slipped through on a clean role-fix. |
…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.
|
Merged via #52250. Your 5 commits were cherry-picked onto current main with your authorship preserved in git log (rebase-merge). Verified on main: tests 186/186 green, E2E (gate resolution + origin-scoping + mirror backward-compat) passed. Thanks for the clean, well-scoped feature — the thread-preferred / None-fallback design and the alternation fix-up were exactly right. |
…on-safe) Addresses review on NousResearch#51077 (kxee). The continuable-cron mirror reused gateway.mirror.mirror_to_session, which writes role=assistant — re- introducing the exact alternation violation NousResearch#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 NousResearch#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 NousResearch#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.
…on-safe) Addresses review on NousResearch#51077 (kxee). The continuable-cron mirror reused gateway.mirror.mirror_to_session, which writes role=assistant — re- introducing the exact alternation violation NousResearch#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 NousResearch#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 NousResearch#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.
…on-safe) Addresses review on NousResearch#51077 (kxee). The continuable-cron mirror reused gateway.mirror.mirror_to_session, which writes role=assistant — re- introducing the exact alternation violation NousResearch#2313 (228472e) 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 NousResearch#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 NousResearch#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.
…on-safe) Addresses review on NousResearch#51077 (kxee). The continuable-cron mirror reused gateway.mirror.mirror_to_session, which writes role=assistant — re- introducing the exact alternation violation NousResearch#2313 (228472e) 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 NousResearch#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 NousResearch#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.
…on-safe) Addresses review on NousResearch#51077 (kxee). The continuable-cron mirror reused gateway.mirror.mirror_to_session, which writes role=assistant — re- introducing the exact alternation violation NousResearch#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 NousResearch#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 NousResearch#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.
…on-safe) Addresses review on NousResearch#51077 (kxee). The continuable-cron mirror reused gateway.mirror.mirror_to_session, which writes role=assistant — re- introducing the exact alternation violation NousResearch#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 NousResearch#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 NousResearch#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.
What & why
Cron deliveries today live only in the cron job's own isolated session. When a brief is delivered to a chat and the user replies ("start Task #2"), that reply lands on a target session with no record of the brief — the agent asks "what is Task #2?". This makes recurring conversational jobs (daily briefings, reminders that kick off follow-up work) a dead end.
This PR makes a cron job optionally continuable: the delivered result becomes a conversation the user can reply into, with the brief already in context. Opt-in, default OFF — the historical isolation guarantee is preserved unless a job (or an operator) turns it on.
Design: thread-preferred, with a DM-mirror fallback
The continuation surface is chosen by a single platform-capability fork, scoped to the job's origin conversation only:
create_handoff_thread, the brief is routed into it, and the thread-keyed session is seeded so the user's in-thread reply continues with full context. Each continuable job gets its own scrollback, isolated from the parent channel.create_handoff_threadreturnsNone), so the brief is mirrored into the origin DM session — the DM itself is the continuation surface.A
Nonereturn fromcreate_handoff_threadis the fallback signal, so there is one code path, not two features. This mirrorsGatewayRunner._process_handoff's existing open-thread-or-fallback + seed pattern already used for CLI→platform handoffs.Footprint (per AGENTS.md — expansive at the edges, conservative at the waist)
supports_threadsflag — reuses the shipped per-platformcreate_handoff_thread; itsNonereturn is the capability probe.SessionStorevia the adapter's existing_session_storehandle rather than threading a new param through the frozenCronScheduler.start()contract.HERMES_*env var — the behavioural setting iscron.mirror_deliveryinconfig.yaml, overridable per-job via thecronjobtool'sattach_to_session.thread_idfor free.Net diff is +711 lines, additive only, across 5 files.
Invariants
(platform, chat_id[, thread_id])equals the job's origin. Fan-out / broadcast / home-channel-fallback targets are never threaded or mirrored — they are not a continuation of a conversation and may legitimately have no session.thread_seededguard prevents a double-mirror after seeding.gateway/mirror.py's_find_session_id, which returnsNone(no-op) rather than guess when a chat has multiple participant sessions and none matches the origin sender — parity with interactivesend_message. The thread path sidesteps this entirely: a freshchat_type="thread"session is participant-shared by construction.Config / surface
Per-job opt-in via the
cronjobtool:attach_to_session: true.Testing
tests/cron/test_scheduler.py— 185 passed, including the new mirror/thread/continuable coverage: thread id returned on a thread platform;Noneon a DM platform;Nonewithout capability/loop; seed creates the thread session + mirrors; seed no-op on empty content; plus the origin-scoping suite (exact match; other-chat / other-platform / no-origin rejection; thread scoping; fan-out mirrors ONLY the origin target).tests/cron/suite: 538 passed, 7 skipped; the 4 failures are pre-existingcroniter-not-installed env failures (computenext_runfrom cron expressions), unrelated to this change and identical on untouchedmain.main.Commits
feat(cron): optional mirror of cron delivery into target chat sessionrefactor(cron): scope delivery mirror to the origin conversationfeat(cron): pass origin user_id to delivery mirror (send_message parity)feat(cron): thread-preferred continuable delivery (open a thread, mirror DM fallback)