fix(cron): DM cron thread seed keys through the DM arm — thread-typed seed row never matched the DM reply's key
Live incident (Alice canary 2026-08-20, job 8e21a957b77b): the continuable thread seed created its session with chat_type='thread', but a Slack DM in-thread reply arrives chat_type='dm' and build_session_key routes DM threads through the DM arm (...:dm:<chat>:<thread>). Seed row and reply row never matched — the reply had no brief in context (continuation amnesia). is_dm on _seed_cron_thread_session selects the seeded chat_type at both call sites (opened-thread and the companion in_channel thread seed); channel threads are unchanged. Sibling lane of the flat seed's is_dm fix. Tests pin the key-equality contract: seeded key == the key the reply builds.
This commit is contained in:
+16
-1
@@ -1730,6 +1730,7 @@ def _seed_cron_thread_session(
|
||||
thread_id: str,
|
||||
mirror_text: str,
|
||||
chat_name: Optional[str] = None,
|
||||
is_dm: bool = False,
|
||||
) -> None:
|
||||
"""Seed the freshly-opened cron thread's session with the brief.
|
||||
|
||||
@@ -1740,6 +1741,16 @@ def _seed_cron_thread_session(
|
||||
threads as participant-shared, so no ``user_id`` is needed) and append the
|
||||
brief as an assistant turn via the shipped ``mirror_to_session``.
|
||||
|
||||
``is_dm`` selects the seeded ``chat_type``: a thread under a DM must seed
|
||||
``chat_type="dm"`` because the user's in-thread DM reply arrives with
|
||||
chat_type="dm" and ``build_session_key`` routes DM threads through the DM
|
||||
arm (``...:dm:<chat>:<thread>``) — a "thread"-typed seed lands in
|
||||
``...:thread:<chat>:<thread>``, a row no DM reply ever resolves to
|
||||
(continuation amnesia, Alice live 2026-08-20, job 8e21a957b77b). Channel
|
||||
threads keep ``chat_type="thread"`` (their replies really do arrive as
|
||||
threads). Same sibling-lane class as the flat seed's ``is_dm``
|
||||
(dcca9d8cfe).
|
||||
|
||||
Mirrors ``GatewayRunner._process_handoff``'s seed step, but standalone:
|
||||
cron reaches the live ``SessionStore`` through the adapter's
|
||||
``_session_store`` handle rather than the gateway object. Best-effort — a
|
||||
@@ -1774,7 +1785,9 @@ def _seed_cron_thread_session(
|
||||
platform=platform_enum,
|
||||
chat_id=seed_chat_id,
|
||||
chat_name=chat_name,
|
||||
chat_type="thread",
|
||||
# DM threads key through the DM arm (see docstring); the
|
||||
# reply's chat_type is what the seed must reproduce.
|
||||
chat_type="dm" if is_dm else "thread",
|
||||
user_id="system:cron",
|
||||
user_name="Cron",
|
||||
thread_id=str(thread_id),
|
||||
@@ -3130,6 +3143,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
job, runtime_adapter, platform_name, chat_id,
|
||||
opened_thread_id, mirror_text,
|
||||
chat_name=origin.get("chat_name"),
|
||||
is_dm=is_dm_target,
|
||||
)
|
||||
thread_seeded = True
|
||||
# in_channel surface: CREATE + seed the flat channel/DM
|
||||
@@ -3169,6 +3183,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
job, runtime_adapter, platform_name, chat_id,
|
||||
str(delivered_message_id), mirror_text,
|
||||
chat_name=origin.get("chat_name"),
|
||||
is_dm=is_dm_target,
|
||||
)
|
||||
elif in_channel_surface and not origin_target:
|
||||
logger.warning(
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Cron thread-seed must key EXACTLY like the reply that will continue it.
|
||||
|
||||
Live incident (Alice, 2026-08-20 01:08, job 8e21a957b77b): the continuable
|
||||
cron thread seed created its session row with chat_type="thread", but a
|
||||
Slack DM thread reply arrives with chat_type="dm" — build_session_key puts
|
||||
them in different rows (agent:main:slack:thread:D...:<ts> vs
|
||||
agent:main:slack:dm:D...:<ts>), so the user's reply hit a session that had
|
||||
never seen the brief. Channels are unaffected (channel thread replies carry
|
||||
chat_type="thread"); the DM lane is the unswept sibling of the flat-seed
|
||||
is_dm fix (dcca9d8cfe).
|
||||
|
||||
Contract under test: the KEY of the seeded session equals the KEY the
|
||||
user's in-thread reply will build. Asserting on build_session_key output —
|
||||
not on SessionSource field shapes — pins the end-to-end contract.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cron.scheduler import _seed_cron_thread_session
|
||||
from gateway.config import Platform
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
|
||||
|
||||
def _seeded_source(store):
|
||||
store.get_or_create_session.assert_called_once()
|
||||
return store.get_or_create_session.call_args[0][0]
|
||||
|
||||
|
||||
def test_dm_thread_seed_key_matches_dm_reply_key():
|
||||
"""A brief threaded under a Slack DM must seed the same session row a
|
||||
DM in-thread reply resolves to (chat_type='dm', not 'thread')."""
|
||||
store = MagicMock()
|
||||
adapter = MagicMock()
|
||||
adapter._session_store = store
|
||||
|
||||
with patch("gateway.mirror.mirror_to_session", return_value=True):
|
||||
_seed_cron_thread_session(
|
||||
{"id": "j1", "name": "digest"}, adapter, "slack",
|
||||
"D0BJTDCSR7C", "1787188136.448949", "Three bullets",
|
||||
chat_name=None, is_dm=True,
|
||||
)
|
||||
|
||||
reply_source = SessionSource(
|
||||
platform=Platform.SLACK,
|
||||
chat_id="D0BJTDCSR7C",
|
||||
chat_type="dm",
|
||||
user_id="U0B5F8EEYAD",
|
||||
thread_id="1787188136.448949",
|
||||
)
|
||||
assert build_session_key(_seeded_source(store)) == build_session_key(
|
||||
reply_source
|
||||
), (
|
||||
"seeded key diverges from the DM reply's key — the brief lands in a "
|
||||
"row no reply ever resolves to (continuation amnesia)"
|
||||
)
|
||||
|
||||
|
||||
def test_channel_thread_seed_key_matches_thread_reply_key():
|
||||
"""Channel behavior must NOT regress: a channel thread reply keys as
|
||||
chat_type='thread' (participant-shared), and the seed must keep matching
|
||||
it."""
|
||||
store = MagicMock()
|
||||
adapter = MagicMock()
|
||||
adapter._session_store = store
|
||||
|
||||
with patch("gateway.mirror.mirror_to_session", return_value=True):
|
||||
_seed_cron_thread_session(
|
||||
{"id": "j2", "name": "digest"}, adapter, "slack",
|
||||
"C0AAAAAAAA", "1787188000.000100", "Three bullets",
|
||||
chat_name="ops", is_dm=False,
|
||||
)
|
||||
|
||||
reply_source = SessionSource(
|
||||
platform=Platform.SLACK,
|
||||
chat_id="C0AAAAAAAA",
|
||||
chat_type="thread",
|
||||
user_id="U0B5F8EEYAD",
|
||||
thread_id="1787188000.000100",
|
||||
)
|
||||
assert build_session_key(_seeded_source(store)) == build_session_key(
|
||||
reply_source
|
||||
)
|
||||
|
||||
|
||||
def test_dm_seed_default_is_backward_compatible():
|
||||
"""Callers that don't pass is_dm keep today's thread-keyed behavior —
|
||||
the new parameter must not silently rekey non-DM call sites."""
|
||||
store = MagicMock()
|
||||
adapter = MagicMock()
|
||||
adapter._session_store = store
|
||||
|
||||
with patch("gateway.mirror.mirror_to_session", return_value=True):
|
||||
_seed_cron_thread_session(
|
||||
{"id": "j3"}, adapter, "telegram", "123", "9001", "brief",
|
||||
)
|
||||
|
||||
assert _seeded_source(store).chat_type == "thread"
|
||||
Reference in New Issue
Block a user