diff --git a/cron/scheduler.py b/cron/scheduler.py index eb43196a7dd4..4a2b80b97903 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -1204,6 +1204,36 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option delivered = False target_errors = [] + # Continuable cron surface (D1/D2/D6): resolve the delivery surface for + # this platform generically from its config ``extra``. Default "thread" + # (today's behaviour, byte-identical). "in_channel" delivers the brief + # FLAT into the channel (no dedicated thread) so a plain channel reply + # continues the job in-context via the shared-channel session + # ``(platform, chat_id, None)`` — the same bucket ``reply_in_thread: + # false`` routes inbound channel messages to. The key is read + # generically here (any platform); the ``in_channel`` branch is gated on + # the adapter capability flag ``supports_inchannel_continuable`` so an + # unsupported platform fails SAFE to "thread" (Slack is the first + # consumer; "first consumer ≠ definition"). + surface_mode = "thread" + try: + surface_raw = (pconfig.extra or {}).get("cron_continuable_surface") + if surface_raw is not None and str(surface_raw).strip().lower() == "in_channel": + surface_mode = "in_channel" + except Exception: + surface_mode = "thread" + in_channel_surface = surface_mode == "in_channel" + if in_channel_surface and runtime_adapter is not None and not getattr( + runtime_adapter, "supports_inchannel_continuable", False + ): + # Fail safe (D6): platform has no in_channel continuation primitive. + logger.debug( + "Job '%s': cron_continuable_surface=in_channel not supported on " + "%s, using thread", + job.get("id", "?"), platform_name, + ) + in_channel_surface = False + # Continuable cron (thread-preferred): when mirroring is enabled for the # origin target and the gateway is live, try to open a DEDICATED thread # for this job and deliver the brief into it. On thread-capable @@ -1212,10 +1242,18 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option # continues with full context. On DM-only platforms (WhatsApp/Signal) # create_handoff_thread returns None and we fall back to mirroring into # the origin DM session (handled after delivery). Cf. _process_handoff. + # + # in_channel surface (D2): SKIP thread creation entirely — leave + # thread_id=None so the delivery posts flat, and let the existing + # origin-mirror (below) seed the shared-channel session (F5: for a + # channel-origin job with thread_id=None, _target_matches_origin matches + # and _maybe_mirror_cron_delivery seeds (platform, chat_id, None)). No + # new seed call is needed. thread_seeded = False opened_thread_id: Optional[str] = None if ( mirror_this_target + and not in_channel_surface and runtime_adapter is not None and loop is not None and not thread_id # never override an explicit origin thread/topic diff --git a/gateway/config.py b/gateway/config.py index b8adb930dbe4..3ecef630f93d 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -1008,6 +1008,8 @@ def _merge_platform_map(source_platforms: Any) -> None: bridged["reply_prefix"] = platform_cfg["reply_prefix"] if "reply_in_thread" in platform_cfg: bridged["reply_in_thread"] = platform_cfg["reply_in_thread"] + if "cron_continuable_surface" in platform_cfg: + bridged["cron_continuable_surface"] = platform_cfg["cron_continuable_surface"] if "require_mention" in platform_cfg: bridged["require_mention"] = platform_cfg["require_mention"] if plat == Platform.TELEGRAM and "allowed_chats" in platform_cfg: diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 6e4db4467a09..15fc9eb3a764 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -2257,6 +2257,21 @@ class BasePlatformAdapter(ABC): # "typed_command_prefix", "/"); no per-platform branching at call sites. typed_command_prefix: str = "/" + # Whether this adapter supports the ``in_channel`` continuable-cron surface + # (``platforms.
.extra.cron_continuable_surface: in_channel``): a
+ # continuable cron job delivered FLAT into a channel (no dedicated thread),
+ # with the user's plain channel reply continuing the job in-context via the
+ # shared-channel session. Only coherent on a platform that has BOTH a
+ # flat-reply outbound gate AND a whole-channel inbound session bucket keyed
+ # ``(platform, chat_id, None)`` — today that is Slack (``reply_in_thread:
+ # false``). Default False: an unsupported platform fails SAFE, treating
+ # ``in_channel`` as ``thread`` (a threaded continuation ≈ today's
+ # behaviour), never a dropped continuation. Read generically by the cron
+ # scheduler via ``getattr(adapter, "supports_inchannel_continuable",
+ # False)`` — no per-platform branching at the call site (the key stays a
+ # generic seam; Slack is merely the first consumer).
+ supports_inchannel_continuable: bool = False
+
def __init__(self, config: PlatformConfig, platform: Platform):
self.config = config
self.platform = platform
diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py
index f2fdbd527b63..2767c6bc2fe3 100644
--- a/plugins/platforms/slack/adapter.py
+++ b/plugins/platforms/slack/adapter.py
@@ -422,6 +422,14 @@ class SlackAdapter(BasePlatformAdapter):
# the prefix that works everywhere — instruction text must show it.
typed_command_prefix = "!"
+ # Slack has both halves the ``in_channel`` continuable-cron surface needs:
+ # a flat-reply outbound gate (``reply_in_thread: false`` → ``_resolve_thread_ts``
+ # returns None for top-level channel messages) AND a whole-channel inbound
+ # session bucket keyed ``(platform, channel_id, None)`` (the same
+ # ``reply_in_thread: false`` path in ``_handle_slack_message``). So a
+ # continuable cron delivered flat here continues in-context on a plain reply.
+ supports_inchannel_continuable = True
+
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.SLACK)
self._app: Optional[Any] = None
@@ -1068,6 +1076,7 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:
self._warn_if_missing_group_dm_scopes(auth_response, team_name)
self._warn_if_not_bot_token(auth_response, team_name)
+ self._warn_if_inchannel_without_flat_reply(team_name)
# Register message event handler
@self._app.event("message")
@@ -1539,6 +1548,62 @@ def _dm_top_level_threads_as_sessions(self) -> bool:
return True # default: each DM thread is its own session
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
+ def _cron_continuable_surface(self) -> str:
+ """Resolve the continuable-cron delivery surface for this platform.
+
+ Values: ``"thread"`` (default — today's behaviour: a continuable cron
+ job opens a dedicated hidden thread and seeds it) or ``"in_channel"``
+ (deliver FLAT into the channel timeline; the shared-channel session
+ ``(slack, channel_id, None)`` is the continuation surface). Set
+ ``platforms.slack.extra.cron_continuable_surface: in_channel`` in
+ config.yaml. Pair with ``reply_in_thread: false`` so the user's reply
+ is answered flat in the channel and keyed to the same shared session —
+ see ``_warn_if_inchannel_without_flat_reply``. Any unrecognised value
+ coerces to ``"thread"`` (fail safe).
+ """
+ raw = self.config.extra.get("cron_continuable_surface")
+ if raw is None:
+ return "thread"
+ val = str(raw).strip().lower()
+ return "in_channel" if val == "in_channel" else "thread"
+
+ def _warn_if_inchannel_without_flat_reply(self, team_name: str) -> None:
+ """Warn when ``in_channel`` is set without the required ``reply_in_thread: false`` pairing.
+
+ The two knobs are orthogonal (D4/D5): ``cron_continuable_surface:
+ in_channel`` skips thread creation on delivery, and ``reply_in_thread:
+ false`` makes the bot answer inbound channel messages flat and key them
+ to the whole-channel session ``(slack, channel_id, None)``. For a
+ continuable in-channel cron to actually continue on a plain reply, BOTH
+ must hold: the seed lands in the shared-channel session, and the reply
+ must resolve to (and be answered in) that same flat session.
+
+ Enforcement is WARN, not hard-require (D5): the misconfiguration fails
+ SAFE — ``in_channel`` without ``reply_in_thread: false`` yields a
+ threaded continuation (≈ today's behaviour), never a dropped/orphaned
+ session — so a config-load rejection would be heavier than warranted
+ and would make the two knobs non-orthogonal. Mirrors the existing
+ connect-time warning pattern (``_warn_if_missing_group_dm_scopes``,
+ ``_warn_if_not_bot_token``).
+ """
+ try:
+ if self._cron_continuable_surface() != "in_channel":
+ return
+ # reply_in_thread defaults True (legacy: reply in a thread).
+ if self.config.extra.get("reply_in_thread", True):
+ logger.warning(
+ "[Slack] %s: cron_continuable_surface=in_channel is set "
+ "WITHOUT reply_in_thread=false. A continuable in-channel "
+ "cron job will deliver flat, but the bot will still reply "
+ "to your continuation in a thread — so it falls back to a "
+ "threaded continuation (\u2248 default behaviour), not the "
+ "flat channel session you asked for. Set "
+ "platforms.slack.extra.reply_in_thread: false to pair them.",
+ team_name,
+ )
+ except Exception:
+ pass
+
def _resolve_thread_ts(
self,
reply_to: Optional[str] = None,
diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py
index 935533b11b97..73d172f23601 100644
--- a/tests/cron/test_scheduler.py
+++ b/tests/cron/test_scheduler.py
@@ -3997,3 +3997,134 @@ def test_seed_thread_session_noop_on_empty_text(self):
)
store.get_or_create_session.assert_not_called()
mirror_mock.assert_not_called()
+
+
+class TestCronContinuableSurfaceInChannel:
+ """cron_continuable_surface: in_channel — deliver a continuable cron FLAT
+ into a channel (no dedicated thread), so a plain channel reply continues the
+ job via the shared-channel session (platform, chat_id, None).
+
+ Design: decisions.md D1/D2/D6 + F5. The scheduler reads the per-platform key
+ generically from pconfig.extra; the in_channel branch is gated on the
+ adapter capability flag ``supports_inchannel_continuable`` (Slack=True,
+ others fail SAFE to thread). In in_channel mode the thread-open branch is
+ SKIPPED (thread_id stays None), so the existing origin-mirror seeds the
+ shared-channel session — no new seed code (G6).
+ """
+
+ def _slack_cfg(self, extra):
+ """A mock GatewayConfig with a Slack pconfig carrying ``extra``."""
+ from gateway.config import Platform
+
+ pconfig = MagicMock()
+ pconfig.enabled = True
+ pconfig.extra = extra
+ mock_cfg = MagicMock()
+ mock_cfg.platforms = {Platform.SLACK: pconfig}
+ return mock_cfg
+
+ def _run_inchannel_delivery(self, extra, adapter, *, mirror_ok=True):
+ """Drive _deliver_result down the live-adapter path for a Slack
+ channel-origin job with the given ``extra`` config. Returns the
+ _open_continuable_cron_thread mock and the mirror_to_session mock."""
+ from gateway.config import Platform
+ from concurrent.futures import Future
+
+ mock_cfg = self._slack_cfg(extra)
+
+ loop = MagicMock()
+ loop.is_running.return_value = True
+
+ def fake_run_coro(coro, _loop):
+ future = Future()
+ try:
+ import asyncio as _asyncio
+ future.set_result(_asyncio.run(coro))
+ except BaseException as _e: # noqa: BLE001
+ future.set_exception(_e)
+ return future
+
+ job = {
+ "id": "brief-job",
+ "name": "Daily Brief",
+ "deliver": "origin",
+ # Channel origin: no thread_id (flat channel message scheduled it).
+ "origin": {"platform": "slack", "chat_id": "C123"},
+ # Opt into the continuable mirror.
+ "attach_to_session": True,
+ }
+
+ with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \
+ patch("cron.scheduler.load_config", return_value={"cron": {"wrap_response": False}}), \
+ patch("cron.scheduler._open_continuable_cron_thread") as open_thread_mock, \
+ patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro), \
+ patch("gateway.mirror.mirror_to_session", return_value=mirror_ok) as mirror_mock:
+ _deliver_result(
+ job, "Here is today's brief.",
+ adapters={Platform.SLACK: adapter}, loop=loop,
+ )
+ return open_thread_mock, mirror_mock
+
+ def _slack_adapter(self, supports_inchannel=True):
+ adapter = AsyncMock()
+ adapter.send.return_value = MagicMock(success=True)
+ # Capability flag read via getattr in the scheduler.
+ adapter.supports_inchannel_continuable = supports_inchannel
+ return adapter
+
+ def test_in_channel_skips_thread_open(self):
+ """G2: in_channel mode must NOT open a handoff thread."""
+ adapter = self._slack_adapter(supports_inchannel=True)
+ open_thread_mock, _ = self._run_inchannel_delivery(
+ {"cron_continuable_surface": "in_channel"}, adapter,
+ )
+ open_thread_mock.assert_not_called()
+
+ def test_in_channel_seeds_shared_channel_session_flat(self):
+ """G3/F5: with the thread-open branch skipped, the existing origin-mirror
+ seeds the shared-channel session with thread_id=None (flat)."""
+ adapter = self._slack_adapter(supports_inchannel=True)
+ _, mirror_mock = self._run_inchannel_delivery(
+ {"cron_continuable_surface": "in_channel"}, adapter,
+ )
+ mirror_mock.assert_called_once()
+ # Seeded flat: no thread_id → session (slack, C123, None).
+ assert mirror_mock.call_args.kwargs.get("thread_id") is None
+ assert mirror_mock.call_args[0][0] == "slack"
+ assert mirror_mock.call_args[0][1] == "C123"
+ assert "Here is today's brief." in mirror_mock.call_args[0][2]
+
+ def test_thread_mode_default_still_opens_thread(self):
+ """G1 regression: the default (thread) mode is byte-identical — the
+ thread-open branch still fires when no surface key is set."""
+ adapter = self._slack_adapter(supports_inchannel=True)
+ open_thread_mock, _ = self._run_inchannel_delivery({}, adapter)
+ open_thread_mock.assert_called_once()
+
+ def test_explicit_thread_value_opens_thread(self):
+ """An explicit cron_continuable_surface: thread is the default path."""
+ adapter = self._slack_adapter(supports_inchannel=True)
+ open_thread_mock, _ = self._run_inchannel_delivery(
+ {"cron_continuable_surface": "thread"}, adapter,
+ )
+ open_thread_mock.assert_called_once()
+
+ def test_in_channel_on_unsupported_platform_fails_safe_to_thread(self):
+ """D6 fail-safe: in_channel on an adapter WITHOUT the capability flag
+ falls back to the thread path (a threaded continuation ≈ today), never
+ a dropped continuation."""
+ adapter = self._slack_adapter(supports_inchannel=False)
+ open_thread_mock, _ = self._run_inchannel_delivery(
+ {"cron_continuable_surface": "in_channel"}, adapter,
+ )
+ # Capability absent → treated as thread → thread-open still attempted.
+ open_thread_mock.assert_called_once()
+
+ def test_unrecognised_surface_value_coerces_to_thread(self):
+ """Any non-'in_channel' value is the default thread path (fail safe)."""
+ adapter = self._slack_adapter(supports_inchannel=True)
+ open_thread_mock, _ = self._run_inchannel_delivery(
+ {"cron_continuable_surface": "bogus"}, adapter,
+ )
+ open_thread_mock.assert_called_once()
+
diff --git a/tests/gateway/test_slack_cron_continuable_surface.py b/tests/gateway/test_slack_cron_continuable_surface.py
new file mode 100644
index 000000000000..871df34ff6e1
--- /dev/null
+++ b/tests/gateway/test_slack_cron_continuable_surface.py
@@ -0,0 +1,149 @@
+"""
+Tests for the Slack ``cron_continuable_surface`` extra key and its pairing warning.
+
+``cron_continuable_surface: in_channel`` (paired with ``reply_in_thread: false``)
+lets a continuable cron job deliver FLAT into a channel — no dedicated thread —
+so a plain channel reply continues the job via the shared-channel session
+``(slack, channel_id, None)``. See specs/cron-inchannel-continuable decisions
+D1/D4/D5/D6.
+
+- ``_cron_continuable_surface`` resolves the key: default ``"thread"``, coerces
+ any unrecognised value to ``"thread"`` (fail safe), only ``"in_channel"``
+ opts in.
+- ``supports_inchannel_continuable`` is True on Slack (it has both a flat-reply
+ outbound gate and a whole-channel inbound session bucket).
+- ``_warn_if_inchannel_without_flat_reply`` warns (D5: warn, not hard-require)
+ when ``in_channel`` is set without ``reply_in_thread: false`` — the misconfig
+ fails SAFE to a threaded continuation, so it is a warning, not a rejection.
+"""
+
+import logging
+import sys
+from unittest.mock import MagicMock
+
+
+# ---------------------------------------------------------------------------
+# Mock slack-bolt if not installed (same pattern as test_slack_user_token_warning.py)
+# ---------------------------------------------------------------------------
+
+def _ensure_slack_mock():
+ if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
+ return
+
+ slack_bolt = MagicMock()
+ slack_bolt.async_app.AsyncApp = MagicMock
+ slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
+
+ slack_sdk = MagicMock()
+ slack_sdk.web.async_client.AsyncWebClient = MagicMock
+
+ for name, mod in [
+ ("slack_bolt", slack_bolt),
+ ("slack_bolt.async_app", slack_bolt.async_app),
+ ("slack_bolt.adapter", slack_bolt.adapter),
+ ("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
+ ("slack_bolt.adapter.socket_mode.async_handler",
+ slack_bolt.adapter.socket_mode.async_handler),
+ ("slack_sdk", slack_sdk),
+ ("slack_sdk.web", slack_sdk.web),
+ ("slack_sdk.web.async_client", slack_sdk.web.async_client),
+ ]:
+ sys.modules.setdefault(name, mod)
+
+
+_ensure_slack_mock()
+
+import plugins.platforms.slack.adapter as _slack_mod # noqa: E402
+_slack_mod.SLACK_AVAILABLE = True
+
+from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
+
+
+def _make_adapter(extra):
+ """object.__new__ skips __init__ (heavy setup) — established slack-test
+ pattern. Attach a minimal config carrying only the ``extra`` dict."""
+ adapter = object.__new__(SlackAdapter)
+ cfg = MagicMock()
+ cfg.extra = dict(extra)
+ adapter.config = cfg
+ return adapter
+
+
+# --- capability flag -------------------------------------------------------
+
+def test_slack_declares_inchannel_capability():
+ """Slack has both halves the in_channel surface needs, so the class-level
+ capability flag the cron scheduler reads generically must be True."""
+ assert SlackAdapter.supports_inchannel_continuable is True
+
+
+# --- surface resolver ------------------------------------------------------
+
+def test_surface_defaults_to_thread():
+ adapter = _make_adapter({})
+ assert adapter._cron_continuable_surface() == "thread"
+
+
+def test_surface_in_channel_opts_in():
+ adapter = _make_adapter({"cron_continuable_surface": "in_channel"})
+ assert adapter._cron_continuable_surface() == "in_channel"
+
+
+def test_surface_in_channel_case_and_whitespace_insensitive():
+ adapter = _make_adapter({"cron_continuable_surface": " In_Channel "})
+ assert adapter._cron_continuable_surface() == "in_channel"
+
+
+def test_surface_explicit_thread():
+ adapter = _make_adapter({"cron_continuable_surface": "thread"})
+ assert adapter._cron_continuable_surface() == "thread"
+
+
+def test_surface_unrecognised_value_coerces_to_thread():
+ """Fail safe: any value that isn't 'in_channel' resolves to 'thread'."""
+ adapter = _make_adapter({"cron_continuable_surface": "bogus"})
+ assert adapter._cron_continuable_surface() == "thread"
+
+
+# --- pairing warning (D5: warn, not hard-require) --------------------------
+
+def test_warns_when_in_channel_without_flat_reply(caplog):
+ """in_channel set, reply_in_thread left at its True default → warn."""
+ adapter = _make_adapter({"cron_continuable_surface": "in_channel"})
+ with caplog.at_level(logging.WARNING):
+ adapter._warn_if_inchannel_without_flat_reply("Acme")
+ matched = [r for r in caplog.records
+ if "cron_continuable_surface=in_channel" in r.message
+ and "reply_in_thread=false" in r.message]
+ assert matched
+
+
+def test_warns_when_in_channel_with_reply_in_thread_true(caplog):
+ """Explicit reply_in_thread: true alongside in_channel → still warn."""
+ adapter = _make_adapter(
+ {"cron_continuable_surface": "in_channel", "reply_in_thread": True}
+ )
+ with caplog.at_level(logging.WARNING):
+ adapter._warn_if_inchannel_without_flat_reply("Acme")
+ assert any("cron_continuable_surface=in_channel" in r.message
+ for r in caplog.records)
+
+
+def test_no_warning_when_properly_paired(caplog):
+ """in_channel + reply_in_thread: false is the correct pairing → silent."""
+ adapter = _make_adapter(
+ {"cron_continuable_surface": "in_channel", "reply_in_thread": False}
+ )
+ with caplog.at_level(logging.WARNING):
+ adapter._warn_if_inchannel_without_flat_reply("Acme")
+ assert not any("cron_continuable_surface=in_channel" in r.message
+ for r in caplog.records)
+
+
+def test_no_warning_when_surface_is_thread(caplog):
+ """Default thread surface never warns about the pairing."""
+ adapter = _make_adapter({"reply_in_thread": True})
+ with caplog.at_level(logging.WARNING):
+ adapter._warn_if_inchannel_without_flat_reply("Acme")
+ assert not any("cron_continuable_surface=in_channel" in r.message
+ for r in caplog.records)
diff --git a/tests/gateway/test_slack_mention.py b/tests/gateway/test_slack_mention.py
index 62210a69b7a6..c23da97e6f6e 100644
--- a/tests/gateway/test_slack_mention.py
+++ b/tests/gateway/test_slack_mention.py
@@ -501,6 +501,56 @@ def test_config_bridges_slack_reply_in_thread(monkeypatch, tmp_path):
) == "171.000"
+def test_config_bridges_slack_cron_continuable_surface_toplevel(monkeypatch, tmp_path):
+ """The cron_continuable_surface key bridges from a top-level ``slack:`` block
+ into slack.extra, mirroring reply_in_thread (specs D1/D6)."""
+ from gateway.config import load_gateway_config
+
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ (hermes_home / "config.yaml").write_text(
+ "slack:\n"
+ " cron_continuable_surface: in_channel\n"
+ " reply_in_thread: false\n",
+ encoding="utf-8",
+ )
+
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-test")
+
+ config = load_gateway_config()
+
+ slack_config = config.platforms[Platform.SLACK]
+ assert slack_config.extra.get("cron_continuable_surface") == "in_channel"
+ # The adapter resolver reads the bridged key.
+ adapter = SlackAdapter(slack_config)
+ assert adapter._cron_continuable_surface() == "in_channel"
+
+
+def test_config_bridges_slack_cron_continuable_surface_nested(monkeypatch, tmp_path):
+ """The key also bridges from the nested ``platforms.slack.extra`` path."""
+ from gateway.config import load_gateway_config
+
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ (hermes_home / "config.yaml").write_text(
+ "platforms:\n"
+ " slack:\n"
+ " enabled: false\n"
+ " extra:\n"
+ " cron_continuable_surface: in_channel\n",
+ encoding="utf-8",
+ )
+
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-test")
+
+ config = load_gateway_config()
+
+ slack_config = config.platforms[Platform.SLACK]
+ assert slack_config.extra.get("cron_continuable_surface") == "in_channel"
+
+
def test_config_bridges_slack_strict_mention(monkeypatch, tmp_path):
from gateway.config import load_gateway_config
diff --git a/tests/manual/cron_inchannel_e2e.py b/tests/manual/cron_inchannel_e2e.py
new file mode 100644
index 000000000000..aaa525f191c1
--- /dev/null
+++ b/tests/manual/cron_inchannel_e2e.py
@@ -0,0 +1,176 @@
+"""
+Offline E2E harness for continuable in-channel cron (specs/cron-inchannel-continuable).
+
+Drives BOTH legs of the feature against the REAL code paths — no network, no
+Slack contact, no Socket Mode — and asserts they converge on the same
+shared-channel session key ``(slack,