|
63 | 63 | "initial_delivery_receipt_unavailable", |
64 | 64 | "original_route_or_return_delivery_unavailable", |
65 | 65 | "delivery_state_unreadable", |
| 66 | + "return_transport_unavailable", |
| 67 | + "manager_return_payload_conflict", |
66 | 68 | } |
67 | 69 |
|
68 | 70 | class ReturnResolutionBlocked(ValueError, RuntimeError): |
@@ -602,6 +604,92 @@ def _retry_state(state, now, *, error): |
602 | 604 | return result |
603 | 605 |
|
604 | 606 |
|
| 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 | + |
605 | 693 | def _drain_exact(root, registry, store, external_sender, *, now, cancelled): |
606 | 694 | processed = 0 |
607 | 695 | for path in iter_result_paths(_root(root) / "replies"): |
@@ -655,6 +743,36 @@ def _drain_exact(root, registry, store, external_sender, *, now, cancelled): |
655 | 743 | try: |
656 | 744 | if cancelled(): |
657 | 745 | 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 |
658 | 776 | store.append_message( |
659 | 777 | route["session_id"], |
660 | 778 | role="agent", |
@@ -791,11 +909,24 @@ def record_attempt(value): |
791 | 909 | preserve_admission=True, |
792 | 910 | ) |
793 | 911 |
|
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 |
795 | 926 | sent = ( |
796 | 927 | 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) |
799 | 930 | ) |
800 | 931 | if sent.get("reply_verified") is not True: |
801 | 932 | if sent.get("external_write_performed") is True: |
@@ -967,6 +1098,34 @@ def drain(root, registry, store, external_sender, *, now=None, cancelled=lambda: |
967 | 1098 | mid = "handoff." + _hash([row["request_id"], path.stem]) |
968 | 1099 | if cancelled(): |
969 | 1100 | 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 |
970 | 1129 | store.append_message( |
971 | 1130 | route["session_id"], |
972 | 1131 | role="agent", |
@@ -1121,11 +1280,29 @@ def record_attempt(value): |
1121 | 1280 | }, |
1122 | 1281 | ) |
1123 | 1282 |
|
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 |
1125 | 1302 | sent = ( |
1126 | 1303 | 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) |
1129 | 1306 | ) |
1130 | 1307 | if sent.get("reply_verified") is not True: |
1131 | 1308 | if sent.get("external_write_performed") is True: |
|
0 commit comments