Repository navigation
fix(gateway): preserve prompt pins across synthetic goal/heartbeat/resume turns #127208
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -620,8 +620,8 @@ def _voice_channel_sidecar_note(self, event, source: SessionSource, session_key: | |
| return f"[Voice channel now: {vc_now or 'not connected to a voice channel'}]" | ||
|
|
||
| async def _rehydrate_prompt_pins(self, session_key: str, expected_session_id: Optional[str]) -> None: | ||
| """Adopt the durable pin snapshot for an internal turn when this process holds no pins for | ||
| *session_key* (a restart). Eviction clears only ``ephemeral_pin`` and keeps ``channel_pin``, | ||
| """Adopt the durable pin snapshot for a pin-preserving turn (internal wake or synthetic | ||
| goal/heartbeat continuation) when this process holds no pins for *session_key* (a restart). Eviction clears only ``ephemeral_pin`` and keeps ``channel_pin``, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P3 — post-eviction pin flip, decision requested] (non-blocking, pre-existing but in this PR's class)
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3 — narrow the eviction contract: explicit I need to correct the broad capacity wording in my earlier P3 and in this updated docstring. Actual LRU, TTL, and pressure removal preserve both conversation pins and pass the current continuity controls. The remaining inherited failure is the real Required repair: retain a coherent context/channel pair for resource release and runtime-only rebuilds; express real prompt invalidation and snapshot eligibility explicitly at its caller. Do not adopt every partial snapshot blindly or suppress all synthetic persistence: authoritative human metadata and current privacy policy can legitimately require a replacement pair. Capacity lifetime is already covered by the conversation invariant; this P3 needs a caller-specific repair, not a blanket capacity-eviction exception. Runnable regression: Reproducer C in this review body is the exact HERMES_PYTHON=/path/to/test/python scripts/run_tests.sh -j 1 --file-retries 0 tests/gateway/test_reviewer_prompt_lifetime.py -s --tb=shortHead gives 10 passed / 2 failed; direct helper eviction and the real reasoning command are the only failures. Baseline gives 3 passed / 9 failed. For |
||
| so an evicted agent still re-renders instead of reviving the snapshot.""" | ||
| state = self._peek_session_state(session_key) | ||
| if state is not None and ( | ||
|
|
@@ -662,25 +662,34 @@ async def _persist_prompt_pins(self, session_key: Optional[str], expected_sessio | |
| # Durability protects cache continuity; a store outage must not block the user turn. | ||
| logger.debug("Failed to persist prompt pin for %s", session_key, exc_info=True) | ||
|
|
||
| @staticmethod | ||
| def _event_preserves_prompt_pins(event: Any) -> bool: | ||
| """True when *event* must reuse established prompt-pin identity without becoming internal.""" | ||
| return bool( | ||
| getattr(event, "internal", False) | ||
| or getattr(event, "preserve_prompt_pins", False) | ||
| ) | ||
|
|
||
| def _pinned_session_context_prompt( | ||
| self, context, redact_pii: bool, session_key: Optional[str], *, internal: bool = False, | ||
| self, context, redact_pii: bool, session_key: Optional[str], *, | ||
| internal: bool = False, preserve_pin: Optional[bool] = None, | ||
| ) -> str: | ||
| """Session-context prompt pinned per session: key hit → pinned bytes reused VERBATIM (immune | ||
| to renderer nondeterminism); key miss → re-render and re-pin (rename, topic edit, /sethome). | ||
|
|
||
| ``internal`` events (kanban wakes, delegation completions, watch notifications) carry a | ||
| source rebuilt from the persisted origin, without chat_name/user_name/message_id. Rendering | ||
| from it re-keyed the pin, and the next human turn re-keyed it back (A→B→A), rewriting | ||
| already-sent system bytes each time. An internal event is never a real metadata change, so | ||
| it reuses an existing pin verbatim (restored by ``_rehydrate_prompt_pins`` after a restart); with | ||
| no pin yet it renders and pins as usual. The pin records the ``privacy.redact_pii`` it was | ||
| Pin-preserving events include internal wakes plus synthetic goal/heartbeat continuations | ||
| that intentionally carry no fresh channel/session metadata. Re-rendering either shape | ||
| re-keys the pin and the next human turn re-keys it back (A→B→A), rewriting already-sent | ||
| system bytes each time. With no established pin yet, render and pin as usual. The pin records the ``privacy.redact_pii`` it was | ||
| rendered under: bytes from another privacy policy are never reused, even by an internal | ||
| event.""" | ||
| if preserve_pin is None: | ||
| preserve_pin = internal | ||
| _pin_state = self._peek_session_state(session_key) if session_key else None | ||
| _eph_pin = _pin_state.conversation.ephemeral_pin if _pin_state else None | ||
| if _eph_pin is not None and _eph_pin[2] != redact_pii: | ||
| _eph_pin = None | ||
| if internal and _eph_pin is not None: | ||
| if preserve_pin and _eph_pin is not None: | ||
| return _eph_pin[1] | ||
| _eph_key = self._ephemeral_change_key(context, redact_pii) | ||
| if _eph_pin is not None and _eph_pin[0] == _eph_key: | ||
|
|
@@ -691,18 +700,21 @@ def _pinned_session_context_prompt( | |
| return text | ||
|
|
||
| def _pinned_channel_inputs( | ||
| self, session_key: Optional[str], channel_prompt: Optional[str], source: SessionSource, *, internal: bool, | ||
| self, session_key: Optional[str], channel_prompt: Optional[str], source: SessionSource, *, | ||
| internal: bool = False, preserve_pin: Optional[bool] = None, | ||
| ): | ||
| """``(channel_prompt, source)`` for this turn's agent run. | ||
|
|
||
| The ephemeral system prompt also appends ``channel_prompt`` and the ``channel_overrides`` | ||
| prompt (looked up by chat/thread/``parent_chat_id``). Internal events carry | ||
| ``channel_prompt=None`` and a source without ``parent_chat_id``, so they dropped both and | ||
| toggled the system prompt like the context pin did. Human turns record their inputs; | ||
| internal turns reuse them (``_rehydrate_prompt_pins`` restores both after a restart).""" | ||
| prompt (looked up by chat/thread/``parent_chat_id``). Pin-preserving events carry no | ||
| authoritative channel inputs of their own, so they reuse the established human-turn pin | ||
| without changing authorization semantics. Ordinary turns record their current inputs | ||
| (``_rehydrate_prompt_pins`` restores both after a restart).""" | ||
| if preserve_pin is None: | ||
| preserve_pin = internal | ||
| if not session_key: | ||
| return channel_prompt, source | ||
| if not internal: | ||
| if not preserve_pin: | ||
| self._session_state(session_key).conversation.channel_pin = (channel_prompt, source.parent_chat_id) | ||
| return channel_prompt, source | ||
| state = self._peek_session_state(session_key) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -386,6 +386,8 @@ def _queue_or_replace_pending_event(self, session_key: str, event: MessageEvent) | |
| existing = pending_slot.get(session_key) if isinstance(pending_slot, dict) else None | ||
| same_security_context = existing is not None and ( | ||
| getattr(existing, "internal", False) == getattr(event, "internal", False) | ||
| and getattr(existing, "preserve_prompt_pins", False) | ||
| == getattr(event, "preserve_prompt_pins", False) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Bug] (blocking) This identity check only protects There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Preserve this separation when restoring a turn at the recursion cap The new admission predicate separates a synthetic continuation from human input, but the later recursion-cap branch restores the drained event through the unguarded merge helper. The drain has already promoted the successor into the pending slot. Restoring the older event therefore replaces that successor when both are TEXT, or absorbs the older photo/caption into the successor when media is present. At The exact same three conservation assertions fail at merge base The repair belongs at queue-front restoration: retain the staged successor, restore the unprocessed current event ahead of it, and never merge these already-separated events or count their restoration as fresh capacity. Regression coverage needs both orders, the photo case, existing overflow, and capacity-full restoration, asserting distinct object/content identity and original order through subsequent draining. #78015 already owns the generic restoration class; #126167 has a narrower cross-prompt-identity change. Neither repair exists at this head. This is distinct from the existing ingress/debounce finding, not a duplicate of it.
Comment on lines
+389
to
+390
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new merge guard misses queue-mode text. With The merged event keeps the continuation’s source, Enforce this boundary in the shared merge/debounce path too, retaining both events, and test through the wired adapter. Prompt To Fix With AIThis is a comment left during a code review.
Path: gateway/run_busy.py
Line: 389-390
Comment:
**Human text inherits synthetic pins**
The new merge guard misses queue-mode text. With `busy_text_mode: queue`, the runner returns `False` for ordinary text, so the adapter buffers it separately. `_flush_text_debounce_now` later merges it into a pending continuation when both sources name the same sender, without checking `preserve_prompt_pins`.
The merged event keeps the continuation’s source, `channel_prompt=None`, and preservation flag. The human message’s fresh channel prompt is therefore ignored.
Enforce this boundary in the shared merge/debounce path too, retaining both events, and test through the wired adapter.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| and getattr(existing, "allow_gateway_control", True) | ||
| == getattr(event, "allow_gateway_control", True) | ||
| and all( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -107,7 +107,10 @@ def _synthetic_prompt_event(source: Any, text: str, *, internal: bool = False) - | |
| would quote that stale message (Telegram DM topics route anchorless via the topic id). | ||
| """ | ||
| source = dataclasses.replace(source, message_id=None) if getattr(source, "message_id", None) else source | ||
| return MessageEvent(text=text, message_type=MessageType.TEXT, source=source, internal=internal) | ||
| return MessageEvent( | ||
| text=text, message_type=MessageType.TEXT, source=source, internal=internal, | ||
| preserve_prompt_pins=True, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1 — fused continuations are destructively misidentified] (blocking) This producer's output is identified downstream by text prefix:
Required repair. Primary: prevent the fusion (merge-boundary finding). Defense in depth: use a structural marker (the producer already sets Regression contract. A human turn queued behind a continuation must survive There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1 — give goal work a structural kind at the producer; the current classifier deletes separate human input. This is fresh execution evidence for the existing classification concern and extends the earlier cleanup finding, rather than another count of its fused-event examples. The new flag at this seam expresses prompt preservation for both goals and heartbeats; it cannot supply a goal-specific identity. The actual Required repair: tag genuine goal continuations structurally at their producer, preserve that identity through copies, and use it in both destructive consumers. Keep heartbeat preservation distinct from goal cancellation. Preserve accepted human content, media and FIFO order at every queue boundary, including cleanup of any previously fused object. Runnable regression: the exact HERMES_PYTHON=/path/to/test/python scripts/run_tests.sh -j 1 --file-retries 0 tests/gateway/test_review_continuation_conservation.py -s --tb=shortIt checks pause/clear × slot/overflow plus the no-goal consumer, with genuine-continuation cleanup and ordinary-human controls. Head and baseline each give 7 failed / 6 passed overall; the other two failures are the separate judge-settlement race explained in the body. Command handlers are invoked directly; the consumer tests compose real adapter/drain/follow-up behavior with fake transport and a recording model sink. They do not execute full gateway admission or a live model. Closure must retain these human requests and remove only actual canceled goal work; add the corresponding heartbeat-kind control when introducing that field. |
||
| ) | ||
|
|
||
| def _register_heartbeat_watch(self, quick_key: str, source: Any, session_id: str) -> None: | ||
| """Track the canonical route and start the restart-recoverable poller.""" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -78,13 +78,16 @@ def _enqueue_goal_turn( | |
| try: | ||
| adapter, quick_key = self._adapter_and_key_for(event) | ||
| if text and adapter and quick_key: | ||
| turn = MessageEvent( | ||
| text=text, | ||
| message_type=MessageType.TEXT, | ||
| source=event.source, | ||
| message_id=event.message_id if kickoff else None, | ||
| channel_prompt=event.channel_prompt if kickoff else None, | ||
| ) | ||
| if kickoff: | ||
| turn = MessageEvent( | ||
| text=text, | ||
| message_type=MessageType.TEXT, | ||
| source=event.source, | ||
| message_id=event.message_id, | ||
| channel_prompt=event.channel_prompt, | ||
| ) | ||
| else: | ||
| turn = self._synthetic_prompt_event(event.source, text) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Keep the receiving-bot identity when routing resume through this factory This new call replaces the previous The failing path is Reproduced in a partially composed probe through the real FIFO drain and The common helper's unsafe clone is older code, but moving resume into it introduces this failure for a previously provenance-preserving caller. The existing Regression: both routed directions, source message ID present, enqueue a resume during an active turn, drain it in-band, and assert progress is sent through the receiving adapter, authorization-home identity is retained, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new A resume queued during an active turn reaches The gateway guide requires source copies to use Context Used: gateway/AGENTS.md (source) Prompt To Fix With AIThis is a comment left during a code review.
Path: gateway/slash_commands_goals.py
Line: 90
Comment:
**Resume uses the wrong bot**
The new `/goal resume` path loses the receiving bot’s identity when `source.message_id` is present. `_synthetic_prompt_event` uses `dataclasses.replace`, which drops `_identity`, `_transport_adapter_ref`, and `_authorization_profile_home`.
A resume queued during an active turn reaches `_run_agent_queued_followup` without rebuilding that identity. If the receiving bot routes into a profile with its own bot, progress and streamed replies then use that profile’s bot instead.
The gateway guide requires source copies to use `session_identity.replace_source`. Use it inside the shared producer and add a test for a routed, mid-run resume.
**Context Used:** gateway/AGENTS.md ([source](https://github.com/nousresearch/hermes-agent/blob/main/gateway/AGENTS.md))
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| self._enqueue_fifo(quick_key, turn, adapter) | ||
| except Exception as exc: | ||
| logger.debug("goal %s failed: %s", label, exc) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ | |
|
|
||
| from __future__ import annotations | ||
|
|
||
| import dataclasses | ||
| from datetime import datetime | ||
| from unittest.mock import AsyncMock, MagicMock | ||
|
|
||
|
|
@@ -320,6 +321,77 @@ async def test_event_backed_followup_overrides_inherited_channel_prompt(monkeypa | |
| assert len(calls) == 1 | ||
| assert calls[0]["channel_prompt"] == "Queued event prompt.", "event prompt must win over the inherited one" | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_non_internal_synthetic_event_preserves_all_prompt_pins(monkeypatch): | ||
| """#126109: non-internal synthetic turns inherit both session-context and channel pins.""" | ||
| config = GatewayConfig() | ||
| config.platforms[Platform.DISCORD] = PlatformConfig( | ||
| enabled=True, | ||
| channel_overrides={PARENT_ID: ChannelOverride(system_prompt="Parent persona.")}, | ||
| ) | ||
| runner = _make_runner(monkeypatch, config) | ||
| calls: list[dict] = [] | ||
| _capture(runner, calls) | ||
|
|
||
| source = _human_thread_source() | ||
| await _drive(runner, ((False, source),), channel_prompt="Channel hint.") | ||
|
|
||
| synthetic = runner._synthetic_prompt_event(source, "[Continuing toward your standing goal]") | ||
| assert synthetic.internal is False | ||
| assert synthetic.channel_prompt is None | ||
| assert synthetic.source.message_id is None | ||
| assert synthetic.preserve_prompt_pins is True | ||
| # pre_gateway_dispatch rewrite hooks rebuild events with dataclasses.replace. | ||
| synthetic = dataclasses.replace(synthetic, text=synthetic.text + " (rewritten)") | ||
| assert synthetic.preserve_prompt_pins is True | ||
|
|
||
| await runner._handle_message_with_agent(synthetic, synthetic.source, KEY, 1) | ||
| await _drive(runner, ((False, source),), channel_prompt="Channel hint.") | ||
|
|
||
| assert [call["channel_prompt"] for call in calls] == ["Channel hint."] * 3 | ||
| ephemeral = [_effective_ephemeral(runner, call) for call in calls] | ||
| assert "Triggering message: provided per-turn" in ephemeral[0] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Assert prompt continuity without freezing this sentence This literal substring is not the byte-stability contract. In the local uncommitted composition of this head with #127944 at The union tree is The repair is to retain semantic equality and the channel/parent inputs as the oracle; if fixture activation needs checking, assert the relevant metadata/capability relationship instead of obsolete wording. Regression acceptance is this pin-wiring file plus the complementary voice tests green on the exact composed tree. The separate queue failures remain unresolved; changing this test does not fix them. No existing product or test source was modified to obtain the observations above. |
||
| assert ephemeral[0] == ephemeral[1] == ephemeral[2] | ||
| assert runner._peek_session_state(KEY).conversation.channel_pin == ("Channel hint.", PARENT_ID) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_queued_synthetic_event_preserves_channel_pin(monkeypatch): | ||
| """In-band FIFO continuations consume the same preservation marker.""" | ||
| runner = _make_runner(monkeypatch) | ||
| calls: list[dict] = [] | ||
| _capture(runner, calls) | ||
| runner._run_agent_deliver_first_response = AsyncMock() | ||
| runner._refresh_agent_cache_message_count = AsyncMock() | ||
| runner._prepare_profile_scoped_inbound_message_text = AsyncMock(return_value="continue") | ||
| runner._session_key_for_source = lambda source: KEY | ||
|
|
||
| source = _human_thread_source() | ||
| await _drive(runner, ((False, source),), channel_prompt="Channel hint.") | ||
| first = calls[0] | ||
| turn_ctx = TurnContext( | ||
| source=first["source"], | ||
| context_prompt=first["context_prompt"], | ||
| channel_prompt=first["channel_prompt"], | ||
| session_key=KEY, | ||
| session_id="sess-wiring", | ||
| run_generation=1, | ||
| history=[], | ||
| ) | ||
| adapter = MagicMock() | ||
| adapter._active_sessions = {} | ||
| pending = runner._synthetic_prompt_event(source, "[goal] continue") | ||
|
|
||
| await runner._run_agent_queued_followup( | ||
| turn_ctx, adapter, pending.text, pending, "done", | ||
| {"final_response": "done", "messages": []}, None, | ||
| ) | ||
|
|
||
| assert len(calls) == 2 | ||
| assert calls[1]["channel_prompt"] == "Channel hint." | ||
| assert runner._peek_session_state(KEY).conversation.channel_pin == ("Channel hint.", PARENT_ID) | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_first_internal_event_after_restart_rehydrates_durable_prompt_pins(monkeypatch): | ||
| config = GatewayConfig() | ||
|
|
@@ -362,6 +434,37 @@ async def test_first_internal_event_after_restart_rehydrates_durable_prompt_pins | |
| assert _effective_ephemeral(after, first_internal) == _effective_ephemeral(before, human) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_first_synthetic_event_after_restart_rehydrates_durable_prompt_pins(monkeypatch): | ||
| """#126109 across a restart: a goal/heartbeat continuation is usually the first turn a | ||
| restarted gateway runs for a session, and it is non-internal, so it must adopt the durable | ||
| pins on the same decision the in-process reuse makes (``preserve_prompt_pins``).""" | ||
| config = GatewayConfig() | ||
| config.platforms[Platform.DISCORD] = PlatformConfig( | ||
| enabled=True, | ||
| channel_overrides={PARENT_ID: ChannelOverride(system_prompt="Parent persona.")}, | ||
| ) | ||
| durable: dict = {} | ||
| before = _make_runner(monkeypatch, config, durable_prompt_pin=durable) | ||
| calls_before: list[dict] = [] | ||
| _capture(before, calls_before) | ||
| await _drive(before, ((False, _human_thread_source()),), channel_prompt="Channel hint.") | ||
| assert durable["value"]["channel_prompt"] == "Channel hint." | ||
|
|
||
| after = _make_runner(monkeypatch, config, durable_prompt_pin=durable) | ||
| calls_after: list[dict] = [] | ||
| _capture(after, calls_after) | ||
| synthetic = after._synthetic_prompt_event(_human_thread_source(), "[Continuing toward your standing goal]") | ||
| assert synthetic.internal is False and synthetic.preserve_prompt_pins is True | ||
| await after._handle_message_with_agent(synthetic, synthetic.source, KEY, 1) | ||
| await _drive(after, ((False, _human_thread_source()),), channel_prompt="Channel hint.") | ||
|
|
||
| human, first_synthetic, next_human = calls_before[0], calls_after[0], calls_after[1] | ||
| assert [human["channel_prompt"], first_synthetic["channel_prompt"], next_human["channel_prompt"]] == ["Channel hint."] * 3 | ||
| assert _effective_ephemeral(after, first_synthetic) == _effective_ephemeral(before, human) | ||
| assert _effective_ephemeral(after, next_human) == _effective_ephemeral(before, human) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_human_first_after_restart_ignores_stale_durable_prompt_pin(monkeypatch): | ||
| durable = { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P1 — merge boundary incomplete] (blocking, extends @ehz0ah's inline 4128991175 on this hunk)
The guard added here protects
_queue_or_replace_pending_event, but the same synthetic-vs-human fusion happens at the other merge sites of this pending slot. Reproduced at this head with the real functions (busy Telegram DM session,_synthetic_prompt_eventoutput pending):gateway/run_inbound.py:672(merge_text=True): insideHERMES_TELEGRAM_FOLLOWUP_GRACE_SECONDS, pending text becomes"[Continuing toward your standing goal]\nGoal: …\n<human text>", flag staysTrue.gateway/run_inbound.py:745(merge_text=True): same while_AGENT_PENDING_SENTINELholds the slot.gateway/run_inbound.py:651->_hm_merge_pending_for_source(merge at 621): a human PHOTO is absorbed into the pending continuation (media_urlsextended, no interrupt). The PR body's "never … absorbs a human photo turn" holds for_queue_or_replace_pending_event, not here.gateway/platforms/base.py:3858(merge at 3812): as filed by @ehz0ah; their inline stands.At consumption the merged event resolves
preserve=True(run_turn_event_preserves_prompt_pins), so the human content runs under the continuation's pinned prompt identity and the human's own inputs are never recorded. Reproduced: merged turn uses the pinned channel prompt ("Channel hint.").Required repair. Enforce the boundary where merges are attempted, not in one caller — make the merge decision identity-aware (e.g.
merge_pending_message_event/_can_merge_text_debounce_eventsrefuse absorption whenpreserve_prompt_pinsdiffers, or a shared predicate used by the run_inbound sites + debounce pair). Invariant: a pin-preserving event is never a merge target; two turns with different prompt identity never become one turn.Regression contract. For grace/sentinel/photo/debounce: with a
preserve_prompt_pins=Trueevent pending and a human TEXT or PHOTO inbound, assert two queue entries (or a pending owned by the human), and assert the run's prompt identity matches the human turn. The current suite passes because its guard test drives only_queue_or_replace_pending_event.