Skip to content

Commit 6bfa358

Browse files
authored
Merge pull request #5554 from AronSwan/fix/5462-chat-store-contracts
fix(chat): surface silent contract failures in session store and delivery
2 parents fbe4ac3 + 05afaf9 commit 6bfa358

5 files changed

Lines changed: 628 additions & 9 deletions

File tree

‎loopx/capabilities/manager_context/roundtrip.py‎

Lines changed: 183 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@
6363
"initial_delivery_receipt_unavailable",
6464
"original_route_or_return_delivery_unavailable",
6565
"delivery_state_unreadable",
66+
"return_transport_unavailable",
67+
"manager_return_payload_conflict",
6668
}
6769

6870
class ReturnResolutionBlocked(ValueError, RuntimeError):
@@ -602,6 +604,92 @@ def _retry_state(state, now, *, error):
602604
return result
603605

604606

607+
def _resolve_delivery_sender(external_sender):
608+
"""Resolve a manager return transport to ``(sender, attempt_aware)``.
609+
610+
Two transport shapes are legitimate: an object exposing a callable
611+
``send_with_attempt`` (the attempt-aware protocol) and a bare callable
612+
transport. The predicate is shape-based on purpose — the transports
613+
share no base class, so an ``isinstance`` check would reject valid
614+
transports. ``(None, False)`` means the transport cannot deliver:
615+
callers must record ``return_transport_unavailable`` before invoking
616+
instead of letting the call raise ``TypeError`` mid-loop.
617+
"""
618+
sender = getattr(external_sender, "send_with_attempt", None)
619+
if callable(sender):
620+
return sender, True
621+
if callable(external_sender):
622+
return external_sender, False
623+
return None, False
624+
625+
626+
_MANAGER_RETURN_PAYLOAD_FIELDS = (
627+
"role",
628+
"text",
629+
"turn_id",
630+
"origin",
631+
"attachments",
632+
"goal_draft",
633+
)
634+
635+
# Canonical per-field comparison defaults for the delivery-side replay
636+
# check. The append path drops falsy optional fields together with their
637+
# column, so rows written through the store never carry literal empty
638+
# values; externally written or migrated rows can. Folding a missing key
639+
# and an explicit ``None`` onto each field's canonical empty value keeps
640+
# the comparison closed over those stored shapes: ``attachments`` is
641+
# list-valued and normalizes to ``[]``, the scalar fields to ``None``.
642+
_MANAGER_RETURN_PAYLOAD_DEFAULTS = {
643+
"role": None,
644+
"text": None,
645+
"turn_id": None,
646+
"origin": None,
647+
"attachments": [],
648+
"goal_draft": None,
649+
}
650+
651+
652+
def _normalized_return_field(field, value):
653+
"""Fold a missing column and an explicit ``None`` into one value."""
654+
if value is not None:
655+
return value
656+
return _MANAGER_RETURN_PAYLOAD_DEFAULTS[field]
657+
658+
659+
def _manager_return_payload(text, turn):
660+
"""The transcript payload this pump is about to append for ``turn``."""
661+
return {
662+
"role": "agent",
663+
"text": text,
664+
"turn_id": turn["turn_id"],
665+
"origin": "manager_followup",
666+
}
667+
668+
669+
def _replayed_return_payload_conflict(store, session_id, message_id, incoming):
670+
"""First divergent field if ``message_id`` is already recorded with
671+
different payload, else ``None``.
672+
673+
Same-id replay at the append layer is intentional replay-ignore
674+
semantics: the store returns the existing row without comparing. A
675+
silent merge is safe for generic appends but not for manager return
676+
delivery, where the same handoff id arriving with a different
677+
conclusion must surface as a delivery fault instead of leaving the
678+
old text standing behind a delivered receipt. The comparison therefore
679+
lives here, on the delivery path, before anything is sent or appended.
680+
"""
681+
for row in store.messages(session_id):
682+
if row.get("message_id") != message_id:
683+
continue
684+
for field in _MANAGER_RETURN_PAYLOAD_FIELDS:
685+
existing = _normalized_return_field(field, row.get(field))
686+
candidate = _normalized_return_field(field, incoming.get(field))
687+
if existing != candidate:
688+
return field
689+
return None
690+
return None
691+
692+
605693
def _drain_exact(root, registry, store, external_sender, *, now, cancelled):
606694
processed = 0
607695
for path in iter_result_paths(_root(root) / "replies"):
@@ -655,6 +743,36 @@ def _drain_exact(root, registry, store, external_sender, *, now, cancelled):
655743
try:
656744
if cancelled():
657745
return processed
746+
conflict_field = _replayed_return_payload_conflict(
747+
store,
748+
route["session_id"],
749+
mid,
750+
_manager_return_payload(text, turn),
751+
)
752+
if conflict_field is not None:
753+
# A payload conflict is a data fault, not a transport fault:
754+
# the transcript already holds this handoff message_id with
755+
# different content. Record the existing explicit_unverified
756+
# status with the typed error code instead of delivering or
757+
# retrying; a retry can never fix a payload mismatch and
758+
# would only multiply the divergence, while a silent merge
759+
# would leave the old text behind a delivered receipt.
760+
logging.getLogger(__name__).warning(
761+
"Manager return payload conflict on %s: %s",
762+
mid,
763+
conflict_field,
764+
)
765+
_write_exact_return_state(
766+
root,
767+
registry,
768+
context,
769+
{
770+
"status": "explicit_unverified",
771+
"error": "manager_return_payload_conflict",
772+
},
773+
)
774+
processed += 1
775+
continue
658776
store.append_message(
659777
route["session_id"],
660778
role="agent",
@@ -791,11 +909,24 @@ def record_attempt(value):
791909
preserve_admission=True,
792910
)
793911

