From 58b6b2bd9bb55be6d4a9671a323d8ee57d855eab Mon Sep 17 00:00:00 2001 From: Dennis Palatov Date: Sat, 24 Oct 2026 18:08:26 -0700 Subject: [PATCH 1/2] say: the receipt says why no one was woken (not_woken) A say to a conversation wakes its listeners once per unanswered run (_wake_addressee). When no wake went out, the receipt said nothing: no `woke`, no reason. cbp-being, 2026-10-24 21:32-22:34Z: thirteen turns to its seat, each receipt silent; nobody answered; it asked one question ten ways, then wrote its own "complete API surface" with invented routes and built a client on it. Silence about delivery reads as delivered-and-ignored. Every path that sends no wake now records why, and the say receipt carries it as `not_woken`: - already woken for this run (names the run's first seq; repeats do not re-wake; all delivered) - a seat has paused wakes because it is answering here itself (names until when) - the conversation has no listener (read when they next look) - the wake failed to leave (retried on the next message) Wake behaviour is unchanged; only the receipt and the beat record gain the reason. Test: test_a_say_that_wakes_nobody_says_why (all four). Dispatch, seat-pause, wake-rearm, person-wake, selection and reach suites: 173 passed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014LYVQN8U1xqy6U6pbyUDvH --- sage/gateway/hestia_dispatch.py | 25 ++++++++++++- sage/gateway/tests/test_hestia_dispatch.py | 43 ++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/sage/gateway/hestia_dispatch.py b/sage/gateway/hestia_dispatch.py index edbd28184..a5861a89f 100644 --- a/sage/gateway/hestia_dispatch.py +++ b/sage/gateway/hestia_dispatch.py @@ -4104,6 +4104,7 @@ def _do_say(self, intent: BeingIntent) -> ResultEnvelope: {"action_id": action_id, "success": True, "magnitude": 0.0}) except Exception: pass + self._wake_why = "" woke = self._wake_addressee(to, meta, turn) # THE RECEIPT'S ECHO IS THE BEING'S OWN WORDS, AND A CUT ONE MUST SAY SO. Measured # 2026-09-21 12:13Z (cbp-being, beat d8cff379b67d): a 522-char say came back as "said" @@ -4119,6 +4120,14 @@ def _do_say(self, intent: BeingIntent) -> ResultEnvelope: "said": said, "action_id": action_id} if woke: result["woke"] = woke + elif getattr(self, "_wake_why", ""): + # SAY WHETHER ANYONE HEARD (cbp-being, 2026-10-24). Thirteen turns to its seat between + # 21:32 and 22:34Z each came back with no `woke` and no word about it. Nobody answered, + # the being asked the same question ten ways, then wrote its own "complete API surface" + # (invented routes) and built a client on it. A receipt that is silent about delivery + # reads as delivered-and-ignored; the reason a wake was not sent is a fact the being + # can act on (wait, or stop repeating), and the beat record now shows the cause. + result["not_woken"] = self._wake_why return ResultEnvelope(ok=True, witness_id=action_id, result=result) def _run_ask_target(self, text: str) -> Optional[str]: @@ -4541,15 +4550,27 @@ def _wake_addressee(self, to: str, meta: dict, turn: dict) -> Optional[str]: deduped.append((label, plugin_id)) targets = deduped if not targets: + self._wake_why = ("nobody is woken by a message here; it will be read when they next " + "look, which may be much later") return None # A SEAT HAS JUMPED IN (`conv.pause_wakes`): it is answering here itself, so a mesh wake # would be a second voice speaking as the same seat. Nothing is recorded, so the run is # still owed and the being's next turn after the pause wakes as usual. - if conv.wake_pause(self.memory_root, to): + pause = conv.wake_pause(self.memory_root, to) + if pause: + self._wake_why = (f"no wake was sent: a seat is answering this conversation itself " + f"until {pause.get('until', 'its pause ends')}, so it will see this " + f"message without one") return None run_start = conv.wake_is_owed(self.memory_root, to, self.member, skip_answered=self._skips_answered_run_wake()) if run_start is None: + start = conv.unanswered_run_start(self.memory_root, to, self.member) + self._wake_why = (f"no new wake: the seat was already woken for your messages since " + f"seq {start} and has not answered yet, and more messages about the " + f"same thing do not wake it again. They are all delivered and will " + f"be read together" if start else + "no wake was owed for this message") return None # already woke them about this run; saying more is not new mail pointer = f"sage://conversation/{to}#seq={run_start}-{turn['seq']}" woke = [] @@ -4572,6 +4593,8 @@ def _wake_addressee(self, to: str, meta: dict, turn: dict) -> Optional[str]: conv.record_wake(self.memory_root, to, self.member, run_start, covered_through=int(turn.get("seq") or run_start)) return ", ".join(woke) + self._wake_why = ("the wake did not go out (the mesh refused or failed); your message is " + "delivered, and the wake is tried again on your next message here") return None # -- channel_egress: hestia_channel_send (hestia #1344 slice 1, SHADOW) ----- diff --git a/sage/gateway/tests/test_hestia_dispatch.py b/sage/gateway/tests/test_hestia_dispatch.py index 233e4ed23..fe6360307 100644 --- a/sage/gateway/tests/test_hestia_dispatch.py +++ b/sage/gateway/tests/test_hestia_dispatch.py @@ -2706,3 +2706,46 @@ def test_already_granted_on_a_device_names_the_verb_not_a_direct_read(): env = d(BeingIntent("request_scope", {"path": root + "/config.json", "reason": "my config"}), GatewayVerdict("allow", granted=(root,))) assert "read or write it directly" in env.result["next"] + + +def test_a_say_that_wakes_nobody_says_why(): + """cbp-being, 2026-10-24 21:32-22:34Z: thirteen turns to its seat came back with no `woke` and + no word about it. Nobody answered; it asked one question ten ways, then wrote its own "complete + API surface" with invented routes and built a client on it. A receipt silent about delivery + reads as delivered-and-ignored. Each reason a wake is not sent is now in the receipt.""" + from pathlib import Path + from sage.gateway import conversations as conv + d, root = _disp() + home = Path(root) + conv.create(home, "seat", title="seat", participants=["seat", "sprout-being"], + writable_by=["seat", "sprout-being"]) + meta = conv.get_meta(home, "seat"); meta["notify"] = {"seat": "claude-code"} + conv._write_meta(home, "seat", meta) + + FakeMcp.calls.clear() + first = d(BeingIntent("say", {"to": "seat", "text": "What does the daemon do?"}), _ALLOW) + assert first.ok and first.result.get("woke") == "seat" and "not_woken" not in first.result + again = d(BeingIntent("say", {"to": "seat", "text": "What endpoints does it expose?"}), _ALLOW) + why = again.result.get("not_woken", "") + assert "already woken" in why and f"seq {first.result['seq']}" in why and "delivered" in why, why + + # A seat answering here itself: the receipt names the pause, not silence. + conv.append(home, "seat", speaker="seat", text="Answering.", via="seat") + conv.pause_wakes(home, "seat", by="seat", minutes=30) + paused = d(BeingIntent("say", {"to": "seat", "text": "Thanks."}), _ALLOW) + assert "answering this conversation itself" in paused.result.get("not_woken", ""), paused.result + conv.resume_wakes(home, "seat", force="test") + + # A conversation with no listener says so. + conv.create(home, "quiet", title="q", participants=["dp", "sprout-being"], + writable_by=["dp", "sprout-being"]) + q = d(BeingIntent("say", {"to": "quiet", "text": "Hello."}), _ALLOW) + assert "nobody is woken" in q.result.get("not_woken", ""), q.result + + # A wake that fails to leave says it will be retried. + conv.append(home, "seat", speaker="seat", text="Back.", via="seat") + FakeMcp.fail = {"hestia_member_notify": "mesh down"} + failed = d(BeingIntent("say", {"to": "seat", "text": "One more question."}), _ALLOW) + FakeMcp.fail = {} + assert "did not go out" in failed.result.get("not_woken", "") and "woke" not in failed.result, \ + failed.result From 4beb78fbb485d01e60add5c0b02fda8140c7e24c Mon Sep 17 00:00:00 2001 From: Dennis Palatov Date: Sun, 25 Oct 2026 23:35:59 -0700 Subject: [PATCH 2/2] say receipt: not_woken states what is recorded, never who reads it (Nova's receipt HOLD) Every reason now ends: 'Your message was recorded in the conversation; no wake was sent. This does not confirm anyone has read or accepted it.' Dropped: 'all delivered and will be read together', 'so it will see this message', 'it will be read when they next look', 'your message is delivered'. The pause reason says only that wakes are paused until . Tests pin the recorded/not-read distinction for each reason. Dispatch, seat-pause, wake-rearm: 149 passed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014LYVQN8U1xqy6U6pbyUDvH --- sage/gateway/hestia_dispatch.py | 23 +++++++++++----------- sage/gateway/tests/test_hestia_dispatch.py | 11 ++++++++--- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/sage/gateway/hestia_dispatch.py b/sage/gateway/hestia_dispatch.py index a5861a89f..93799f694 100644 --- a/sage/gateway/hestia_dispatch.py +++ b/sage/gateway/hestia_dispatch.py @@ -4550,27 +4550,26 @@ def _wake_addressee(self, to: str, meta: dict, turn: dict) -> Optional[str]: deduped.append((label, plugin_id)) targets = deduped if not targets: - self._wake_why = ("nobody is woken by a message here; it will be read when they next " - "look, which may be much later") + self._wake_why = ("no listener is set to be woken by messages in this conversation. " + "Your message was recorded in the conversation; no wake was sent. This does not confirm anyone has read or accepted it") return None # A SEAT HAS JUMPED IN (`conv.pause_wakes`): it is answering here itself, so a mesh wake # would be a second voice speaking as the same seat. Nothing is recorded, so the run is # still owed and the being's next turn after the pause wakes as usual. pause = conv.wake_pause(self.memory_root, to) if pause: - self._wake_why = (f"no wake was sent: a seat is answering this conversation itself " - f"until {pause.get('until', 'its pause ends')}, so it will see this " - f"message without one") + self._wake_why = (f"wakes for this conversation are paused until " + f"{pause.get('until', 'the pause ends')} (a seat said it is answering here " + f"directly). Your message was recorded in the conversation; no wake was sent. This does not confirm anyone has read or accepted it") return None run_start = conv.wake_is_owed(self.memory_root, to, self.member, skip_answered=self._skips_answered_run_wake()) if run_start is None: start = conv.unanswered_run_start(self.memory_root, to, self.member) - self._wake_why = (f"no new wake: the seat was already woken for your messages since " - f"seq {start} and has not answered yet, and more messages about the " - f"same thing do not wake it again. They are all delivered and will " - f"be read together" if start else - "no wake was owed for this message") + self._wake_why = (f"no new wake: a wake was already sent for your messages since seq " + f"{start}, with no answer recorded since, and further messages in the " + f"same run do not send another. Your message was recorded in the conversation; no wake was sent. This does not confirm anyone has read or accepted it" if start else + "no wake was owed for this message. Your message was recorded in the conversation; no wake was sent. This does not confirm anyone has read or accepted it") return None # already woke them about this run; saying more is not new mail pointer = f"sage://conversation/{to}#seq={run_start}-{turn['seq']}" woke = [] @@ -4593,8 +4592,8 @@ def _wake_addressee(self, to: str, meta: dict, turn: dict) -> Optional[str]: conv.record_wake(self.memory_root, to, self.member, run_start, covered_through=int(turn.get("seq") or run_start)) return ", ".join(woke) - self._wake_why = ("the wake did not go out (the mesh refused or failed); your message is " - "delivered, and the wake is tried again on your next message here") + self._wake_why = ("the wake did not go out (the mesh refused or failed); it is tried " + "again on your next message here. Your message was recorded in the conversation; no wake was sent. This does not confirm anyone has read or accepted it") return None # -- channel_egress: hestia_channel_send (hestia #1344 slice 1, SHADOW) ----- diff --git a/sage/gateway/tests/test_hestia_dispatch.py b/sage/gateway/tests/test_hestia_dispatch.py index fe6360307..6f4324925 100644 --- a/sage/gateway/tests/test_hestia_dispatch.py +++ b/sage/gateway/tests/test_hestia_dispatch.py @@ -2727,20 +2727,25 @@ def test_a_say_that_wakes_nobody_says_why(): assert first.ok and first.result.get("woke") == "seat" and "not_woken" not in first.result again = d(BeingIntent("say", {"to": "seat", "text": "What endpoints does it expose?"}), _ALLOW) why = again.result.get("not_woken", "") - assert "already woken" in why and f"seq {first.result['seq']}" in why and "delivered" in why, why + assert "already sent" in why and f"seq {first.result['seq']}" in why, why + # Nova on #539: the receipt states what is RECORDED, never who reads it or when. + assert "recorded in the conversation" in why and "does not confirm" in why, why + assert "will be read" not in why and "delivered" not in why, why # A seat answering here itself: the receipt names the pause, not silence. conv.append(home, "seat", speaker="seat", text="Answering.", via="seat") conv.pause_wakes(home, "seat", by="seat", minutes=30) paused = d(BeingIntent("say", {"to": "seat", "text": "Thanks."}), _ALLOW) - assert "answering this conversation itself" in paused.result.get("not_woken", ""), paused.result + pw = paused.result.get("not_woken", "") + assert "paused until" in pw and "does not confirm" in pw and "will see" not in pw, paused.result conv.resume_wakes(home, "seat", force="test") # A conversation with no listener says so. conv.create(home, "quiet", title="q", participants=["dp", "sprout-being"], writable_by=["dp", "sprout-being"]) q = d(BeingIntent("say", {"to": "quiet", "text": "Hello."}), _ALLOW) - assert "nobody is woken" in q.result.get("not_woken", ""), q.result + qw = q.result.get("not_woken", "") + assert "no listener" in qw and "does not confirm" in qw and "will be read" not in qw, q.result # A wake that fails to leave says it will be retried. conv.append(home, "seat", speaker="seat", text="Back.", via="seat")