794-
sender = getattr(external_sender, "send_with_attempt", None)
912+
sender, attempt_aware = _resolve_delivery_sender(external_sender)
913+
if sender is None:
914+
_write_exact_return_state(
915+
root,
916+
registry,
917+
context,
918+
_retry_state(
919+
state,
920+
now,
921+
error="return_transport_unavailable",
922+
),
923+
)
924+
processed += 1
925+
continue
795926
sent = (
796927
sender(route, session, turn, text, record_attempt)
797-
if callable(sender)
798-
else external_sender(route, session, turn, text)
928+
if attempt_aware
929+
else sender(route, session, turn, text)
799930
)
800931
if sent.get("reply_verified") is not True:
801932
if sent.get("external_write_performed") is True:
@@ -967,6 +1098,34 @@ def drain(root, registry, store, external_sender, *, now=None, cancelled=lambda:
9671098
mid = "handoff." + _hash([row["request_id"], path.stem])
9681099
if cancelled():
9691100
return processed
1101+
conflict_field = _replayed_return_payload_conflict(
1102+
store,
1103+
route["session_id"],
1104+
mid,
1105+
_manager_return_payload(text, turn),
1106+
)
1107+
if conflict_field is not None:
1108+
# Same data-fault rule as the exact loop: the transcript
1109+
# already holds this handoff message_id with different
1110+
# content. Record the existing explicit_unverified status
1111+
# with the typed error code instead of delivering or
1112+
# folding the conflict into the transport retry path
1113+
# below; a retry can never fix a payload mismatch and
1114+
# would only multiply the divergence.
1115+
logging.getLogger(__name__).warning(
1116+
"Manager return payload conflict on %s: %s",
1117+
mid,
1118+
conflict_field,
1119+
)
1120+
_write(
1121+
state_path,
1122+
{
1123+
"status": "explicit_unverified",
1124+
"error": "manager_return_payload_conflict",
1125+
},
1126+
)
1127+
processed += 1
1128+
continue
9701129
store.append_message(
9711130
route["session_id"],
9721131
role="agent",
@@ -1121,11 +1280,29 @@ def record_attempt(value):
11211280
},
11221281
)
11231282

1124-
sender = getattr(external_sender, "send_with_attempt", None)
1283+
sender, attempt_aware = _resolve_delivery_sender(external_sender)
1284+
if sender is None:
1285+
attempts = int(state.get("attempts", 0)) + 1
1286+
_write(
1287+
state_path,
1288+
{
1289+
"status": "retry_pending",
1290+
"attempts": attempts,
1291+
"error": "return_transport_unavailable",
1292+
"retry_at": (
1293+
now
1294+
+ timedelta(
1295+
seconds=min(300, 5 * 2 ** min(attempts, 6))
1296+
)
1297+
).isoformat(),
1298+
},
1299+
)
1300+
processed += 1
1301+
continue
11251302
sent = (
11261303
sender(route, session, turn, text, record_attempt)
1127-
if callable(sender)
1128-
else external_sender(route, session, turn, text)
1304+
if attempt_aware
1305+
else sender(route, session, turn, text)
11291306
)
11301307
if sent.get("reply_verified") is not True:
11311308
if sent.get("external_write_performed") is True:

‎loopx/chat_store.py‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,12 @@ def latest_session(
586586
agent_id: str,
587587
channel_id: str | None = None,
588588
) -> dict[str, Any] | None:
589+
"""Return the newest resumable session row on one explicit channel.
590+
591+
Omitting ``channel_id`` pins the exact ``goal.<goal_id>`` channel:
592+
a single namespace key, not a search over that goal's recorded
593+
conversations. Use ``list_sessions`` for intentional discovery.
594+
"""
589595
candidates = self.resumable_session_candidates(
590596
goal_id=goal_id,
591597
agent_id=agent_id,
@@ -600,7 +606,12 @@ def resumable_session_candidates(
600606
agent_id: str,
601607
channel_id: str | None = None,
602608
) -> list[dict[str, Any]]:
603-
"""Return matching storage facts without deciding Goal identity."""
609+
"""Return matching storage facts without deciding Goal identity.
610+
611+
Omitting ``channel_id`` pins the exact ``goal.<goal_id>`` channel:
612+
a single namespace key, not a search over that goal's recorded
613+
conversations. Use ``list_sessions`` for intentional discovery.
614+
"""
604615

605616
return [
606617
candidate
@@ -619,7 +630,14 @@ def session_candidates(
619630
agent_id: str,
620631
channel_id: str | None = None,
621632
) -> list[dict[str, Any]]:
622-
"""Return matching Session records without lifecycle filtering."""
633+
"""Return matching Session records without lifecycle filtering.
634+
635+
Omitting ``channel_id`` pins the exact ``goal.<goal_id>`` channel:
636+
a single namespace key, not a search over that goal's recorded
637+
conversations. Use ``list_sessions`` for intentional discovery.
638+
Raises ``ValueError`` when ``channel_id`` and ``goal_id`` are both
639+
omitted.
640+
"""
623641

624642
if goal_id is None and channel_id is None:
625643
raise ValueError("channel_id is required when goal_id is omitted")

‎loopx/semantics/project_registry_io_manifest_v1.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@
263263
},
264264
{
265265
"site": "loopx/capabilities/manager_context/roundtrip.py::<module>.drain::codec_read:load_project_registry#1",
266-
"line": 884,
266+
"line": 1015,
267267
"column": 22,
268268
"kind": "codec_read",
269269
"api": "load_project_registry",

0 commit comments

Comments
 (0)