From 91aa6861ec3bb95835cdf756ef402fd2ced358cf Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:02:10 +0800 Subject: [PATCH 1/5] refactor(lark): share verified card callbacks Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/extensions/lark/card_callback.py | 424 ++++++++++++++++++ .../lark/event_collector_runtime.py | 4 + .../extensions/lark/goal_channel_operation.py | 417 +---------------- 3 files changed, 443 insertions(+), 402 deletions(-) create mode 100644 loopx/extensions/lark/card_callback.py diff --git a/loopx/extensions/lark/card_callback.py b/loopx/extensions/lark/card_callback.py new file mode 100644 index 0000000000..c58f3f6818 --- /dev/null +++ b/loopx/extensions/lark/card_callback.py @@ -0,0 +1,424 @@ +"""Shared authenticated Lark card callback transport helpers. + +The operation and team-plan domains own different proposal state machines. +They share only the provider boundary that authenticates one callback and +updates the exact originating Lark message with verified readback. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from datetime import datetime, timedelta, timezone +import json + +from .goal_channel_message_delivery import ( + card_projection_matches, + message_card_matches, + normalized_card_text, +) +from .goal_channel_transport import call, json_payload, lark_args +from .presentation.kanban import CommandRunner + + +def callback_timestamp(value: object, *, subject: str) -> str: + token = str(value or "").strip() + precision = { + 13: 1_000, + 16: 1_000_000, + }.get(len(token)) + if not token.isdigit() or precision is None: + raise ValueError(f"{subject} callback timestamp is invalid") + seconds, remainder = divmod(int(token), precision) + return ( + ( + datetime.fromtimestamp(seconds, tz=timezone.utc) + + timedelta(microseconds=remainder * (1_000_000 // precision)) + ) + .isoformat() + .replace("+00:00", "Z") + ) + + +def _lark_card_v2_fallback_matches( + observed: Mapping[str, object], expected: Mapping[str, object] +) -> bool: + """Recognize Lark's message-get fallback for a Card 2.0 payload.""" + + if expected.get("schema") != "2.0": + return False + header = expected.get("header") + if not isinstance(header, Mapping): + return False + title_value = header.get("title") + subtitle_value = header.get("subtitle") + title = title_value.get("content") if isinstance(title_value, Mapping) else None + subtitle = ( + subtitle_value.get("content") if isinstance(subtitle_value, Mapping) else None + ) + expected_title = "\n".join( + item for item in (title, subtitle) if isinstance(item, str) and item + ) + return _lark_card_v2_fallback_matches_title(observed, expected_title) + + +def _lark_card_v2_fallback_matches_title( + observed: Mapping[str, object], expected_title: str +) -> bool: + if set(observed) != {"title", "elements"}: + return False + elements = observed.get("elements") + if observed.get("title") != expected_title or not isinstance(elements, list): + return False + leaves: list[Mapping[str, object]] = [] + + def collect(value: object) -> bool: + if isinstance(value, list): + return bool(value) and all(collect(item) for item in value) + if not isinstance(value, Mapping) or value.get("tag") not in {"img", "text"}: + return False + leaves.append(value) + return True + + return collect(elements) and any(item.get("tag") == "img" for item in leaves) + + +def callback_card_content_matches( + value: object, expected: Mapping[str, object] +) -> bool: + """Verify either provider JSON or the documented userDSL callback shape.""" + + observed: object = value + if isinstance(value, str): + if not value: + return False + try: + observed = json.loads(value) + except json.JSONDecodeError: + return value == normalized_card_text(expected) + return bool( + isinstance(observed, Mapping) + and ( + card_projection_matches(observed, expected) + or _lark_card_v2_fallback_matches(observed, expected) + ) + ) + + +def _find_message(value: object, message_id: str) -> Mapping[str, object] | None: + if isinstance(value, Mapping): + if str(value.get("message_id") or "") == message_id: + return value + for child in value.values(): + if found := _find_message(child, message_id): + return found + elif isinstance(value, list): + for child in value: + if found := _find_message(child, message_id): + return found + return None + + +def read_callback_card_content( + *, + runner: CommandRunner, + cli_bin: str, + profile: str, + message_id: str, + chat_id: str, + app_id: str, +) -> object: + """Retry best-effort callback hydration through exact message readback.""" + + result = call( + runner, + lark_args( + cli_bin=cli_bin, + profile=profile, + tail=[ + "api", + "GET", + f"/open-apis/im/v1/messages/{message_id}", + "--params", + json.dumps({"card_msg_content_type": "user_card_content"}), + "--as", + "bot", + ], + ), + ) + if result.get("returncode") != 0: + return None + message = _find_message(json_payload(result), message_id) + sender = message.get("sender") if isinstance(message, Mapping) else None + if ( + not isinstance(message, Mapping) + or str(message.get("chat_id") or "") != chat_id + or not isinstance(sender, Mapping) + or sender.get("sender_type") != "app" + or sender.get("id") != app_id + ): + return None + body = message.get("body") if isinstance(message, Mapping) else None + return body.get("content") if isinstance(body, Mapping) else None + + +def _first_tenant_key(value: object) -> str | None: + if isinstance(value, Mapping): + candidate = value.get("tenant_key") + if isinstance(candidate, str) and candidate: + return candidate + for child in value.values(): + if found := _first_tenant_key(child): + return found + elif isinstance(value, list): + for child in value: + if found := _first_tenant_key(child): + return found + return None + + +def _member_tenant_key(value: object, operator_id: str) -> str | None: + if isinstance(value, Mapping): + identities = { + str(value.get(key) or "") + for key in ("member_id", "open_id", "operator_id", "id") + } + tenant_key = value.get("tenant_key") + if operator_id in identities and isinstance(tenant_key, str) and tenant_key: + return tenant_key + for child in value.values(): + if found := _member_tenant_key(child, operator_id): + return found + elif isinstance(value, list): + for child in value: + if found := _member_tenant_key(child, operator_id): + return found + return None + + +def operator_membership_verified( + *, + runner: CommandRunner, + cli_bin: str, + profile: str, + chat_id: str, + operator_id: str, +) -> bool: + chat_result = call( + runner, + lark_args( + cli_bin=cli_bin, + profile=profile, + tail=[ + "im", + "chats", + "get", + "--chat-id", + chat_id, + "--as", + "bot", + "--format", + "json", + ], + ), + ) + member_result = call( + runner, + lark_args( + cli_bin=cli_bin, + profile=profile, + tail=[ + "im", + "+chat-members-list", + "--chat-id", + chat_id, + "--member-types", + "user", + "--member-id-type", + "open_id", + "--page-all", + "--as", + "bot", + "--format", + "json", + ], + ), + ) + if chat_result.get("returncode") != 0 or member_result.get("returncode") != 0: + return False + chat_tenant = _first_tenant_key(json_payload(chat_result)) + member_tenant = _member_tenant_key(json_payload(member_result), operator_id) + return bool(chat_tenant and member_tenant and chat_tenant == member_tenant) + + +def _result_card_readback_verified( + payload: Mapping[str, object], + *, + message_id: str, + chat_id: str, + app_id: str, + card: Mapping[str, object], +) -> bool: + message = _find_message(payload, message_id) + sender = message.get("sender") if isinstance(message, Mapping) else None + return bool( + payload.get("ok") is True + and isinstance(message, Mapping) + and str(message.get("chat_id") or "") == chat_id + and isinstance(sender, Mapping) + and sender.get("sender_type") == "app" + and sender.get("id") == app_id + and message_card_matches(message, card) + ) + + +def _read_result_card( + *, runner: CommandRunner, cli_bin: str, profile: str, message_id: str +) -> tuple[Mapping[str, object], bool]: + readback = call( + runner, + lark_args( + cli_bin=cli_bin, + profile=profile, + tail=[ + "im", + "+messages-mget", + "--message-ids", + message_id, + "--as", + "bot", + "--no-reactions", + "--format", + "json", + ], + ), + ) + return json_payload(readback), readback.get("returncode") == 0 + + +def update_callback_card( + *, + runner: CommandRunner, + cli_bin: str, + profile: str, + token: str, + card: Mapping[str, object], + message_id: str, + chat_id: str, + app_id: str, +) -> dict[str, bool]: + result = call( + runner, + lark_args( + cli_bin=cli_bin, + profile=profile, + tail=[ + "api", + "POST", + "/open-apis/interactive/v1/card/update", + "--as", + "bot", + "--data", + json.dumps( + {"token": token, "card": dict(card)}, + ensure_ascii=False, + separators=(",", ":"), + ), + ], + ), + ) + payload = json_payload(result) + write_performed = result.get("returncode") == 0 and payload.get("ok") is True + if not write_performed: + return {"external_write_performed": False, "readback_verified": False} + readback_payload, readback_ok = _read_result_card( + runner=runner, + cli_bin=cli_bin, + profile=profile, + message_id=message_id, + ) + return { + "external_write_performed": True, + "readback_verified": bool( + readback_ok + and _result_card_readback_verified( + readback_payload, + message_id=message_id, + chat_id=chat_id, + app_id=app_id, + card=card, + ) + ), + } + + +def patch_result_card( + *, + runner: CommandRunner, + cli_bin: str, + profile: str, + card: Mapping[str, object], + message_id: str, + chat_id: str, + app_id: str, +) -> dict[str, bool]: + result = call( + runner, + lark_args( + cli_bin=cli_bin, + profile=profile, + tail=[ + "im", + "messages", + "patch", + "--message-id", + message_id, + "--data", + json.dumps( + { + "content": json.dumps( + dict(card), ensure_ascii=False, separators=(",", ":") + ) + }, + ensure_ascii=False, + separators=(",", ":"), + ), + "--as", + "bot", + "--format", + "json", + ], + ), + ) + payload = json_payload(result) + write_performed = result.get("returncode") == 0 and payload.get("ok") is True + if not write_performed: + return {"external_write_performed": False, "readback_verified": False} + readback_payload, readback_ok = _read_result_card( + runner=runner, + cli_bin=cli_bin, + profile=profile, + message_id=message_id, + ) + return { + "external_write_performed": True, + "readback_verified": bool( + readback_ok + and _result_card_readback_verified( + readback_payload, + message_id=message_id, + chat_id=chat_id, + app_id=app_id, + card=card, + ) + ), + } + + +__all__ = [ + "callback_card_content_matches", + "callback_timestamp", + "operator_membership_verified", + "patch_result_card", + "read_callback_card_content", + "update_callback_card", +] diff --git a/loopx/extensions/lark/event_collector_runtime.py b/loopx/extensions/lark/event_collector_runtime.py index 826e5e2cd2..41fc01b658 100644 --- a/loopx/extensions/lark/event_collector_runtime.py +++ b/loopx/extensions/lark/event_collector_runtime.py @@ -75,11 +75,15 @@ _CALLBACK_FAILURE_STAGES = { "_callback_action": "parse_action", "_callback_timestamp": "validate_timestamp", + "callback_timestamp": "validate_timestamp", "_callback_card_content_matches": "verify_card_content", + "callback_card_content_matches": "verify_card_content", "_operator_membership_verified": "verify_operator_membership", + "operator_membership_verified": "verify_operator_membership", "decide_operation": "claim_operation", "_execute_claimed_operation": "execute_operation", "_update_callback_card": "deliver_result", + "update_callback_card": "deliver_result", } CommandRunner = Callable[..., subprocess.CompletedProcess[str]] Sleeper = Callable[[float], None] diff --git a/loopx/extensions/lark/goal_channel_operation.py b/loopx/extensions/lark/goal_channel_operation.py index 17532d8de2..505132b794 100644 --- a/loopx/extensions/lark/goal_channel_operation.py +++ b/loopx/extensions/lark/goal_channel_operation.py @@ -1,7 +1,7 @@ from __future__ import annotations from copy import deepcopy -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone import hashlib import html import json @@ -18,6 +18,14 @@ execute_extension_runtime_binding, resolve_extension_binding, ) +from .card_callback import ( + callback_card_content_matches, + callback_timestamp, + operator_membership_verified, + patch_result_card, + read_callback_card_content, + update_callback_card, +) from .goal_channel_contracts import operation_packet from .goal_channel_delivery_contract import ( goal_channel_binding_digest, @@ -26,12 +34,8 @@ from .goal_channel_message_delivery import ( GoalChannelDeliveryStageError, GoalChannelMessageDeliverySession, - card_projection_matches, - message_card_matches, - normalized_card_text, resolve_bound_goal_channel, ) -from .goal_channel_transport import call, json_payload, lark_args from .presentation.kanban import CommandRunner, default_subprocess_runner @@ -586,135 +590,11 @@ def _callback_action(event: Mapping[str, Any]) -> dict[str, str]: def _callback_timestamp(value: object) -> str: - token = str(value or "").strip() - precision = { - 13: 1_000, - 16: 1_000_000, - }.get(len(token)) - if not token.isdigit() or precision is None: - raise ValueError("operation callback timestamp is invalid") - seconds, remainder = divmod(int(token), precision) - return ( - ( - datetime.fromtimestamp(seconds, tz=timezone.utc) - + timedelta(microseconds=remainder * (1_000_000 // precision)) - ) - .isoformat() - .replace("+00:00", "Z") - ) - - -def _lark_card_v2_fallback_matches( - observed: Mapping[str, Any], expected: Mapping[str, Any] -) -> bool: - """Recognize Lark's message-get fallback for a Card 2.0 payload. - - The provider exposes Card 2.0 through message-get as a title plus an - upgrade-client placeholder. ``card.action.trigger`` consumers hydrate - ``card_content`` from that endpoint, so its digest cannot equal the - submitted Card 2.0 JSON. The exact message, route, app, action digest, and - recorded submitted-card digest are checked independently by the caller. - """ - - if expected.get("schema") != "2.0": - return False - header = expected.get("header") - if not isinstance(header, Mapping): - return False - title_value = header.get("title") - subtitle_value = header.get("subtitle") - title = title_value.get("content") if isinstance(title_value, Mapping) else None - subtitle = ( - subtitle_value.get("content") if isinstance(subtitle_value, Mapping) else None - ) - expected_title = "\n".join( - item for item in (title, subtitle) if isinstance(item, str) and item - ) - return _lark_card_v2_fallback_matches_title(observed, expected_title) - - -def _lark_card_v2_fallback_matches_title( - observed: Mapping[str, Any], expected_title: str -) -> bool: - if set(observed) != {"title", "elements"}: - return False - elements = observed.get("elements") - if observed.get("title") != expected_title or not isinstance(elements, list): - return False - leaves: list[Mapping[str, Any]] = [] - - def collect(value: object) -> bool: - if isinstance(value, list): - return bool(value) and all(collect(item) for item in value) - if not isinstance(value, Mapping) or value.get("tag") not in {"img", "text"}: - return False - leaves.append(value) - return True - - return collect(elements) and any(item.get("tag") == "img" for item in leaves) - - -def _callback_card_content_matches(value: object, expected: Mapping[str, Any]) -> bool: - """Verify either provider JSON or the documented userDSL callback shape.""" - - observed: object = value - if isinstance(value, str): - if not value: - return False - try: - observed = json.loads(value) - except json.JSONDecodeError: - return value == normalized_card_text(expected) - return bool( - isinstance(observed, Mapping) - and ( - card_projection_matches(observed, expected) - or _lark_card_v2_fallback_matches(observed, expected) - ) - ) - + return callback_timestamp(value, subject="operation") -def _read_callback_card_content( - *, - runner: CommandRunner, - cli_bin: str, - profile: str, - message_id: str, - chat_id: str, - app_id: str, -) -> object: - """Retry the CLI's best-effort callback hydration through exact readback.""" - result = call( - runner, - lark_args( - cli_bin=cli_bin, - profile=profile, - tail=[ - "api", - "GET", - f"/open-apis/im/v1/messages/{message_id}", - "--params", - json.dumps({"card_msg_content_type": "user_card_content"}), - "--as", - "bot", - ], - ), - ) - if result.get("returncode") != 0: - return None - message = _find_message(json_payload(result), message_id) - sender = message.get("sender") if isinstance(message, Mapping) else None - if ( - not isinstance(message, Mapping) - or str(message.get("chat_id") or "") != chat_id - or not isinstance(sender, Mapping) - or sender.get("sender_type") != "app" - or sender.get("id") != app_id - ): - return None - body = message.get("body") if isinstance(message, Mapping) else None - return body.get("content") if isinstance(body, Mapping) else None +_callback_card_content_matches = callback_card_content_matches +_read_callback_card_content = read_callback_card_content def _callback_replays_confirmation( @@ -739,95 +619,7 @@ def _callback_replays_confirmation( return all(confirmation.get(key) == value for key, value in expected.items()) -def _operator_membership_verified( - *, - runner: CommandRunner, - cli_bin: str, - profile: str, - chat_id: str, - operator_id: str, -) -> bool: - chat_result = call( - runner, - lark_args( - cli_bin=cli_bin, - profile=profile, - tail=[ - "im", - "chats", - "get", - "--chat-id", - chat_id, - "--as", - "bot", - "--format", - "json", - ], - ), - ) - member_result = call( - runner, - lark_args( - cli_bin=cli_bin, - profile=profile, - tail=[ - "im", - "+chat-members-list", - "--chat-id", - chat_id, - "--member-types", - "user", - "--member-id-type", - "open_id", - "--page-all", - "--as", - "bot", - "--format", - "json", - ], - ), - ) - if chat_result.get("returncode") != 0 or member_result.get("returncode") != 0: - return False - chat_payload = json_payload(chat_result) - member_payload = json_payload(member_result) - chat_tenant = _first_tenant_key(chat_payload) - member_tenant = _member_tenant_key(member_payload, operator_id) - return bool(chat_tenant and member_tenant and chat_tenant == member_tenant) - - -def _first_tenant_key(value: object) -> str | None: - if isinstance(value, Mapping): - candidate = value.get("tenant_key") - if isinstance(candidate, str) and candidate: - return candidate - for child in value.values(): - if found := _first_tenant_key(child): - return found - elif isinstance(value, list): - for child in value: - if found := _first_tenant_key(child): - return found - return None - - -def _member_tenant_key(value: object, operator_id: str) -> str | None: - if isinstance(value, Mapping): - identities = { - str(value.get(key) or "") - for key in ("member_id", "open_id", "operator_id", "id") - } - tenant_key = value.get("tenant_key") - if operator_id in identities and isinstance(tenant_key, str) and tenant_key: - return tenant_key - for child in value.values(): - if found := _member_tenant_key(child, operator_id): - return found - elif isinstance(value, list): - for child in value: - if found := _member_tenant_key(child, operator_id): - return found - return None +_operator_membership_verified = operator_membership_verified def _resolve_operation_executor_binding( @@ -961,187 +753,8 @@ def _execute_claimed_operation( return result -def _find_message(value: object, message_id: str) -> Mapping[str, Any] | None: - if isinstance(value, Mapping): - if str(value.get("message_id") or "") == message_id: - return value - for child in value.values(): - if found := _find_message(child, message_id): - return found - elif isinstance(value, list): - for child in value: - if found := _find_message(child, message_id): - return found - return None - - -def _result_card_readback_verified( - payload: Mapping[str, Any], - *, - message_id: str, - chat_id: str, - app_id: str, - card: Mapping[str, Any], -) -> bool: - message = _find_message(payload, message_id) - sender = message.get("sender") if isinstance(message, Mapping) else None - return bool( - payload.get("ok") is True - and isinstance(message, Mapping) - and str(message.get("chat_id") or "") == chat_id - and isinstance(sender, Mapping) - and sender.get("sender_type") == "app" - and sender.get("id") == app_id - and message_card_matches(message, card) - ) - - -def _read_result_card( - *, - runner: CommandRunner, - cli_bin: str, - profile: str, - message_id: str, -) -> tuple[Mapping[str, Any], bool]: - readback = call( - runner, - lark_args( - cli_bin=cli_bin, - profile=profile, - tail=[ - "im", - "+messages-mget", - "--message-ids", - message_id, - "--as", - "bot", - "--no-reactions", - "--format", - "json", - ], - ), - ) - return json_payload(readback), readback.get("returncode") == 0 - - -def _update_callback_card( - *, - runner: CommandRunner, - cli_bin: str, - profile: str, - token: str, - card: Mapping[str, Any], - message_id: str, - chat_id: str, - app_id: str, -) -> dict[str, bool]: - result = call( - runner, - lark_args( - cli_bin=cli_bin, - profile=profile, - tail=[ - "api", - "POST", - "/open-apis/interactive/v1/card/update", - "--as", - "bot", - "--data", - json.dumps( - {"token": token, "card": dict(card)}, - ensure_ascii=False, - separators=(",", ":"), - ), - ], - ), - ) - payload = json_payload(result) - write_performed = result.get("returncode") == 0 and payload.get("ok") is True - if not write_performed: - return {"external_write_performed": False, "readback_verified": False} - readback_payload, readback_ok = _read_result_card( - runner=runner, - cli_bin=cli_bin, - profile=profile, - message_id=message_id, - ) - verified = bool( - readback_ok - and _result_card_readback_verified( - readback_payload, - message_id=message_id, - chat_id=chat_id, - app_id=app_id, - card=card, - ) - ) - return { - "external_write_performed": True, - "readback_verified": verified, - } - - -def _patch_operation_result_card( - *, - runner: CommandRunner, - cli_bin: str, - profile: str, - card: Mapping[str, Any], - message_id: str, - chat_id: str, - app_id: str, -) -> dict[str, bool]: - result = call( - runner, - lark_args( - cli_bin=cli_bin, - profile=profile, - tail=[ - "im", - "messages", - "patch", - "--message-id", - message_id, - "--data", - json.dumps( - { - "content": json.dumps( - dict(card), - ensure_ascii=False, - separators=(",", ":"), - ) - }, - ensure_ascii=False, - separators=(",", ":"), - ), - "--as", - "bot", - "--format", - "json", - ], - ), - ) - payload = json_payload(result) - write_performed = result.get("returncode") == 0 and payload.get("ok") is True - if not write_performed: - return {"external_write_performed": False, "readback_verified": False} - readback_payload, readback_ok = _read_result_card( - runner=runner, - cli_bin=cli_bin, - profile=profile, - message_id=message_id, - ) - verified = readback_ok and _result_card_readback_verified( - readback_payload, - message_id=message_id, - chat_id=chat_id, - app_id=app_id, - card=card, - ) - return { - "external_write_performed": True, - "readback_verified": verified, - } +_update_callback_card = update_callback_card +_patch_operation_result_card = patch_result_card def recover_goal_channel_operation_results( From c7d29ce0721100238da8edfff53f2d4fa39f1e13 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:02:41 +0800 Subject: [PATCH 2/5] feat(lark): confirm team plans across audiences Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../capabilities/manager_context/team_plan.py | 20 +- loopx/chat_action_store.py | 341 ++++++++++ loopx/chat_server.py | 1 + .../presentation/action_review_plan.ts | 81 ++- loopx/extensions/lark/goal_topic_runtime.py | 311 ++++++++- .../extensions/lark/manager_reply_delivery.py | 63 ++ .../extensions/lark/presentation/team_plan.py | 277 ++++++++ .../extensions/lark/team_plan_confirmation.py | 593 ++++++++++++++++++ .../action_review_plan.test.ts | 24 +- .../test_lark_goal_topic_runtime.py | 353 +++++++++++ .../test_lark_manager_reply_delivery.py | 40 ++ .../test_lark_team_plan_confirmation.py | 448 +++++++++++++ tests/test_chat_team_plan_action.py | 164 ++++- tests/test_steward_team_plan_preview.py | 13 +- 14 files changed, 2687 insertions(+), 42 deletions(-) create mode 100644 loopx/extensions/lark/presentation/team_plan.py create mode 100644 loopx/extensions/lark/team_plan_confirmation.py create mode 100644 tests/extensions/test_lark_team_plan_confirmation.py diff --git a/loopx/capabilities/manager_context/team_plan.py b/loopx/capabilities/manager_context/team_plan.py index e2f9005514..b6dec91d88 100644 --- a/loopx/capabilities/manager_context/team_plan.py +++ b/loopx/capabilities/manager_context/team_plan.py @@ -115,12 +115,15 @@ def project_team_plan_preview( ) -> None: """Offer each admitted team preview in this answer as a confirmable card. - Only the owner's own local manager channel is projected. A remote audience's - confirmation surface is not this store, so its answer keeps the preview in - text and no card is written on its behalf. + Every admitted manager preview is projected into the one typed action store. + Remote delivery still requires its own authenticated surface, but it must + refer to this same proposal instead of constructing a second action from the + model response. """ - if projector is None or str(session.get("channel_id") or "") != "manager": + if projector is None or not is_manager_channel( + str(session.get("channel_id") or "") + ): return for preview in team_plan_previews(response): try: @@ -175,7 +178,7 @@ def confirmation_pointer(goals: Sequence[str]) -> str: named = "、".join(goals) return ( - f"已为 {named} 准备好可确认的团队计划卡片:在 LoopX 工作区的该 Goal 下确认后," + f"已为 {named} 准备好同一份团队计划卡片:可在当前管家会话或该 Goal 的已绑定频道确认;" "才会为每条就绪 lane 创建它的首个有界 Todo;确认前不会创建任何 lane。" ) @@ -193,10 +196,9 @@ def offer_team_plan_confirmation( Admission decides whether a preview may be *shown*; this is what turns it into something the owner can act on, and it does exactly two things for a manager - channel: it appends one typed pointer line naming the Goal whose workspace - holds the card, and -- for the owner's own local channel only -- it stores - that card. A remote audience's confirmation surface is not this store, so it - receives the pointer and no card is written on its behalf. + channel: it appends one typed pointer line naming the Goal and stores one + provider-neutral proposal. Local and remote surfaces may then render that + exact proposal; neither surface gains authority to create a second action. The steward's prose is preserved: the added line is an operational receipt from the channel, in the same way the delegation path states its own receipt, diff --git a/loopx/chat_action_store.py b/loopx/chat_action_store.py index 74eb58f5f5..93d98901ec 100644 --- a/loopx/chat_action_store.py +++ b/loopx/chat_action_store.py @@ -260,6 +260,7 @@ def create_preview( "status": "preview_ready", "receipt": None, "operation": None, + "review_card": None, "gate": None, "failure": None, "checkpoint": None, @@ -280,6 +281,346 @@ def create_preview( self._write(payload) return proposal + def record_review_card_delivery( + self, + proposal_id: str, + *, + audience_id: str, + delivery: Mapping[str, Any], + ) -> dict[str, Any]: + """Bind one verified external review surface to a typed proposal. + + A proposal may be rendered into more than one audience, but every card + retains the proposal id and state fingerprint owned by the typed action + store. Recording all audiences on that one proposal is what prevents a + manager card and a Goal card from becoming two independent approvals. + """ + + audience = _opaque_id(audience_id, field="review_card.audience_id") + safe_delivery = _safe_json_value( + dict(delivery), path=f"review_card.deliveries.{audience}" + ) + if not isinstance(safe_delivery, dict): + raise ValueError("review card delivery must be an object") + required = { + "provider", + "message_id", + "chat_id", + "app_id", + "cli_bin", + "sender_profile", + "binding_digest", + "card_digest", + "submitted_card", + "delivered_at", + "authorized_principal", + } + if set(safe_delivery) != required: + raise ValueError( + "review card delivery has unsupported or missing fields" + ) + for field in required - {"submitted_card"}: + _bounded_text( + safe_delivery.get(field), + field=f"review_card.delivery.{field}", + limit=512, + ) + submitted_card = safe_delivery.get("submitted_card") + if not isinstance(submitted_card, dict): + raise ValueError("review card submitted payload must be an object") + if _canonical_digest(submitted_card) != safe_delivery["card_digest"]: + raise ActionConflictError("review card submitted payload drifted") + token = _opaque_id(proposal_id, field="proposal_id") + with exclusive_file_lock( + self.path, + agent_id="loopx-chat", + operation="record_review_card_delivery", + ): + payload = self._read() + proposal = payload["proposals"].get(token) + if not isinstance(proposal, dict): + raise KeyError("typed Chat action proposal was not found") + if proposal.get("action_kind") != "team.plan": + raise ActionConflictError( + "only a team plan can bind this review card" + ) + review_card = proposal.get("review_card") + if review_card is None: + raise ActionConflictError( + "review card audiences must be prepared before delivery" + ) + if not isinstance(review_card, dict): + raise ValueError("typed review card state is malformed") + if review_card.get("state_fingerprint") != proposal.get( + "expected_state_fingerprint" + ): + raise ActionConflictError("review card state fingerprint drifted") + if review_card.get("authorized_principal") != safe_delivery.get( + "authorized_principal" + ): + raise ActionConflictError( + "review card audience changed the authorized principal" + ) + deliveries = review_card.get("deliveries") + if not isinstance(deliveries, dict): + raise ValueError("typed review card deliveries are malformed") + expected_audiences = review_card.get("expected_audience_ids") + if ( + not isinstance(expected_audiences, list) + or audience not in expected_audiences + ): + raise ActionConflictError("review card audience was not prepared") + existing = deliveries.get(audience) + if existing is not None: + immutable_fields = required - {"delivered_at"} + if not isinstance(existing, Mapping) or any( + existing.get(field) != safe_delivery.get(field) + for field in immutable_fields + ): + raise ActionConflictError( + "review card audience is already bound to another message" + ) + return proposal + if review_card.get("confirmation") is not None: + raise ActionConflictError("review card decision is already consumed") + deliveries[audience] = safe_delivery + proposal["updated_at"] = _utc_now() + self._write(payload) + return proposal + + def prepare_review_card_delivery( + self, + proposal_id: str, + *, + audience_ids: Sequence[str], + authorized_principal: str, + ) -> dict[str, Any]: + """Freeze every audience before the first actionable card is sent.""" + + normalized_audiences = sorted( + {_opaque_id(value, field="review_card.audience_id") for value in audience_ids} + ) + if not normalized_audiences or len(normalized_audiences) != len(audience_ids): + raise ValueError("review card audiences must be non-empty and unique") + principal = _opaque_id( + authorized_principal, field="review_card.authorized_principal" + ) + token = _opaque_id(proposal_id, field="proposal_id") + with exclusive_file_lock( + self.path, + agent_id="loopx-chat", + operation="prepare_review_card_delivery", + ): + payload = self._read() + proposal = payload["proposals"].get(token) + if not isinstance(proposal, dict): + raise KeyError("typed Chat action proposal was not found") + if proposal.get("action_kind") != "team.plan": + raise ActionConflictError( + "only a team plan can bind this review card" + ) + expected = { + "schema_version": "loopx_review_card_delivery_v0", + "state_fingerprint": proposal.get("expected_state_fingerprint"), + "expected_audience_ids": normalized_audiences, + "deliveries": {}, + "confirmation": None, + "authorized_principal": principal, + } + existing = proposal.get("review_card") + if existing is None: + proposal["review_card"] = expected + proposal["updated_at"] = _utc_now() + self._write(payload) + return proposal + if not isinstance(existing, dict): + raise ValueError("typed review card state is malformed") + immutable = { + "schema_version": expected["schema_version"], + "state_fingerprint": expected["state_fingerprint"], + "expected_audience_ids": expected["expected_audience_ids"], + "authorized_principal": expected["authorized_principal"], + } + if any(existing.get(key) != value for key, value in immutable.items()): + raise ActionConflictError("review card audience plan drifted") + return proposal + + def decide_review_card( + self, + proposal_id: str, + *, + decision: str, + confirmation: Mapping[str, Any], + ) -> dict[str, Any]: + """Consume one verified card decision across every delivered audience.""" + + selected_decision = str(decision or "").strip().lower() + if selected_decision not in {"confirm", "reject"}: + raise ValueError("review card decision must be confirm or reject") + safe_confirmation = _safe_json_value( + dict(confirmation), path="review_card.confirmation" + ) + if not isinstance(safe_confirmation, dict): + raise ValueError("review card confirmation must be an object") + required = { + "provider", + "event_id", + "principal", + "message_id", + "chat_id", + "app_id", + "audience_id", + "state_fingerprint", + "card_digest", + "confirmed_at", + } + if set(safe_confirmation) != required: + raise ValueError( + "review card confirmation has unsupported or missing fields" + ) + for field in required: + _bounded_text( + safe_confirmation.get(field), + field=f"review_card.confirmation.{field}", + limit=512, + ) + token = _opaque_id(proposal_id, field="proposal_id") + audience = _opaque_id( + safe_confirmation["audience_id"], field="review_card.audience_id" + ) + with exclusive_file_lock( + self.path, + agent_id="loopx-chat", + operation="decide_review_card", + ): + payload = self._read() + proposal = payload["proposals"].get(token) + review_card = ( + proposal.get("review_card") if isinstance(proposal, dict) else None + ) + if not isinstance(review_card, dict): + raise KeyError("typed review card was not found") + deliveries = review_card.get("deliveries") + delivery = ( + deliveries.get(audience) if isinstance(deliveries, dict) else None + ) + if not isinstance(delivery, dict): + raise ActionConflictError("review card audience was not delivered") + expected_audiences = review_card.get("expected_audience_ids") + if ( + not isinstance(expected_audiences, list) + or set(deliveries) != set(expected_audiences) + ): + raise ActionConflictError( + "review card audiences are not completely delivered" + ) + expected = { + "provider": delivery.get("provider"), + "message_id": delivery.get("message_id"), + "chat_id": delivery.get("chat_id"), + "app_id": delivery.get("app_id"), + "state_fingerprint": review_card.get("state_fingerprint"), + "card_digest": delivery.get("card_digest"), + } + if any( + safe_confirmation.get(field) != value + for field, value in expected.items() + ): + raise ActionConflictError( + "review card callback does not match the delivered request" + ) + if safe_confirmation.get("principal") != review_card.get( + "authorized_principal" + ): + raise ActionConflictError( + "principal is not authorized for this review card" + ) + existing = review_card.get("confirmation") + if isinstance(existing, dict): + # Every audience is a view of the same proposal. Once one exact + # decision wins, later clicks only observe that decision and can + # never launch a second canonical effect. + return proposal + status = str(proposal.get("status") or "") + if status not in {"preview_ready", "deferred"}: + raise ActionConflictError( + "review card proposal is no longer awaiting confirmation" + ) + now = _utc_now() + review_card["confirmation"] = { + **safe_confirmation, + "decision": selected_decision, + } + if selected_decision == "confirm": + proposal["status"] = "applying" + proposal["gate"] = None + proposal["failure"] = None + else: + proposal["status"] = "rejected" + proposal["rejected_at"] = now + proposal["updated_at"] = now + self._write(payload) + return proposal + + def record_review_card_result_delivery( + self, + proposal_id: str, + *, + audience_id: str, + result: Mapping[str, Any], + ) -> dict[str, Any]: + """Record exact result-card readback for one already-bound audience.""" + + audience = _opaque_id(audience_id, field="review_card.audience_id") + safe_result = _safe_json_value( + dict(result), path=f"review_card.deliveries.{audience}.result" + ) + if not isinstance(safe_result, dict): + raise ValueError("review card result delivery must be an object") + required = {"card_digest", "transport", "delivered_at"} + if set(safe_result) != required: + raise ValueError( + "review card result delivery has unsupported or missing fields" + ) + for field in required: + _bounded_text( + safe_result.get(field), + field=f"review_card.result.{field}", + limit=512, + ) + token = _opaque_id(proposal_id, field="proposal_id") + with exclusive_file_lock( + self.path, + agent_id="loopx-chat", + operation="record_review_card_result_delivery", + ): + payload = self._read() + proposal = payload["proposals"].get(token) + review_card = ( + proposal.get("review_card") if isinstance(proposal, dict) else None + ) + deliveries = ( + review_card.get("deliveries") + if isinstance(review_card, dict) + else None + ) + delivery = ( + deliveries.get(audience) if isinstance(deliveries, dict) else None + ) + if not isinstance(delivery, dict): + raise KeyError("typed review card audience was not found") + existing = delivery.get("result") + if existing is not None: + if existing != safe_result: + raise ActionConflictError( + "review card result delivery is already immutable" + ) + return proposal + delivery["result"] = safe_result + proposal["updated_at"] = _utc_now() + self._write(payload) + return proposal + def arm_operation(self, proposal_id: str) -> dict[str, Any]: """Turn one provider-neutral preview into the canonical confirmation gate. diff --git a/loopx/chat_server.py b/loopx/chat_server.py index c0f3ebc762..0457244f63 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -1490,6 +1490,7 @@ def serve_chat( ), runtime_root=runtime_root, runtime_controller=server.runtime_controller, + action_service=server.action_service, manager_route_reconciler=lambda route: reconcile_lark_manager_route( route=route, registry_path=server.registry_path, diff --git a/loopx/control_plane/presentation/action_review_plan.ts b/loopx/control_plane/presentation/action_review_plan.ts index 36d4230bde..e67e8a609a 100644 --- a/loopx/control_plane/presentation/action_review_plan.ts +++ b/loopx/control_plane/presentation/action_review_plan.ts @@ -79,24 +79,41 @@ export type ActionReviewPlan = ActionReviewIdentity & ActionReviewState & { * stay keys, not sentences, because this boundary is language-neutral; the * surface owns the words and renders the data below. */ -export type ReviewCardFrame = { +type ReviewCardFrameBase = { schemaVersion: "review_card_frame_v0"; actionKind: string; proposalId: string; stateFingerprint: string; - kind: "confirmation"; - attentionKind: "authority"; - interactionMode: "confirm_reject"; - decisions: readonly ["confirm", "reject"]; titleKey: string; subtitleKey: string; - confirmLabelKey: string; - rejectLabelKey: string; warningKey: string; focus: string; fields: Array<{ key: string; value: string }>; }; +export type ReviewCardFrame = ReviewCardFrameBase & ( + | { + kind: "confirmation"; + attentionKind: "authority"; + interactionMode: "confirm_reject"; + decisions: readonly ["confirm", "reject"]; + confirmLabelKey: string; + rejectLabelKey: string; + } + | { + kind: "pending"; + attentionKind: "progress"; + interactionMode: "inform"; + } + | { + kind: "result"; + attentionKind: "progress"; + interactionMode: "inform"; + resultKind: "applied" | "rejected" | "stale" | "failed" | "inactive"; + resultSummary: string; + } +); + type JsonRecord = Record; const lifecycleReviewReasons = { @@ -160,7 +177,6 @@ function envelopeFieldValue(value: unknown): string { export function compileReviewCardFrame(proposalValue: unknown): ReviewCardFrame | undefined { const proposal = objectValue(proposalValue); if (proposal?.action_kind !== "team.plan") return undefined; - if (proposal.status !== "preview_ready" && proposal.status !== "deferred") return undefined; const parameters = objectValue(proposal.normalized_parameters); const plan = objectValue(parameters?.plan); if (!plan || plan.kind !== "steward_team_plan_preview" || plan.applies !== false) return undefined; @@ -189,23 +205,58 @@ export function compileReviewCardFrame(proposalValue: unknown): ReviewCardFrame { key: "quota_envelope", value: envelopeFieldValue(plan.quota_envelope) }, { key: "stop_condition", value: compactValue(plan.stop_condition) }, ].filter((field) => field.value.length > 0); - return { + const base: ReviewCardFrameBase = { schemaVersion: "review_card_frame_v0", actionKind: "team.plan", proposalId, stateFingerprint, - kind: "confirmation", - attentionKind: "authority", - interactionMode: "confirm_reject", - decisions: ["confirm", "reject"], titleKey: "team_plan_preview", subtitleKey: "preview_only_no_lane_exists", - confirmLabelKey: "confirm_team_plan", - rejectLabelKey: "reject_team_plan", warningKey: "confirming_creates_each_ready_lane_first_todo", focus: `${goalId} · ${lanes.length} lane${lanes.length === 1 ? "" : "s"}`, fields, }; + if (proposal.status === "preview_ready" || proposal.status === "deferred") { + return { + ...base, + kind: "confirmation", + attentionKind: "authority", + interactionMode: "confirm_reject", + decisions: ["confirm", "reject"], + confirmLabelKey: "confirm_team_plan", + rejectLabelKey: "reject_team_plan", + }; + } + if (proposal.status === "applying") { + return { + ...base, + kind: "pending", + attentionKind: "progress", + interactionMode: "inform", + }; + } + const receipt = objectValue(proposal.receipt); + const failure = objectValue(proposal.failure); + const resultKind = proposal.status === "applied" + ? "applied" + : proposal.status === "rejected" + ? "rejected" + : proposal.status === "stale" + ? "stale" + : proposal.status === "failed" + ? "failed" + : "inactive"; + return { + ...base, + kind: "result", + attentionKind: "progress", + interactionMode: "inform", + resultKind, + resultSummary: compactValue( + receipt?.outcome ?? failure?.error_code ?? proposal.status, + 160, + ), + }; } function operationContent(parameters: JsonRecord): OperationReviewContent | null { diff --git a/loopx/extensions/lark/goal_topic_runtime.py b/loopx/extensions/lark/goal_topic_runtime.py index 9c46002d43..33a99e1d44 100644 --- a/loopx/extensions/lark/goal_topic_runtime.py +++ b/loopx/extensions/lark/goal_topic_runtime.py @@ -43,6 +43,7 @@ load_delivery as _load_manager_delivery, pending_delivery as _pending_manager_delivery, text_digest as _manager_delivery_text_digest, + validate_team_plan_delivery_receipt, write_delivery as _write_manager_delivery, ) from .manager_context import ( @@ -77,6 +78,10 @@ ProcessFactory = Callable[[list[str]], Any] HealthSink = Callable[[Mapping[str, Any]], None] ManagerRouteReconciler = Callable[[Mapping[str, Any]], Mapping[str, Any]] +ProposalDeliverer = Callable[ + [Mapping[str, Any], list[str]], Mapping[str, Any] +] +ReviewCallbackHandler = Callable[[Mapping[str, Any]], Mapping[str, Any]] class LarkGoalTopicTurnFailed(RuntimeError): @@ -107,6 +112,7 @@ def __init__(self, error_code: str, effect_receipt: Mapping[str, Any]) -> None: _EVENT_READY_PREFIX = "[event] ready " _EVENT_DIAGNOSTIC_PREFIX = "[event] " _EVENT_EXIT_REASON = re.compile(r"\(reason: (limit|timeout|signal)\)$") +_TEAM_PLAN_CALLBACK_SCHEMA = "loopx_team_plan_card_action_v0" def _active_profile_configs(snapshot: Mapping[str, Any]) -> dict[str, dict[str, str]]: @@ -134,11 +140,51 @@ def _active_profile_configs(snapshot: Mapping[str, Any]) -> dict[str, dict[str, continue profiles.setdefault( profile, - {"cli_bin": str(identity.get("cli_bin") or "lark-cli")}, + { + "cli_bin": str(identity.get("cli_bin") or "lark-cli"), + "bot_app_id": str(identity.get("bot_app_id") or ""), + }, ) return profiles +def _active_profile_chat_ids( + snapshot: Mapping[str, Any], profile: str +) -> list[str]: + binding_payloads = snapshot.get("binding_payloads") + binding_payloads = ( + binding_payloads if isinstance(binding_payloads, Mapping) else {} + ) + active_target_refs = { + str(binding.get("target_ref") or "") + for goal_id, payload in binding_payloads.items() + if isinstance(payload, Mapping) + for binding in bindings_for_goal(payload, str(goal_id)) + if binding.get("enabled") is True + } + targets = snapshot.get("target_payload") + targets = targets.get("targets") if isinstance(targets, Mapping) else None + if not isinstance(targets, Mapping): + return [] + chats: set[str] = set() + for target_ref, target in targets.items(): + if not isinstance(target, Mapping) or target.get("enabled") is not True: + continue + if str(target_ref) not in active_target_refs: + continue + identity = target.get("identity") + channel = target.get("channel") + if not isinstance(identity, Mapping) or not isinstance(channel, Mapping): + continue + chat_id = str(channel.get("chat_id") or "") + if ( + str(identity.get("sender_profile") or "") == profile + and re.fullmatch(r"oc_[A-Za-z0-9_-]+", chat_id) + ): + chats.add(chat_id) + return sorted(chats) + + def _default_simple_runner(args: list[str]) -> Mapping[str, Any]: try: completed = subprocess.run( @@ -260,6 +306,7 @@ def poll_lark_goal_topic_profile_once( consume_runner: SimpleRunner = _default_simple_runner, provider_runner: Any = subprocess.run, reply_runner: CommandRunner = _default_simple_runner, + proposal_deliverer: ProposalDeliverer | None = None, ) -> dict[str, Any]: """Consume one bounded event batch for an App and reuse Inbox reply/ACK.""" @@ -350,6 +397,7 @@ def poll_lark_goal_topic_profile_once( answer=answer, reply_runner=reply_runner, provider_runner=provider_runner, + proposal_deliverer=proposal_deliverer, ) except Exception: event_statuses.append("processing_failed") @@ -379,6 +427,8 @@ def stream_lark_goal_topic_profile( provider_runner: Any = subprocess.run, reply_runner: CommandRunner = _default_simple_runner, health_sink: HealthSink | None = None, + proposal_deliverer: ProposalDeliverer | None = None, + review_callback_handler: ReviewCallbackHandler | None = None, ) -> dict[str, Any]: """Keep one bounded long-lived CLI consumer attached between messages.""" @@ -410,6 +460,79 @@ def stream_lark_goal_topic_profile( _EVENT_PROJECTION, ] ) + callback_process = None + callback_thread: threading.Thread | None = None + callback_stop = threading.Event() + callback_disconnected = threading.Event() + if review_callback_handler is not None: + chat_ids = _active_profile_chat_ids(snapshot, profile) + if chat_ids: + chat_filter = " or ".join( + f".chat_id == {json.dumps(chat_id)}" for chat_id in chat_ids + ) + callback_process = process_factory( + [ + cli_bin, + "--profile", + profile, + "event", + "consume", + "card.action.trigger", + "--as", + "bot", + "--timeout", + "30m", + "--max-events", + "0", + "--jq", + f"select({chat_filter})", + ] + ) + + def consume_review_callbacks() -> None: + stdout = callback_process.stdout + if stdout is None: + return + for callback_line in stdout: + if callback_stop.is_set() or stop.is_set(): + return + stripped = callback_line.strip() + if stripped.startswith(_EVENT_DIAGNOSTIC_PREFIX): + continue + try: + callback_event = json.loads(callback_line) + except json.JSONDecodeError: + continue + if not isinstance(callback_event, Mapping): + continue + raw_action = callback_event.get("action_value") + try: + callback_action = ( + json.loads(raw_action) + if isinstance(raw_action, str) + else raw_action + ) + except json.JSONDecodeError: + continue + if ( + not isinstance(callback_action, Mapping) + or callback_action.get("schema_version") + != _TEAM_PLAN_CALLBACK_SCHEMA + ): + continue + try: + review_callback_handler(callback_event) + except (OSError, RuntimeError, TypeError, ValueError): + logging.getLogger(__name__).warning( + "Lark manager review callback was rejected" + ) + + callback_thread = threading.Thread( + target=consume_review_callbacks, + name=f"loopx-lark-review-callback-{profile}", + daemon=True, + ) + callback_thread.start() if health_sink is not None: # A live child process is not proof that lark-cli registered a consumer # with its local event bus. Keep the connection non-ready until the @@ -421,6 +544,15 @@ def stream_lark_goal_topic_profile( def stop_consumer() -> None: while not watcher_done.wait(1.0): if stop.is_set(): + for child in (process, callback_process): + if child is not None and child.poll() is None: + child.terminate() + return + if ( + callback_process is not None + and callback_process.poll() is not None + ): + callback_disconnected.set() if process.poll() is None: process.terminate() return @@ -432,8 +564,9 @@ def stop_consumer() -> None: if not configured: configuration_removed.set() stop.set() - if process.poll() is None: - process.terminate() + for child in (process, callback_process): + if child is not None and child.poll() is None: + child.terminate() return watcher = threading.Thread( @@ -482,6 +615,7 @@ def stop_consumer() -> None: }, provider_runner=provider_runner, reply_runner=reply_runner, + proposal_deliverer=proposal_deliverer, ) if int(result.get("event_count") or 0) and not provider_ready: # A provider event is stronger readiness evidence than a @@ -523,6 +657,7 @@ def stop_consumer() -> None: ) finally: watcher_done.set() + callback_stop.set() if process.poll() is None: process.terminate() try: @@ -531,6 +666,16 @@ def stop_consumer() -> None: process.kill() returncode = process.wait(timeout=3) watcher.join(timeout=1) + if callback_process is not None: + if callback_process.poll() is None: + callback_process.terminate() + try: + callback_process.wait(timeout=3) + except subprocess.TimeoutExpired: + callback_process.kill() + callback_process.wait(timeout=3) + if callback_thread is not None: + callback_thread.join(timeout=1) stopped = stop.is_set() # A bus can die after registering the consumer and tell the CLI to exit # successfully with reason=signal (e.g. a Feishu/Lark domain mismatch). @@ -538,13 +683,27 @@ def stop_consumer() -> None: unexpected_exit = ( provider_ready and not stopped - and (returncode != 0 or exit_reason not in {"limit", "timeout"}) + and ( + callback_disconnected.is_set() + or returncode != 0 + or exit_reason not in {"limit", "timeout"} + ) ) return { "ok": configuration_removed.is_set() or stopped or (returncode == 0 and provider_ready and not unexpected_exit), - **({"error_code": "lark_event_source_disconnected"} if unexpected_exit else {}), + **( + { + "error_code": ( + "lark_review_callback_source_disconnected" + if callback_disconnected.is_set() + else "lark_event_source_disconnected" + ) + } + if unexpected_exit + else {} + ), "status": ( "configuration_removed" if configuration_removed.is_set() @@ -570,12 +729,14 @@ def __init__( snapshot_provider: SnapshotProvider, runtime_root: str | Path, runtime_controller: Any, + action_service: Any | None = None, profile_poller: ProfilePoller | None = None, manager_route_reconciler: ManagerRouteReconciler | None = None, ) -> None: self.snapshot_provider = snapshot_provider self.runtime_root = Path(runtime_root).expanduser().resolve() self.runtime_controller = runtime_controller + self.action_service = action_service self._profile_poller = profile_poller or self._poll_profile self.manager_route_reconciler = manager_route_reconciler self._lock = threading.Lock() @@ -677,7 +838,7 @@ def answer( str(effective_route.get("goal_id") or "") ) context = context if isinstance(context, Mapping) else {} - response_text = answer_lark_goal_topic( + answer_result = answer_lark_goal_topic( route=effective_route, text=text, work_dir=str(context.get("work_dir") or self.runtime_root), @@ -688,17 +849,87 @@ def answer( ), runtime_controller=self.runtime_controller, ) + if isinstance(answer_result, Mapping): + response_text = str( + answer_result.get("response_text") or "" + ) + proposal_ids = list( + answer_result.get("proposal_ids") or [] + ) + else: + response_text = answer_result + proposal_ids = [] return { "response_text": response_text, "effect_receipt": _session_turn_effect(effective_route), + "proposal_ids": proposal_ids, } + def deliver_proposals( + route: Mapping[str, Any], proposal_ids: list[str] + ) -> Mapping[str, Any]: + from .team_plan_confirmation import ( + deliver_team_plan_review_cards, + ) + + registry_path = getattr( + self.runtime_controller, "registry_path", None + ) + if not isinstance(registry_path, Path): + raise ValueError( + "Lark manager proposal delivery requires the active registry" + ) + return deliver_team_plan_review_cards( + proposal_ids=proposal_ids, + manager_route=route, + registry_path=registry_path, + runtime_root=self.runtime_root, + action_store_root=self.runtime_root + / "chat" + / "actions", + ) + + def handle_review_callback( + event: Mapping[str, Any], + ) -> Mapping[str, Any]: + if self.action_service is None: + raise ValueError( + "Lark manager review callbacks require Chat actions" + ) + from .team_plan_confirmation import ( + handle_lark_review_callback, + ) + + profile_config = _active_profile_configs( + self.snapshot_provider() + ).get(profile) + if not isinstance(profile_config, Mapping): + raise ValueError("Lark manager profile is unavailable") + return handle_lark_review_callback( + event, + action_service=self.action_service, + action_store_root=self.runtime_root + / "chat" + / "actions", + profile_app_id=str( + profile_config.get("bot_app_id") or "" + ), + cli_bin=str(profile_config.get("cli_bin") or "lark-cli"), + profile=profile, + ) + result = stream_lark_goal_topic_profile( profile=profile, snapshot_provider=self.snapshot_provider, stop=stop, runtime_root=self.runtime_root, answer=answer, + proposal_deliverer=deliver_proposals, + review_callback_handler=( + handle_review_callback + if self.action_service is not None + else None + ), health_sink=lambda update: self._update_health( profile, **dict(update) ), @@ -859,7 +1090,7 @@ def answer_lark_goal_topic( work_dir: str | Path, objective: str, runtime_controller: Any, -) -> str: +) -> str | Mapping[str, Any]: """Deliver one Topic message using its exact Agent ingress contract.""" goal_id = str(route.get("goal_id") or "") ingress_mode = str(route.get("ingress_mode") or "direct_session") @@ -980,6 +1211,27 @@ def answer_lark_goal_topic( ) if not reply_text.strip(): raise RuntimeError("Lark Goal Topic turn returned no message") + if not manager: + return reply_text + proposal_ids: list[str] = [] + events_after = getattr(runtime_controller.store, "events_after", None) + if callable(events_after): + for event in events_after(session_id, str(turn["turn_id"]), None): + if not isinstance(event, Mapping) or event.get("kind") != "team_plan.projected": + continue + payload = event.get("payload") + proposal_id = ( + str(payload.get("proposal_id") or "") + if isinstance(payload, Mapping) + else "" + ) + if proposal_id and proposal_id not in proposal_ids: + proposal_ids.append(proposal_id) + if proposal_ids: + return { + "response_text": reply_text, + "proposal_ids": proposal_ids, + } return reply_text @@ -1053,6 +1305,7 @@ def process_lark_goal_topic_event( answer: Answer, reply_runner: CommandRunner, provider_runner: Any | None = None, + proposal_deliverer: ProposalDeliverer | None = None, ) -> dict[str, Any]: """Route, persist, answer, reply, and ACK one bound Topic event.""" @@ -1268,6 +1521,7 @@ def process_lark_goal_topic_event( failure_code: str | None = None effect_receipt: Mapping[str, Any] | None = None + proposal_ids: list[str] = [] answer_completed = False if delivery_state is not None: saved_response_reused = True @@ -1278,6 +1532,7 @@ def process_lark_goal_topic_event( effect_receipt = saved_effect if isinstance(saved_effect, Mapping) else None saved_failure = delivery_state.get("failure_code") failure_code = str(saved_failure) if saved_failure else None + proposal_ids = [str(value) for value in delivery_state.get("proposal_ids") or []] else: try: answer_result = answer(route, str(canonical["content"])) @@ -1311,6 +1566,11 @@ def process_lark_goal_topic_event( effect_receipt = ( candidate_receipt if isinstance(candidate_receipt, Mapping) else None ) + proposal_ids = [ + str(value) + for value in answer_result.get("proposal_ids") or [] + if str(value) + ] else: reply_text = str(answer_result or "").strip() content_format = "markdown" if manager else "text" @@ -1355,6 +1615,7 @@ def process_lark_goal_topic_event( context_material_ids=[ item["message_id"] for item in context_materials ], + proposal_ids=proposal_ids, ) if rich_text_repairs: delivery_state["rich_text_repairs"] = rich_text_repairs @@ -1540,6 +1801,42 @@ def process_lark_goal_topic_event( "inbox_config_ref": config_ref, "source_acknowledged": False, } + if proposal_ids and proposal_deliverer is None: + return { + "ok": False, + "status": "proposal_delivery_unavailable", + "goal_id": route["goal_id"], + "inbox_config_ref": config_ref, + "source_acknowledged": False, + } + if proposal_ids and proposal_deliverer is not None: + existing_proposal_delivery = delivery_state.get("proposal_delivery") + if not isinstance(existing_proposal_delivery, Mapping): + try: + proposal_delivery = validate_team_plan_delivery_receipt( + proposal_deliverer(route, proposal_ids), + proposal_ids=proposal_ids, + ) + except (OSError, ValueError, TypeError, KeyError): + return { + "ok": False, + "status": "proposal_delivery_pending", + "goal_id": route["goal_id"], + "inbox_config_ref": config_ref, + "source_acknowledged": False, + } + delivery_state["proposal_delivery"] = proposal_delivery + delivery_state["updated_at"] = datetime.now(timezone.utc).isoformat() + try: + _write_manager_delivery(delivery_path, delivery_state) + except OSError: + return { + "ok": False, + "status": "proposal_delivery_receipt_unavailable", + "goal_id": route["goal_id"], + "inbox_config_ref": config_ref, + "source_acknowledged": False, + } if connector is not None: ack_decision = decide_external_event_ack( event_id=canonical["event_id"], diff --git a/loopx/extensions/lark/manager_reply_delivery.py b/loopx/extensions/lark/manager_reply_delivery.py index a689a7961b..b9a40f3a9d 100644 --- a/loopx/extensions/lark/manager_reply_delivery.py +++ b/loopx/extensions/lark/manager_reply_delivery.py @@ -5,6 +5,7 @@ import hashlib import json import os +import re from collections.abc import Mapping, Sequence from datetime import datetime, timezone from pathlib import Path @@ -14,6 +15,8 @@ from .private_json import write_private_json_atomic SCHEMA_VERSION = "lark_manager_reply_delivery_v0" +TEAM_PLAN_DELIVERY_RECEIPT_SCHEMA_VERSION = "lark_team_plan_review_delivery_v0" +PROPOSAL_ID_PATTERN = re.compile(r"^proposal-[a-f0-9]{32}$") def source_digest(event: Mapping[str, Any]) -> str: @@ -33,6 +36,43 @@ def text_digest(text: str) -> str: return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest() +def validate_team_plan_delivery_receipt( + value: object, *, proposal_ids: Sequence[str] +) -> dict[str, Any]: + """Validate the receipt that makes proposal delivery replay-safe.""" + + expected_ids = [str(item) for item in proposal_ids] + required = { + "schema_version", + "ok", + "status", + "proposal_ids", + "proposal_count", + "audience_count", + "readback_verified", + "external_write_count", + } + if not isinstance(value, Mapping) or set(value) != required: + raise ValueError("manager proposal delivery receipt is invalid") + receipt = dict(value) + audience_count = receipt.get("audience_count") + external_write_count = receipt.get("external_write_count") + if ( + receipt.get("schema_version") != TEAM_PLAN_DELIVERY_RECEIPT_SCHEMA_VERSION + or receipt.get("ok") is not True + or receipt.get("status") != "team_plan_review_cards_delivered" + or receipt.get("proposal_ids") != expected_ids + or receipt.get("proposal_count") != len(expected_ids) + or audience_count != len(expected_ids) * 2 + or receipt.get("readback_verified") is not True + or not isinstance(external_write_count, int) + or isinstance(external_write_count, bool) + or not 0 <= external_write_count <= audience_count + ): + raise ValueError("manager proposal delivery receipt is invalid") + return receipt + + def delivery_path( *, project: Path, config_path: Path, message_id: str ) -> Path: @@ -81,6 +121,21 @@ def load_delivery( or payload.get("content_format") not in {"markdown", "text"} ): raise ValueError("manager pending delivery content is invalid") + proposal_ids = payload.get("proposal_ids", []) + if ( + not isinstance(proposal_ids, list) + or len(set(proposal_ids)) != len(proposal_ids) + or any( + not isinstance(value, str) or not PROPOSAL_ID_PATTERN.fullmatch(value) + for value in proposal_ids + ) + ): + raise ValueError("manager delivery proposal ids are invalid") + proposal_delivery = payload.get("proposal_delivery") + if proposal_delivery is not None: + validate_team_plan_delivery_receipt( + proposal_delivery, proposal_ids=proposal_ids + ) if payload.get("status") == "sent_verified" and ( payload.get("external_write_performed") is not True or payload.get("verification_performed") is not True @@ -128,12 +183,19 @@ def pending_delivery( effect_receipt: Mapping[str, Any] | None, failure_code: str | None, context_material_ids: Sequence[str] | None = None, + proposal_ids: Sequence[str] | None = None, ) -> dict[str, Any]: normalized_context_ids = [str(value) for value in (context_material_ids or [])] if len(set(normalized_context_ids)) != len(normalized_context_ids) or any( not MESSAGE_ID_PATTERN.fullmatch(value) for value in normalized_context_ids ): raise ValueError("manager delivery context material ids are invalid") + normalized_proposal_ids = [str(value) for value in (proposal_ids or [])] + if len(set(normalized_proposal_ids)) != len(normalized_proposal_ids) or any( + not PROPOSAL_ID_PATTERN.fullmatch(value) + for value in normalized_proposal_ids + ): + raise ValueError("manager delivery proposal ids are invalid") now = datetime.now(timezone.utc).isoformat() return { "schema_version": SCHEMA_VERSION, @@ -149,6 +211,7 @@ def pending_delivery( # Persist the exact context set used to produce this answer so a # transport retry cannot silently switch to newer arrivals. "context_material_ids": normalized_context_ids, + "proposal_ids": normalized_proposal_ids, "failure_code": failure_code, "format_degraded": False, "attempt_count": 0, diff --git a/loopx/extensions/lark/presentation/team_plan.py b/loopx/extensions/lark/presentation/team_plan.py new file mode 100644 index 0000000000..a77a6d945c --- /dev/null +++ b/loopx/extensions/lark/presentation/team_plan.py @@ -0,0 +1,277 @@ +"""Lark presentation for one canonical ``team.plan`` proposal.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +import html +from typing import Any + +from ....chat_action_store import ActionConflictError +from ....control_plane.effect_runtime import effect_runtime_result + + +TEAM_PLAN_CARD_ACTION_SCHEMA_VERSION = "loopx_team_plan_card_action_v0" + + +def _card_text(value: object) -> str: + text = html.escape(str(value or "").strip(), quote=True) + for character, entity in { + "*": "*", + "~": "~", + "[": "[", + "]": "]", + "(": "(", + ")": ")", + "#": "#", + "_": "_", + }.items(): + text = text.replace(character, entity) + return text + + +def _review_frame(proposal: Mapping[str, Any]) -> dict[str, Any]: + plan = effect_runtime_result( + "presentation.action_review_plan.compile", + {"proposal": proposal}, + ) + frame = plan.get("reviewCardFrame") if isinstance(plan, Mapping) else None + if ( + not isinstance(frame, Mapping) + or frame.get("schemaVersion") != "review_card_frame_v0" + or frame.get("actionKind") != "team.plan" + or frame.get("proposalId") != proposal.get("proposal_id") + or frame.get("stateFingerprint") + != proposal.get("expected_state_fingerprint") + ): + raise ValueError("team plan review frame is unavailable") + return dict(frame) + + +def _field_label(key: object) -> str: + token = str(key or "") + labels = { + "goal": "Goal", + "objective": "目标", + "lane_gaps": "待补齐 lane", + "quota_envelope": "配额边界", + "stop_condition": "停止条件", + } + if token.startswith("lane_") and token[5:].isdigit(): + return f"Lane {token[5:]}" + return labels.get(token, token) + + +def _field_markdown(fields: Sequence[Mapping[str, Any]]) -> str: + return "\n".join( + f"**{_card_text(_field_label(field.get('key')))}**\n" + f"{_card_text(field.get('value'))}" + for field in fields + ) + + +def build_team_plan_review_card( + proposal: Mapping[str, Any], *, audience_id: str +) -> dict[str, Any]: + """Render provider-neutral review semantics into one Lark Card 2.0.""" + + frame = _review_frame(proposal) + if frame.get("kind") != "confirmation": + raise ActionConflictError("team plan is not awaiting confirmation") + fields = frame.get("fields") + if not isinstance(fields, list) or not all( + isinstance(item, Mapping) for item in fields + ): + raise ValueError("team plan review fields are unavailable") + action_base = { + "schema_version": TEAM_PLAN_CARD_ACTION_SCHEMA_VERSION, + "proposal_id": str(frame["proposalId"]), + "state_fingerprint": str(frame["stateFingerprint"]), + "audience_id": audience_id, + } + return { + "schema": "2.0", + "config": { + "update_multi": True, + "width_mode": "default", + "enable_forward": False, + "summary": {"content": "LoopX 团队计划待确认"}, + }, + "header": { + "title": {"tag": "plain_text", "content": "团队计划待确认"}, + "subtitle": { + "tag": "plain_text", + "content": "仅预览;确认前不会创建 lane Todo", + }, + "template": "orange", + "icon": {"tag": "standard_icon", "token": "approval_colorful"}, + "text_tag_list": [ + { + "tag": "text_tag", + "text": {"tag": "plain_text", "content": "待确认"}, + "color": "orange", + } + ], + }, + "body": { + "direction": "vertical", + "padding": "12px 12px 20px 12px", + "vertical_spacing": "12px", + "elements": [ + { + "tag": "markdown", + "content": f"**计划范围**\n{_card_text(frame['focus'])}", + }, + { + "tag": "column_set", + "flex_mode": "none", + "columns": [ + { + "tag": "column", + "width": "weighted", + "weight": 1, + "background_style": "grey-50", + "padding": "12px", + "elements": [ + { + "tag": "markdown", + "content": _field_markdown(fields), + } + ], + } + ], + }, + { + "tag": "markdown", + "content": ( + "**确认边界**\n确认后仅为每条就绪 lane 创建首个有界 Todo;" + "当前 proposal 之外不授予任何写权限。" + ), + }, + { + "tag": "column_set", + "flex_mode": "bisect", + "horizontal_spacing": "12px", + "columns": [ + { + "tag": "column", + "elements": [ + { + "tag": "button", + "text": { + "tag": "plain_text", + "content": "确认团队计划", + }, + "type": "primary_filled", + "width": "fill", + "behaviors": [ + { + "type": "callback", + "value": { + **action_base, + "decision": "confirm", + }, + } + ], + "confirm": { + "title": { + "tag": "plain_text", + "content": "确认这个精确计划?", + }, + "text": { + "tag": "plain_text", + "content": ( + "提交后只应用卡片中的当前 proposal;" + "计划或 Goal 状态变化会要求重新确认。" + ), + }, + }, + } + ], + }, + { + "tag": "column", + "elements": [ + { + "tag": "button", + "text": { + "tag": "plain_text", + "content": "拒绝", + }, + "type": "danger", + "width": "fill", + "behaviors": [ + { + "type": "callback", + "value": { + **action_base, + "decision": "reject", + }, + } + ], + } + ], + }, + ], + }, + ], + }, + } + + +def build_team_plan_result_card(proposal: Mapping[str, Any]) -> dict[str, Any]: + frame = _review_frame(proposal) + kind = str(frame.get("kind") or "") + if kind not in {"pending", "result"}: + raise ActionConflictError("team plan result is not available") + result_kind = str(frame.get("resultKind") or "pending") + labels = { + "pending": ("正在应用", "blue"), + "applied": ("已应用", "green"), + "rejected": ("已拒绝", "red"), + "stale": ("需要重新确认", "orange"), + "failed": ("应用失败", "red"), + "inactive": ("已失效", "grey"), + } + label, template = labels.get(result_kind, labels["inactive"]) + return { + "schema": "2.0", + "config": { + "update_multi": True, + "width_mode": "default", + "enable_forward": False, + "summary": {"content": f"LoopX 团队计划 · {label}"}, + }, + "header": { + "title": {"tag": "plain_text", "content": "团队计划"}, + "subtitle": {"tag": "plain_text", "content": str(frame["focus"])}, + "template": template, + "icon": {"tag": "standard_icon", "token": "approval_colorful"}, + "text_tag_list": [ + { + "tag": "text_tag", + "text": {"tag": "plain_text", "content": label}, + "color": template, + } + ], + }, + "body": { + "direction": "vertical", + "padding": "12px", + "elements": [ + { + "tag": "markdown", + "content": ( + f"**{_card_text(label)}**\n" + f"{_card_text(frame.get('resultSummary') or result_kind)}" + ), + } + ], + }, + } + + +__all__ = [ + "build_team_plan_result_card", + "build_team_plan_review_card", + "TEAM_PLAN_CARD_ACTION_SCHEMA_VERSION", +] diff --git a/loopx/extensions/lark/team_plan_confirmation.py b/loopx/extensions/lark/team_plan_confirmation.py new file mode 100644 index 0000000000..89caceba6a --- /dev/null +++ b/loopx/extensions/lark/team_plan_confirmation.py @@ -0,0 +1,593 @@ +"""Lark confirmation surfaces for one canonical ``team.plan`` proposal.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import hashlib +import json +import re +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +from ...chat_action_store import ActionConflictError, ChatActionStore +from ...control_plane.runtime.runtime_projection_route import ( + resolve_goal_source_runtime_route, +) +from ...file_lock import exclusive_file_lock +from ...history import load_registry +from .card_callback import ( + callback_card_content_matches as _callback_card_content_matches, + callback_timestamp, + operator_membership_verified as _operator_membership_verified, + patch_result_card as _patch_operation_result_card, + read_callback_card_content as _read_callback_card_content, + update_callback_card as _update_callback_card, +) +from .goal_channel_contracts import ( + binding_for_goal, + bindings_for_goal, + default_goal_channel_binding_path, + read_goal_channel_binding, +) +from .goal_channel_delivery_contract import ( + goal_channel_binding_digest, + goal_channel_delivery_route, +) +from .goal_channel_message_delivery import ( + GoalChannelDeliveryStageError, + GoalChannelMessageDeliverySession, +) +from .goal_channel_targets import ( + default_goal_channel_target_path, + goal_channel_target_for_name, + read_goal_channel_targets, +) +from .manager_reply_delivery import TEAM_PLAN_DELIVERY_RECEIPT_SCHEMA_VERSION +from .presentation.kanban import CommandRunner, default_subprocess_runner +from .presentation.team_plan import ( + TEAM_PLAN_CARD_ACTION_SCHEMA_VERSION, + build_team_plan_result_card, + build_team_plan_review_card, +) + + +TEAM_PLAN_CALLBACK_RECEIPT_SCHEMA_VERSION = "lark_team_plan_callback_receipt_v0" +_EVENT_ID = re.compile(r"^[A-Za-z0-9._:-]{1,240}$") +_MESSAGE_ID = re.compile(r"^om_[A-Za-z0-9_-]+$") +_CHAT_ID = re.compile(r"^oc_[A-Za-z0-9_-]+$") +_OPEN_ID = re.compile(r"^ou_[A-Za-z0-9_-]+$") + + +def _digest(value: object) -> str: + encoded = json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _resolved_binding( + *, + registry_path: Path, + runtime_root: Path, + goal_id: str, + connection_id: str | None, + manager_audience: bool, +) -> tuple[dict[str, Any], Path, Path]: + registry = load_registry(registry_path) + source = resolve_goal_source_runtime_route( + registry_path=registry_path, + goal_id=goal_id, + registry=registry, + ) + source_registry = Path(str(source["source_registry"])) + binding_path = default_goal_channel_binding_path(source_registry) + payload = read_goal_channel_binding(binding_path) + selected_connection_id = connection_id + if selected_connection_id is None: + default_binding = binding_for_goal(payload, goal_id) + if ( + isinstance(default_binding, Mapping) + and default_binding.get("enabled") is True + and ( + (default_binding.get("routing") or {}).get("conversation_kind") + == "manager" + ) + is manager_audience + ): + selected_connection_id = str( + default_binding.get("connection_id") or "" + ) + candidates = [ + item + for item in bindings_for_goal(payload, goal_id) + if item.get("enabled") is True + and ( + (item.get("routing") or {}).get("conversation_kind") == "manager" + ) + is manager_audience + ] + if selected_connection_id is None and candidates: + selected_connection_id = str( + min( + candidates, + key=lambda item: str(item.get("connection_id") or ""), + ).get("connection_id") + or "" + ) + raw = binding_for_goal( + payload, goal_id, connection_id=selected_connection_id + ) + if raw is None: + raise ValueError("team plan review audience binding is unavailable") + if ( + ((raw.get("routing") or {}).get("conversation_kind") == "manager") + is not manager_audience + ): + raise ValueError("team plan review audience kind is unavailable") + target_path = default_goal_channel_target_path(runtime_root) + target_ref = str(raw.get("target_ref") or "") + target = goal_channel_target_for_name( + read_goal_channel_targets(target_path), target_ref + ) + if target is None: + raise ValueError("team plan review audience target is unavailable") + resolved = binding_for_goal( + payload, + goal_id, + provider_target=target, + connection_id=selected_connection_id, + ) + if resolved is None: + raise ValueError("team plan review audience binding is incomplete") + return resolved, binding_path, target_path + + +def _deliver_one( + *, + store: ChatActionStore, + proposal: Mapping[str, Any], + audience_id: str, + binding: Mapping[str, Any], + binding_path: Path, + target_path: Path, + authorized_principal: str, + runner: CommandRunner, +) -> dict[str, Any]: + audience_goal_id = str(binding.get("goal_id") or "") + route = goal_channel_delivery_route( + audience_goal_id, lambda _goal_id: binding + ) + card = build_team_plan_review_card(proposal, audience_id=audience_id) + card_digest = _digest(card) + + def resolve_current() -> Mapping[str, Any]: + payload = read_goal_channel_binding(binding_path) + target_ref = str(binding.get("target_ref") or "") + target = goal_channel_target_for_name( + read_goal_channel_targets(target_path), target_ref + ) + current = binding_for_goal( + payload, + str(binding["goal_id"]), + provider_target=target, + connection_id=str(binding.get("connection_id") or "") or None, + ) + if current is None: + raise ValueError("team plan review audience binding disappeared") + return current + + session = GoalChannelMessageDeliverySession( + goal_id=str(binding["goal_id"]), + binding=binding, + binding_lock_path=binding_path, + target_lock_path=target_path, + history_start_at=str(proposal["created_at"]), + resolve_current_binding=resolve_current, + runner=runner, + ) + if session.verify(route) is not True: + raise GoalChannelDeliveryStageError( + "team plan review sender identity could not be verified", + blocker="sender_identity_unverified", + failure_stage="verify_sender_identity", + ) + sent = dict( + session.send( + card, + f"{proposal['proposal_id']}:{audience_id}:{proposal['expected_state_fingerprint']}", + route, + ) + ) + message_id = str(sent.get("message_id") or "") + observed = dict(session.readback(message_id)) + if not ( + observed.get("verified") is True + and observed.get("message_id") == message_id + and observed.get("chat_id") == route["chat_id"] + and observed.get("sender_app_id") == route["bot_app_id"] + ): + raise GoalChannelDeliveryStageError( + "team plan review card lacked exact native readback", + blocker="delivery_readback_unverified", + failure_stage="read_review_card", + external_write_performed=sent.get("external_write_performed") is True, + ) + store.record_review_card_delivery( + str(proposal["proposal_id"]), + audience_id=audience_id, + delivery={ + "provider": "lark", + "message_id": message_id, + "chat_id": str(route["chat_id"]), + "app_id": str(route["bot_app_id"]), + "cli_bin": str(route["cli_bin"]), + "sender_profile": str(route["sender_profile"]), + "binding_digest": goal_channel_binding_digest(binding), + "card_digest": card_digest, + "submitted_card": card, + "delivered_at": datetime.now(timezone.utc).isoformat(), + "authorized_principal": authorized_principal, + }, + ) + return { + "audience_id": audience_id, + "message_id": message_id, + "external_write_performed": sent.get("external_write_performed") is True, + "readback_verified": True, + } + + +def deliver_team_plan_review_cards( + *, + proposal_ids: Sequence[str], + manager_route: Mapping[str, Any], + registry_path: Path, + runtime_root: Path, + action_store_root: Path, + runner: CommandRunner = default_subprocess_runner, +) -> dict[str, Any]: + """Deliver one proposal to manager and Goal audiences with exact readback.""" + + store = ChatActionStore(action_store_root) + source_sender_id = str(manager_route.get("source_sender_id") or "") + if not source_sender_id.startswith("ou_"): + raise ValueError("team plan review requires an authenticated Lark owner") + authorized_principal = f"lark:{source_sender_id}" + deliveries: list[dict[str, Any]] = [] + for proposal_id in proposal_ids: + proposal = store.load(str(proposal_id)) + if proposal is None or proposal.get("action_kind") != "team.plan": + raise ValueError("typed team plan proposal was not found") + parameters = proposal.get("normalized_parameters") + plan_goal_id = ( + str(parameters.get("goal_id") or "") + if isinstance(parameters, Mapping) + else "" + ) + manager_goal_id = str(manager_route.get("goal_id") or "") + manager_connection_id = str(manager_route.get("connection_id") or "") + manager_binding, manager_binding_path, target_path = _resolved_binding( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=manager_goal_id, + connection_id=manager_connection_id or None, + manager_audience=True, + ) + goal_binding, goal_binding_path, _ = _resolved_binding( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=plan_goal_id, + connection_id=None, + manager_audience=False, + ) + audiences = [ + ( + "manager", + manager_binding, + manager_binding_path, + ), + ( + f"goal:{plan_goal_id}", + goal_binding, + goal_binding_path, + ), + ] + store.prepare_review_card_delivery( + str(proposal["proposal_id"]), + audience_ids=[audience_id for audience_id, _binding, _path in audiences], + authorized_principal=authorized_principal, + ) + for audience_id, binding, binding_path in audiences: + deliveries.append( + _deliver_one( + store=store, + proposal=proposal, + audience_id=audience_id, + binding=binding, + binding_path=binding_path, + target_path=target_path, + authorized_principal=authorized_principal, + runner=runner, + ) + ) + return { + "schema_version": TEAM_PLAN_DELIVERY_RECEIPT_SCHEMA_VERSION, + "ok": True, + "status": "team_plan_review_cards_delivered", + "proposal_ids": [str(value) for value in proposal_ids], + "proposal_count": len(proposal_ids), + "audience_count": len(deliveries), + "readback_verified": all( + item.get("readback_verified") is True for item in deliveries + ), + "external_write_count": sum( + item.get("external_write_performed") is True for item in deliveries + ), + } + + +def _callback_action(event: Mapping[str, Any]) -> dict[str, str]: + if event.get("type") != "card.action.trigger": + raise ValueError("team plan callback event type is unsupported") + if event.get("action_tag") != "button": + raise ValueError("team plan callback must come from a button") + raw = event.get("action_value") + try: + value = json.loads(raw) if isinstance(raw, str) else raw + except json.JSONDecodeError as exc: + raise ValueError("team plan callback action_value is invalid") from exc + required = { + "schema_version", + "proposal_id", + "state_fingerprint", + "audience_id", + "decision", + } + if not isinstance(value, Mapping) or set(value) != required: + raise ValueError("team plan callback action is incomplete") + if value.get("schema_version") != TEAM_PLAN_CARD_ACTION_SCHEMA_VERSION: + raise ValueError("team plan callback action schema is unsupported") + if value.get("decision") not in {"confirm", "reject"}: + raise ValueError("team plan callback decision is unsupported") + return {key: str(value[key]) for key in value} + + +def handle_team_plan_review_callback( + event: Mapping[str, Any], + *, + action_service: Any, + action_store_root: Path, + profile_app_id: str, + cli_bin: str, + profile: str, + runner: CommandRunner = default_subprocess_runner, +) -> dict[str, Any]: + """Apply one authenticated decision and patch every audience readback.""" + + action = _callback_action(event) + callback_token = str(event.get("token") or "").strip() + if ( + not callback_token + or len(callback_token) > 2048 + or any(ord(character) < 32 for character in callback_token) + ): + raise ValueError("team plan callback update token is invalid") + for field, pattern in ( + ("event_id", _EVENT_ID), + ("message_id", _MESSAGE_ID), + ("chat_id", _CHAT_ID), + ("operator_id", _OPEN_ID), + ): + if not pattern.fullmatch(str(event.get(field) or "")): + raise ValueError(f"team plan callback {field} is invalid") + if str(event.get("host") or "") != "im_message": + raise ValueError("team plan callback host is unsupported") + store = ChatActionStore(action_store_root) + proposal = store.load(action["proposal_id"]) + if proposal is None or proposal.get("action_kind") != "team.plan": + raise ValueError("team plan callback proposal was not found") + review_card = proposal.get("review_card") + deliveries = ( + review_card.get("deliveries") + if isinstance(review_card, Mapping) + else None + ) + delivery = ( + deliveries.get(action["audience_id"]) + if isinstance(deliveries, Mapping) + else None + ) + if not isinstance(delivery, Mapping): + raise ActionConflictError("team plan review card delivery was not recorded") + if action["state_fingerprint"] != proposal.get("expected_state_fingerprint"): + raise ActionConflictError("team plan callback state fingerprint drifted") + if ( + profile_app_id != delivery.get("app_id") + or cli_bin != delivery.get("cli_bin") + or profile != delivery.get("sender_profile") + or str(event["message_id"]) != delivery.get("message_id") + or str(event["chat_id"]) != delivery.get("chat_id") + ): + raise ActionConflictError("team plan callback delivery binding drifted") + operator_id = str(event["operator_id"]) + existing_confirmation = ( + review_card.get("confirmation") + if isinstance(review_card, Mapping) + else None + ) + settled = isinstance(existing_confirmation, Mapping) + if not settled: + expected_card = delivery.get("submitted_card") + if not isinstance(expected_card, Mapping): + raise ValueError("team plan submitted card is unavailable") + if _digest(expected_card) != delivery.get("card_digest"): + raise ActionConflictError("recorded team plan card digest drifted") + card_content = event.get("card_content") + if card_content is None or card_content == "": + card_content = _read_callback_card_content( + runner=runner, + cli_bin=cli_bin, + profile=profile, + message_id=str(event["message_id"]), + chat_id=str(event["chat_id"]), + app_id=profile_app_id, + ) + if card_content is None or card_content == "": + raise ValueError("team plan callback card content is unavailable") + if not _callback_card_content_matches(card_content, expected_card): + raise ActionConflictError("team plan callback card content drifted") + if not _operator_membership_verified( + runner=runner, + cli_bin=cli_bin, + profile=profile, + chat_id=str(event["chat_id"]), + operator_id=operator_id, + ): + raise ActionConflictError( + "team plan callback tenant membership is unverified" + ) + decided = store.decide_review_card( + action["proposal_id"], + decision=action["decision"], + confirmation={ + "provider": "lark", + "event_id": str(event["event_id"]), + "principal": f"lark:{operator_id}", + "message_id": str(event["message_id"]), + "chat_id": str(event["chat_id"]), + "app_id": profile_app_id, + "audience_id": action["audience_id"], + "state_fingerprint": action["state_fingerprint"], + "card_digest": str(delivery["card_digest"]), + "confirmed_at": callback_timestamp( + event.get("timestamp"), subject="team plan" + ), + }, + ) + decided_review_card = decided.get("review_card") + canonical_confirmation = ( + decided_review_card.get("confirmation") + if isinstance(decided_review_card, Mapping) + else None + ) + canonical_decision = ( + str(canonical_confirmation.get("decision") or "") + if isinstance(canonical_confirmation, Mapping) + else "" + ) + if canonical_decision not in {"confirm", "reject"}: + raise ValueError("team plan canonical decision is unavailable") + dispatch_lock = store.root / f"{action['proposal_id']}.dispatch.lock" + with exclusive_file_lock( + dispatch_lock, + agent_id="loopx-lark-team-plan", + operation="dispatch_team_plan", + ): + current = store.load(action["proposal_id"]) + if current is None: + raise ValueError("team plan disappeared before dispatch") + if ( + current.get("status") == "applying" + and canonical_decision == "confirm" + ): + applied = action_service.apply(action["proposal_id"]) + candidate = applied.get("proposal") if isinstance(applied, Mapping) else None + if not isinstance(candidate, Mapping): + raise ValueError("team plan apply returned no canonical proposal") + current = dict(candidate) + decided = current + result_card = build_team_plan_result_card(decided) + current_review_card = decided.get("review_card") + current_deliveries = ( + current_review_card.get("deliveries") + if isinstance(current_review_card, Mapping) + else None + ) + if not isinstance(current_deliveries, Mapping): + raise ValueError("team plan result audiences are unavailable") + result_verified = True + for audience_id, raw_delivery in current_deliveries.items(): + if not isinstance(raw_delivery, Mapping): + result_verified = False + continue + if isinstance(raw_delivery.get("result"), Mapping): + continue + delivery_profile = str(raw_delivery.get("sender_profile") or "") + delivery_cli_bin = str(raw_delivery.get("cli_bin") or "") + if not delivery_profile or not delivery_cli_bin: + result_verified = False + continue + if str(raw_delivery.get("message_id") or "") == str(event["message_id"]): + update = _update_callback_card( + runner=runner, + cli_bin=delivery_cli_bin, + profile=delivery_profile, + token=callback_token, + card=result_card, + message_id=str(raw_delivery["message_id"]), + chat_id=str(raw_delivery["chat_id"]), + app_id=str(raw_delivery["app_id"]), + ) + transport = "callback_update" + else: + update = _patch_operation_result_card( + runner=runner, + cli_bin=delivery_cli_bin, + profile=delivery_profile, + card=result_card, + message_id=str(raw_delivery["message_id"]), + chat_id=str(raw_delivery["chat_id"]), + app_id=str(raw_delivery["app_id"]), + ) + transport = "message_patch" + if update.get("readback_verified") is not True: + result_verified = False + continue + decided = store.record_review_card_result_delivery( + action["proposal_id"], + audience_id=str(audience_id), + result={ + "card_digest": _digest(result_card), + "transport": transport, + "delivered_at": datetime.now(timezone.utc).isoformat(), + }, + ) + return { + "ok": result_verified, + "schema_version": TEAM_PLAN_CALLBACK_RECEIPT_SCHEMA_VERSION, + "proposal_id": action["proposal_id"], + "decision": canonical_decision, + "proposal_status": decided.get("status"), + "result_delivery_verified": result_verified, + } + + +def handle_lark_review_callback( + event: Mapping[str, Any], + **kwargs: Any, +) -> dict[str, Any]: + """Dispatch a Lark review callback without broadening either authority.""" + + raw = event.get("action_value") + try: + value = json.loads(raw) if isinstance(raw, str) else raw + except json.JSONDecodeError: + value = None + if ( + isinstance(value, Mapping) + and value.get("schema_version") == TEAM_PLAN_CARD_ACTION_SCHEMA_VERSION + ): + return handle_team_plan_review_callback(event, **kwargs) + raise ValueError("Lark review callback schema is unsupported") + + +__all__ = [ + "build_team_plan_result_card", + "build_team_plan_review_card", + "deliver_team_plan_review_cards", + "handle_lark_review_callback", + "handle_team_plan_review_callback", + "TEAM_PLAN_CARD_ACTION_SCHEMA_VERSION", + "TEAM_PLAN_DELIVERY_RECEIPT_SCHEMA_VERSION", +] diff --git a/tests/control_plane_ts/action_review_plan.test.ts b/tests/control_plane_ts/action_review_plan.test.ts index 604532ab20..4f692934be 100644 --- a/tests/control_plane_ts/action_review_plan.test.ts +++ b/tests/control_plane_ts/action_review_plan.test.ts @@ -243,14 +243,34 @@ test("a validated plan compiles into a confirmation card frame", () => { } }); -test("a plan card frame is refused for anything that is not an admitted preview", () => { +test("a plan card keeps the same identity through pending and result states", () => { + const pending = compileReviewCardFrame({ ...teamPlanProposal(), status: "applying" }); + if (!pending) assert.fail("expected pending review card frame"); + assert.equal(pending.kind, "pending"); + assert.equal(pending.proposalId, "proposal-team-plan-1"); + assert.equal(pending.stateFingerprint, "registry-revision-1"); + + const applied = compileReviewCardFrame({ + ...teamPlanProposal(), + status: "applied", + receipt: { outcome: "team_plan_applied", projection_verified: true }, + }); + if (!applied) assert.fail("expected applied review card frame"); + assert.equal(applied.kind, "result"); + if (applied.kind !== "result") assert.fail("expected result frame"); + assert.equal(applied.resultKind, "applied"); + assert.equal(applied.resultSummary, "team_plan_applied"); + assert.equal(applied.proposalId, pending.proposalId); +}); + +test("a plan card frame is refused for anything that was never an admitted preview", () => { const applied = teamPlanProposal(); applied.normalized_parameters.plan.applies = true; assert.equal(compileReviewCardFrame(applied), undefined); const moved = teamPlanProposal(); moved.status = "applied"; - assert.equal(compileReviewCardFrame(moved), undefined); + assert.equal(compileReviewCardFrame(moved)?.kind, "result"); const otherKind = { ...teamPlanProposal(), action_kind: "todo.create" }; assert.equal(compileReviewCardFrame(otherKind), undefined); diff --git a/tests/extensions/test_lark_goal_topic_runtime.py b/tests/extensions/test_lark_goal_topic_runtime.py index 2367bf870f..a65e3e2c9d 100644 --- a/tests/extensions/test_lark_goal_topic_runtime.py +++ b/tests/extensions/test_lark_goal_topic_runtime.py @@ -994,6 +994,67 @@ def wait_for_turn(self, **_kwargs: Any): assert "只生成预览" in runtime.submit_calls[0]["message"] +def test_remote_manager_answer_returns_the_exact_projected_proposal_ids( + tmp_path: Path, +) -> None: + from loopx.extensions.lark.goal_topic_runtime import answer_lark_goal_topic + + class Store: + def load_session(self, _session_id: str) -> dict[str, Any]: + return { + "session_id": "manager-session", + "agent_id": "codex", + "channel_id": "manager.external.public_fixture", + "status": "ready", + } + + def events_after( + self, _session_id: str, _turn_id: str, _cursor: object + ) -> list[dict[str, Any]]: + return [ + { + "kind": "team_plan.projected", + "payload": { + "proposal_id": "proposal-" + "a" * 32, + "goal_id": "goal-alpha", + }, + } + ] + + class Runtime: + store = Store() + + def enqueue_turn(self, **_kwargs: Any): + return ({"turn_id": "turn-alpha"}, True) + + def wait_for_turn(self, **_kwargs: Any): + return { + "status": "completed", + "response": {"message": "计划已准备。"}, + } + + result = answer_lark_goal_topic( + route={ + "goal_id": "goal-alpha", + "conversation_kind": "manager", + "ingress_mode": "session_queue", + "session_id": "manager-session", + "manager_channel_id": "manager.external.public_fixture", + "message_id": "om_manager_plan", + "topic_root_message_id": "om_manager_root", + }, + text="请组建团队", + work_dir=tmp_path, + objective="ignored", + runtime_controller=Runtime(), + ) + + assert result == { + "response_text": "计划已准备。", + "proposal_ids": ["proposal-" + "a" * 32], + } + + def test_runtime_service_uses_one_consumer_for_reused_app_profile( tmp_path: Path, ) -> None: @@ -1328,6 +1389,197 @@ def process_factory(args: list[str]) -> FinishedConsumer: } +def test_profile_stream_dispatches_only_team_plan_callbacks_for_bound_chats( + tmp_path: Path, +) -> None: + from loopx.extensions.lark.goal_topic_runtime import stream_lark_goal_topic_profile + + snapshot = { + "target_payload": { + "targets": { + "mew-product": { + "name": "mew-product", + "provider": "lark", + "enabled": True, + "channel": {"chat_id": "oc_public_fixture"}, + "identity": { + "sender_profile": "mew", + "bot_app_id": "cli_public_fixture", + "cli_bin": "fake-lark", + }, + } + } + }, + "binding_payloads": { + "goal-alpha": { + "bindings": { + "goal-alpha": { + "goal_id": "goal-alpha", + "provider": "lark", + "enabled": True, + "target_ref": "mew-product", + } + } + } + }, + } + callback = { + "type": "card.action.trigger", + "chat_id": "oc_public_fixture", + "action_value": { + "schema_version": "loopx_team_plan_card_action_v0", + "proposal_id": "proposal-" + "a" * 32, + }, + } + captured_args: list[list[str]] = [] + handled: list[Mapping[str, Any]] = [] + callback_seen = threading.Event() + + class FinishedConsumer: + def __init__(self, lines: Any) -> None: + self.stdout = iter(lines) + + def poll(self) -> int: + return 0 + + def wait(self, timeout: float | None = None) -> int: + return 0 + + def terminate(self) -> None: + raise AssertionError("a completed consumer must not be terminated") + + def kill(self) -> None: + raise AssertionError("a completed consumer must not be killed") + + def process_factory(args: list[str]) -> FinishedConsumer: + captured_args.append(list(args)) + if "card.action.trigger" in args: + return FinishedConsumer((json.dumps(callback) + "\n",)) + + def message_lines(): + yield "[event] ready event_key=im.message.receive_v1\n" + assert callback_seen.wait(2) + yield "[event] exited — received 0 event(s) in 1s (reason: timeout)\n" + + return FinishedConsumer(message_lines()) + + def handle_callback(event: Mapping[str, Any]) -> dict[str, bool]: + handled.append(dict(event)) + callback_seen.set() + return {"ok": True} + + result = stream_lark_goal_topic_profile( + profile="mew", + snapshot_provider=lambda: snapshot, + stop=threading.Event(), + runtime_root=tmp_path, + answer=lambda _route, _text: "ok", + process_factory=process_factory, + review_callback_handler=handle_callback, + ) + + assert result["ok"] is True + assert len(captured_args) == 2 + callback_args = next(args for args in captured_args if "card.action.trigger" in args) + assert "select(.chat_id == \"oc_public_fixture\")" in callback_args + assert handled == [callback] + + +def test_profile_stream_restarts_when_the_review_callback_source_disconnects( + tmp_path: Path, +) -> None: + from loopx.extensions.lark.goal_topic_runtime import stream_lark_goal_topic_profile + + snapshot = { + "target_payload": { + "targets": { + "mew-product": { + "name": "mew-product", + "provider": "lark", + "enabled": True, + "channel": {"chat_id": "oc_public_fixture"}, + "identity": { + "sender_profile": "mew", + "bot_app_id": "cli_public_fixture", + "cli_bin": "fake-lark", + }, + } + } + }, + "binding_payloads": { + "goal-alpha": { + "bindings": { + "goal-alpha": { + "goal_id": "goal-alpha", + "provider": "lark", + "enabled": True, + "target_ref": "mew-product", + } + } + } + }, + } + released = threading.Event() + + class MessageLines: + def __iter__(self): + yield "[event] ready event_key=im.message.receive_v1\n" + assert released.wait(3) + + class MessageConsumer: + stdout = MessageLines() + + def poll(self): + return 0 if released.is_set() else None + + def wait(self, timeout=None): + assert released.wait(timeout or 3) + return 0 + + def terminate(self): + released.set() + + def kill(self): + released.set() + + class DisconnectedCallbackConsumer: + stdout = iter(()) + + def poll(self): + return 0 + + def wait(self, timeout=None): + return 0 + + def terminate(self): + raise AssertionError("a completed callback consumer must not terminate") + + def kill(self): + raise AssertionError("a completed callback consumer must not be killed") + + result = stream_lark_goal_topic_profile( + profile="mew", + snapshot_provider=lambda: snapshot, + stop=threading.Event(), + runtime_root=tmp_path, + answer=lambda _route, _text: "ok", + process_factory=lambda args: ( + DisconnectedCallbackConsumer() + if "card.action.trigger" in args + else MessageConsumer() + ), + review_callback_handler=lambda _event: {"ok": True}, + ) + + assert result == { + "ok": False, + "error_code": "lark_review_callback_source_disconnected", + "status": "source_disconnected", + "event_count": 0, + "replied_count": 0, + } + + @pytest.mark.parametrize( "exit_reason,stop_requested,returncode", [("timeout", False, 0), ("limit", False, 0), ("signal", False, 0), @@ -2323,3 +2575,104 @@ def no_duplicate_send(args): assert inspect_lark_event_inbox( project=kwargs["runtime_root"], config_path=Path(second["inbox_config_ref"]) )["items"] == [] + + +def test_manager_retries_saved_proposal_delivery_before_source_ack( + tmp_path: Path, monkeypatch: Any +) -> None: + from loopx.extensions.lark import goal_topic_runtime as runtime + + target_path, binding_path = tmp_path / "targets.json", tmp_path / "bindings.json" + _seed_legacy_topic(target_path, binding_path) + original_decide = runtime.decide_lark_topic_event + + def manager_decision(**kwargs: Any) -> dict[str, Any]: + result = original_decide(**kwargs) + result["route"].update( + conversation_kind="manager", + ingress_mode="session_queue", + authority_mode="turn_authorized", + event_id=kwargs["event"]["event_id"], + ) + return result + + monkeypatch.setattr(runtime, "decide_lark_topic_event", manager_decision) + monkeypatch.setattr( + runtime, + "ensure_lark_event_inbox_received_reaction", + lambda **_kwargs: {"ok": True, "status": "already_received"}, + ) + event = { + "event_id": "evt_plan", + "message_id": "om_plan", + "chat_id": "oc_public_fixture", + "root_id": "om_topic_alpha", + "create_time": "2026-09-20T00:00:00Z", + "content": "@linkmacbot 组建团队", + "mentioned": True, + "sender_type": "user", + "sender_id": "ou_owner_fixture", + } + answer_calls: list[str] = [] + proposal_id = "proposal-" + "a" * 32 + + def answer(_route: Mapping[str, Any], text: str) -> dict[str, Any]: + answer_calls.append(text) + return {"response_text": "计划已准备。", "proposal_ids": [proposal_id]} + + delivery_calls: list[tuple[str, ...]] = [] + + def deliver( + route: Mapping[str, Any], proposal_ids: list[str] + ) -> dict[str, Any]: + assert route["source_sender_id"] == "ou_owner_fixture" + delivery_calls.append(tuple(proposal_ids)) + if len(delivery_calls) == 1: + return {"ok": False, "status": "pending"} + return { + "schema_version": "lark_team_plan_review_delivery_v0", + "ok": True, + "status": "team_plan_review_cards_delivered", + "proposal_ids": proposal_ids, + "proposal_count": 1, + "audience_count": 2, + "readback_verified": True, + "external_write_count": 2, + } + + first_state: dict[str, Any] = {} + kwargs = { + "target_payload": read_goal_channel_targets(target_path), + "binding_payloads": { + "goal-alpha": read_goal_channel_binding(binding_path) + }, + "event": event, + "runtime_root": tmp_path / "runtime", + "answer": answer, + "reply_runner": _reply_runner(first_state), + "proposal_deliverer": deliver, + } + + first = runtime.process_lark_goal_topic_event(**kwargs) + assert first["status"] == "proposal_delivery_pending" + assert first["source_acknowledged"] is False + assert answer_calls == [event["content"]] + assert delivery_calls == [(proposal_id,)] + + second_state: dict[str, Any] = {} + working_runner = _reply_runner(second_state) + + def no_duplicate_reply(args: list[str]) -> dict[str, Any]: + if "+messages-reply" in args: + raise AssertionError("verified manager text must not be sent twice") + return working_runner(args) + + kwargs["reply_runner"] = no_duplicate_reply + kwargs["answer"] = lambda *_args: (_ for _ in ()).throw( + AssertionError("saved answer and proposal ids must be reused") + ) + second = runtime.process_lark_goal_topic_event(**kwargs) + + assert second["status"] == "replied_and_acknowledged" + assert second["saved_response_reused"] is True + assert delivery_calls == [(proposal_id,), (proposal_id,)] diff --git a/tests/extensions/test_lark_manager_reply_delivery.py b/tests/extensions/test_lark_manager_reply_delivery.py index 8aa9226aba..0c776ec148 100644 --- a/tests/extensions/test_lark_manager_reply_delivery.py +++ b/tests/extensions/test_lark_manager_reply_delivery.py @@ -104,6 +104,46 @@ def test_manager_delivery_persists_and_validates_exact_context_ids(tmp_path): load_delivery(project=project, config_path=config, event=event) +def test_manager_delivery_rejects_an_unverified_proposal_delivery_receipt(tmp_path): + config, _, project = _fixture(tmp_path, lifecycle=False) + event = { + "event_id": "evt_reply_fixture", + "message_id": "om_reaction_fixture", + "sender_id": "ou_owner_fixture", + "content": "Prepare the team plan.", + } + proposal_id = "proposal-" + "a" * 32 + path, _ = load_delivery(project=project, config_path=config, event=event) + payload = pending_delivery( + event=event, + text="Plan ready.", + content_format="markdown", + effect_receipt=None, + failure_code=None, + proposal_ids=[proposal_id], + ) + payload["proposal_delivery"] = {"ok": True} + write_delivery(path, payload) + + with pytest.raises(ValueError, match="proposal delivery receipt"): + load_delivery(project=project, config_path=config, event=event) + + payload["proposal_delivery"] = { + "schema_version": "lark_team_plan_review_delivery_v0", + "ok": True, + "status": "team_plan_review_cards_delivered", + "proposal_ids": [proposal_id], + "proposal_count": 1, + "audience_count": 2, + "readback_verified": True, + "external_write_count": 2, + } + write_delivery(path, payload) + + _, loaded = load_delivery(project=project, config_path=config, event=event) + assert loaded["proposal_delivery"]["proposal_ids"] == [proposal_id] + + def test_manager_context_retention_discards_oldest_with_reason(tmp_path): config = tmp_path / ".loopx" / "config" / "lark.json" inbox = tmp_path / ".loopx" / "inbox" / "lark" diff --git a/tests/extensions/test_lark_team_plan_confirmation.py b/tests/extensions/test_lark_team_plan_confirmation.py new file mode 100644 index 0000000000..01e9d4b111 --- /dev/null +++ b/tests/extensions/test_lark_team_plan_confirmation.py @@ -0,0 +1,448 @@ +from __future__ import annotations + +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +from collections.abc import Mapping +from typing import Any + +from loopx.chat_action_store import ChatActionStore +from loopx.extensions.lark.team_plan_confirmation import ( + build_team_plan_review_card, + deliver_team_plan_review_cards, + handle_team_plan_review_callback, +) + + +def _proposal(store: ChatActionStore) -> dict[str, Any]: + return store.create_preview( + action_kind="team.plan", + summary="Confirm the team plan", + normalized_parameters={ + "goal_id": "goal-alpha", + "plan": { + "schema_version": "steward_team_plan_preview_v0", + "kind": "steward_team_plan_preview", + "goal_id": "goal-alpha", + "objective": "Ship one bounded intake", + "lanes": [ + { + "lane_id": "lane-alpha", + "agent_id": "agent-alpha", + "acceptance": "one canonical Todo exists", + "staffing": "ready", + "first_todo": { + "text": "Implement the intake", + "priority": "P1", + "task_class": "advancement_task", + "action_kind": "implement", + }, + } + ], + "quota_envelope": {"slots": 1}, + "stop_condition": "the bounded intake is delivered", + "applies": False, + }, + }, + context={"kind": "manager", "goal_id": "goal-alpha"}, + expected_state_fingerprint="state-alpha", + permission_classification="durable_write", + validation_evidence=["validated"], + available_transitions=["apply", "cancel"], + idempotency_key="team-plan-alpha", + ) + + +def _digest(card: dict[str, Any]) -> str: + return hashlib.sha256( + json.dumps( + card, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + ).hexdigest() + + +def _record_delivery( + store: ChatActionStore, + proposal: dict[str, Any], + *, + audience_id: str, + message_id: str, + chat_id: str, +) -> dict[str, Any]: + card = build_team_plan_review_card(proposal, audience_id=audience_id) + store.record_review_card_delivery( + proposal["proposal_id"], + audience_id=audience_id, + delivery={ + "provider": "lark", + "message_id": message_id, + "chat_id": chat_id, + "app_id": "cli_public_fixture", + "cli_bin": "fake-lark", + "sender_profile": "fixture", + "binding_digest": "sha256:" + "b" * 64, + "card_digest": _digest(card), + "submitted_card": card, + "delivered_at": datetime.now(timezone.utc).isoformat(), + "authorized_principal": "lark:ou_owner", + }, + ) + return card + + +def _event( + *, audience_id: str, message_id: str, chat_id: str, card: dict[str, Any] +) -> dict[str, Any]: + return { + "type": "card.action.trigger", + "action_tag": "button", + "action_value": { + "schema_version": "loopx_team_plan_card_action_v0", + "proposal_id": "proposal-placeholder", + "state_fingerprint": "state-alpha", + "audience_id": audience_id, + "decision": "confirm", + }, + "event_id": f"evt_{audience_id.replace(':', '_')}", + "message_id": message_id, + "chat_id": chat_id, + "operator_id": "ou_owner", + "host": "im_message", + "token": "callback-token", + "timestamp": "1789843200000", + "card_content": card, + } + + +def test_two_lark_audiences_apply_one_canonical_team_plan( + tmp_path: Path, monkeypatch: Any +) -> None: + import loopx.extensions.lark.team_plan_confirmation as confirmation + + store = ChatActionStore(tmp_path / "actions") + proposal = _proposal(store) + store.prepare_review_card_delivery( + proposal["proposal_id"], + audience_ids=["manager", "goal:goal-alpha"], + authorized_principal="lark:ou_owner", + ) + manager_card = _record_delivery( + store, + proposal, + audience_id="manager", + message_id="om_manager", + chat_id="oc_manager", + ) + goal_card = _record_delivery( + store, + proposal, + audience_id="goal:goal-alpha", + message_id="om_goal", + chat_id="oc_goal", + ) + + monkeypatch.setattr( + confirmation, "_operator_membership_verified", lambda **_kwargs: True + ) + monkeypatch.setattr( + confirmation, + "_update_callback_card", + lambda **_kwargs: { + "external_write_performed": True, + "readback_verified": True, + }, + ) + monkeypatch.setattr( + confirmation, + "_patch_operation_result_card", + lambda **_kwargs: { + "external_write_performed": True, + "readback_verified": True, + }, + ) + + class ActionService: + calls = 0 + + def apply(self, proposal_id: str) -> dict[str, Any]: + self.calls += 1 + applied = store.apply( + proposal_id, + current_state_fingerprint="state-alpha", + receipt={ + "receipt_id": "receipt-alpha", + "outcome": "team_plan_applied", + "projection_verified": True, + }, + ) + return {"proposal": applied, "turn": None} + + service = ActionService() + manager_event = _event( + audience_id="manager", + message_id="om_manager", + chat_id="oc_manager", + card=manager_card, + ) + manager_event["action_value"]["proposal_id"] = proposal["proposal_id"] + first = handle_team_plan_review_callback( + manager_event, + action_service=service, + action_store_root=store.root, + profile_app_id="cli_public_fixture", + cli_bin="fake-lark", + profile="fixture", + ) + assert first["ok"] is True + assert first["proposal_status"] == "applied" + assert service.calls == 1 + + goal_event = _event( + audience_id="goal:goal-alpha", + message_id="om_goal", + chat_id="oc_goal", + card=goal_card, + ) + goal_event["action_value"]["proposal_id"] = proposal["proposal_id"] + replay = handle_team_plan_review_callback( + goal_event, + action_service=service, + action_store_root=store.root, + profile_app_id="cli_public_fixture", + cli_bin="fake-lark", + profile="fixture", + ) + assert replay["ok"] is True + assert service.calls == 1 + durable = store.load(proposal["proposal_id"]) + assert durable is not None + assert durable["review_card"]["confirmation"]["event_id"] == "evt_manager" + assert set(durable["review_card"]["deliveries"]) == { + "manager", + "goal:goal-alpha", + } + assert all( + delivery.get("result", {}).get("card_digest") + for delivery in durable["review_card"]["deliveries"].values() + ) + + +def test_delivery_projects_one_proposal_to_manager_and_goal_audiences( + tmp_path: Path, monkeypatch: Any +) -> None: + import loopx.extensions.lark.team_plan_confirmation as confirmation + + store = ChatActionStore(tmp_path / "actions") + proposal = _proposal(store) + + def binding(*, manager: bool) -> dict[str, Any]: + goal_id = "manager-goal" if manager else "goal-alpha" + return { + "goal_id": goal_id, + "provider": "lark", + "enabled": True, + "connection_id": "manager-connection" if manager else "goal-connection", + "target_ref": "manager-target" if manager else "goal-target", + "routing": { + "conversation_kind": "manager" if manager else "goal", + }, + "channel": { + "chat_id": "oc_manager" if manager else "oc_goal", + }, + "identity": { + "mode": "project_bot", + "sender_profile": "manager-profile" if manager else "goal-profile", + "sender_identity": "bot", + "bot_app_id": "cli_manager" if manager else "cli_goal", + "bot_display_name": "Manager Bot" if manager else "Goal Bot", + "cli_bin": "manager-lark" if manager else "goal-lark", + }, + } + + def resolve_binding(**kwargs: Any): + selected = binding(manager=kwargs["manager_audience"]) + return selected, tmp_path / "binding.json", tmp_path / "targets.json" + + class DeliverySession: + def __init__(self, **kwargs: Any) -> None: + self.binding = kwargs["binding"] + self.route: Mapping[str, Any] | None = None + + def verify(self, route: Mapping[str, Any]) -> bool: + self.route = route + return True + + def send( + self, + _card: Mapping[str, Any], + _key: str, + route: Mapping[str, Any], + ) -> dict[str, Any]: + return { + "message_id": ( + "om_manager" if route["chat_id"] == "oc_manager" else "om_goal" + ), + "external_write_performed": True, + } + + def readback(self, message_id: str) -> dict[str, Any]: + assert self.route is not None + return { + "verified": True, + "message_id": message_id, + "chat_id": self.route["chat_id"], + "sender_app_id": self.route["bot_app_id"], + } + + monkeypatch.setattr(confirmation, "_resolved_binding", resolve_binding) + monkeypatch.setattr( + confirmation, "GoalChannelMessageDeliverySession", DeliverySession + ) + + receipt = deliver_team_plan_review_cards( + proposal_ids=[proposal["proposal_id"]], + manager_route={ + "goal_id": "manager-goal", + "connection_id": "manager-connection", + "source_sender_id": "ou_owner", + }, + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path, + action_store_root=store.root, + ) + + assert receipt == { + "schema_version": "lark_team_plan_review_delivery_v0", + "ok": True, + "status": "team_plan_review_cards_delivered", + "proposal_ids": [proposal["proposal_id"]], + "proposal_count": 1, + "audience_count": 2, + "readback_verified": True, + "external_write_count": 2, + } + durable = store.load(proposal["proposal_id"]) + assert durable is not None + assert durable["review_card"]["expected_audience_ids"] == [ + "goal:goal-alpha", + "manager", + ] + assert durable["review_card"]["deliveries"]["manager"]["cli_bin"] == ( + "manager-lark" + ) + assert durable["review_card"]["deliveries"]["goal:goal-alpha"][ + "sender_profile" + ] == "goal-profile" + + +def test_recovery_uses_the_first_durable_decision_not_a_later_click( + tmp_path: Path, monkeypatch: Any +) -> None: + import loopx.extensions.lark.team_plan_confirmation as confirmation + + store = ChatActionStore(tmp_path / "actions") + proposal = _proposal(store) + store.prepare_review_card_delivery( + proposal["proposal_id"], + audience_ids=["manager", "goal:goal-alpha"], + authorized_principal="lark:ou_owner", + ) + manager_card = _record_delivery( + store, + proposal, + audience_id="manager", + message_id="om_manager", + chat_id="oc_manager", + ) + _record_delivery( + store, + proposal, + audience_id="goal:goal-alpha", + message_id="om_goal", + chat_id="oc_goal", + ) + manager_delivery = store.load(proposal["proposal_id"])["review_card"][ + "deliveries" + ]["manager"] + store.decide_review_card( + proposal["proposal_id"], + decision="confirm", + confirmation={ + "provider": "lark", + "event_id": "evt_manager", + "principal": "lark:ou_owner", + "message_id": "om_manager", + "chat_id": "oc_manager", + "app_id": "cli_public_fixture", + "audience_id": "manager", + "state_fingerprint": "state-alpha", + "card_digest": manager_delivery["card_digest"], + "confirmed_at": "2026-09-20T00:00:00Z", + }, + ) + + monkeypatch.setattr( + confirmation, "_operator_membership_verified", lambda **_kwargs: True + ) + monkeypatch.setattr( + confirmation, + "_update_callback_card", + lambda **_kwargs: { + "external_write_performed": True, + "readback_verified": True, + }, + ) + monkeypatch.setattr( + confirmation, + "_patch_operation_result_card", + lambda **_kwargs: { + "external_write_performed": True, + "readback_verified": True, + }, + ) + + class ActionService: + calls = 0 + + def apply(self, proposal_id: str) -> dict[str, Any]: + self.calls += 1 + return { + "proposal": store.apply( + proposal_id, + current_state_fingerprint="state-alpha", + receipt={ + "receipt_id": "receipt-alpha", + "outcome": "team_plan_applied", + "projection_verified": True, + }, + ) + } + + later = _event( + audience_id="goal:goal-alpha", + message_id="om_goal", + chat_id="oc_goal", + card=manager_card, + ) + later["event_id"] = "evt_goal_after_confirm" + later["action_value"]["proposal_id"] = proposal["proposal_id"] + later["action_value"]["decision"] = "reject" + later["card_content"] = {"already": "patched"} + service = ActionService() + + result = handle_team_plan_review_callback( + later, + action_service=service, + action_store_root=store.root, + profile_app_id="cli_public_fixture", + cli_bin="fake-lark", + profile="fixture", + ) + + assert result["decision"] == "confirm" + assert result["proposal_status"] == "applied" + assert service.calls == 1 + assert store.load(proposal["proposal_id"])["review_card"]["confirmation"][ + "event_id" + ] == "evt_manager" diff --git a/tests/test_chat_team_plan_action.py b/tests/test_chat_team_plan_action.py index 50ad7c70ce..ad47d09216 100644 --- a/tests/test_chat_team_plan_action.py +++ b/tests/test_chat_team_plan_action.py @@ -2,13 +2,14 @@ from __future__ import annotations -import json +import hashlib import itertools +import json from pathlib import Path import pytest -from loopx.chat_action_store import ChatActionStore +from loopx.chat_action_store import ActionConflictError, ChatActionStore from loopx.chat_actions import ChatActionService GOAL_ID = "team-plan-action-fixture" @@ -108,6 +109,28 @@ def _todos(project: Path) -> str: ) +def _card_delivery(*, message_id: str, chat_id: str) -> dict: + card = {"schema": "2.0", "body": {"elements": []}} + digest = hashlib.sha256( + json.dumps( + card, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + ).hexdigest() + return { + "provider": "lark", + "message_id": message_id, + "chat_id": chat_id, + "app_id": "cli_public_fixture", + "cli_bin": "lark-cli-fixture", + "sender_profile": "fixture", + "binding_digest": "sha256:" + "b" * 64, + "card_digest": digest, + "submitted_card": card, + "delivered_at": "2026-09-20T00:00:00Z", + "authorized_principal": "lark:ou_owner", + } + + def _rewrite_objective(project: Path, objective: str) -> None: """Change only the intent the plan was reviewed against, not the registry.""" @@ -163,6 +186,143 @@ def test_a_confirmed_plan_creates_each_ready_lane_first_todo(tmp_path: Path) -> assert state.count("loopx:todo ") == 1 +def test_two_review_audiences_consume_one_team_plan_decision(tmp_path: Path) -> None: + project, _registry_path, service = _fixture(tmp_path) + preview = _preview(service) + proposal_id = preview["proposal_id"] + fingerprint = preview["expected_state_fingerprint"] + service.store.prepare_review_card_delivery( + proposal_id, + audience_ids=["manager", f"goal:{GOAL_ID}"], + authorized_principal="lark:ou_owner", + ) + service.store.record_review_card_delivery( + proposal_id, + audience_id="manager", + delivery=_card_delivery( + message_id="om_manager_card", chat_id="oc_manager" + ), + ) + service.store.record_review_card_delivery( + proposal_id, + audience_id=f"goal:{GOAL_ID}", + delivery=_card_delivery(message_id="om_goal_card", chat_id="oc_goal"), + ) + + decided = service.store.decide_review_card( + proposal_id, + decision="confirm", + confirmation={ + "provider": "lark", + "event_id": "evt_manager", + "principal": "lark:ou_owner", + "message_id": "om_manager_card", + "chat_id": "oc_manager", + "app_id": "cli_public_fixture", + "audience_id": "manager", + "state_fingerprint": fingerprint, + "card_digest": _card_delivery( + message_id="om_manager_card", chat_id="oc_manager" + )["card_digest"], + "confirmed_at": "2026-09-20T00:01:00Z", + }, + ) + assert decided["status"] == "applying" + + replay = service.store.decide_review_card( + proposal_id, + decision="confirm", + confirmation={ + "provider": "lark", + "event_id": "evt_goal", + "principal": "lark:ou_owner", + "message_id": "om_goal_card", + "chat_id": "oc_goal", + "app_id": "cli_public_fixture", + "audience_id": f"goal:{GOAL_ID}", + "state_fingerprint": fingerprint, + "card_digest": _card_delivery( + message_id="om_goal_card", chat_id="oc_goal" + )["card_digest"], + "confirmed_at": "2026-09-20T00:01:01Z", + }, + ) + assert replay["review_card"]["confirmation"]["event_id"] == "evt_manager" + + applied = service.apply(proposal_id)["proposal"] + assert applied["status"] == "applied" + assert _todos(project).count("loopx:todo ") == 1 + + +def test_team_plan_cannot_be_decided_before_every_audience_is_delivered( + tmp_path: Path, +) -> None: + _project, _registry_path, service = _fixture(tmp_path) + preview = _preview(service) + proposal_id = preview["proposal_id"] + service.store.prepare_review_card_delivery( + proposal_id, + audience_ids=["manager", f"goal:{GOAL_ID}"], + authorized_principal="lark:ou_owner", + ) + manager_delivery = _card_delivery( + message_id="om_manager_card", chat_id="oc_manager" + ) + service.store.record_review_card_delivery( + proposal_id, + audience_id="manager", + delivery=manager_delivery, + ) + + with pytest.raises( + ActionConflictError, match="audiences are not completely delivered" + ): + service.store.decide_review_card( + proposal_id, + decision="confirm", + confirmation={ + "provider": "lark", + "event_id": "evt_manager", + "principal": "lark:ou_owner", + "message_id": "om_manager_card", + "chat_id": "oc_manager", + "app_id": "cli_public_fixture", + "audience_id": "manager", + "state_fingerprint": preview["expected_state_fingerprint"], + "card_digest": manager_delivery["card_digest"], + "confirmed_at": "2026-09-20T00:01:00Z", + }, + ) + + +def test_review_card_delivery_retry_keeps_the_first_verified_receipt( + tmp_path: Path, +) -> None: + _project, _registry_path, service = _fixture(tmp_path) + preview = _preview(service) + proposal_id = preview["proposal_id"] + service.store.prepare_review_card_delivery( + proposal_id, + audience_ids=["manager", f"goal:{GOAL_ID}"], + authorized_principal="lark:ou_owner", + ) + first = _card_delivery(message_id="om_manager_card", chat_id="oc_manager") + service.store.record_review_card_delivery( + proposal_id, + audience_id="manager", + delivery=first, + ) + retry = {**first, "delivered_at": "2026-09-20T00:02:00Z"} + + replay = service.store.record_review_card_delivery( + proposal_id, + audience_id="manager", + delivery=retry, + ) + + assert replay["review_card"]["deliveries"]["manager"] == first + + def test_confirming_a_plan_that_staffs_no_lane_is_not_reported_as_success( tmp_path: Path, ) -> None: diff --git a/tests/test_steward_team_plan_preview.py b/tests/test_steward_team_plan_preview.py index 24369c9907..d2974cf347 100644 --- a/tests/test_steward_team_plan_preview.py +++ b/tests/test_steward_team_plan_preview.py @@ -462,9 +462,9 @@ def test_the_owner_channel_projects_an_admitted_preview_onto_the_action_surface( """Admission answers; the projection is what offers the card to confirm. The surfaces list typed actions, so a Turn proposal is invisible until the - channel hands it to the owner of that store. Only the owner's own local - channel is projected: a remote audience's confirmation surface is not this - store, so no card is written on its behalf. + channel hands it to the owner of that store. Local and remote manager + channels both project there; a remote audience renders the same proposal + through its provider surface instead of constructing a second action. """ from loopx.capabilities.manager_context.team_plan import ( @@ -509,15 +509,14 @@ def offer(session: dict, *, projector=None, turn_id: str = "turn-1") -> dict: assert "确认前不会创建任何 lane" in owner["message"] assert owner["proposals"] == response["proposals"] - # A remote manager audience has no card of its own, so none is written on its - # behalf -- but it still learns the exact Goal whose workspace holds one, - # instead of reading a plan it has no way to confirm. + # A remote manager audience projects the same admitted preview, so its Lark + # surface can bind the canonical proposal identity rather than rebuilding it. remote = offer( {"channel_id": "manager.external." + "a" * 24}, projector=projector, turn_id="turn-2", ) - assert projected == [preview] + assert projected == [preview, preview] assert remote["message"].startswith(answer) assert "authorized-goal" in remote["message"] From 42413fd6f95e8e4d6c7d5aef1525891734532f8f Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:36:03 +0800 Subject: [PATCH 3/5] refactor(lark): isolate topic runtime service Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/extensions/lark/goal_topic_runtime.py | 590 ++---------------- .../lark/goal_topic_runtime_service.py | 372 +++++++++++ .../extensions/lark/team_plan_confirmation.py | 279 ++++++++- 3 files changed, 698 insertions(+), 543 deletions(-) create mode 100644 loopx/extensions/lark/goal_topic_runtime_service.py diff --git a/loopx/extensions/lark/goal_topic_runtime.py b/loopx/extensions/lark/goal_topic_runtime.py index 33a99e1d44..30f9c47654 100644 --- a/loopx/extensions/lark/goal_topic_runtime.py +++ b/loopx/extensions/lark/goal_topic_runtime.py @@ -43,7 +43,6 @@ load_delivery as _load_manager_delivery, pending_delivery as _pending_manager_delivery, text_digest as _manager_delivery_text_digest, - validate_team_plan_delivery_receipt, write_delivery as _write_manager_delivery, ) from .manager_context import ( @@ -70,14 +69,17 @@ _delete_reaction, ensure_lark_event_inbox_received_reaction, ) +from .team_plan_confirmation import ( + proposal_ids_after_turn, + settle_team_plan_proposal_delivery, + start_team_plan_review_callback_stream, +) Answer = Callable[[Mapping[str, Any], str], str | Mapping[str, Any]] SnapshotProvider = Callable[[], Mapping[str, Any]] -ProfilePoller = Callable[[str, threading.Event], None] SimpleRunner = Callable[[list[str]], Mapping[str, Any]] ProcessFactory = Callable[[list[str]], Any] HealthSink = Callable[[Mapping[str, Any]], None] -ManagerRouteReconciler = Callable[[Mapping[str, Any]], Mapping[str, Any]] ProposalDeliverer = Callable[ [Mapping[str, Any], list[str]], Mapping[str, Any] ] @@ -112,7 +114,6 @@ def __init__(self, error_code: str, effect_receipt: Mapping[str, Any]) -> None: _EVENT_READY_PREFIX = "[event] ready " _EVENT_DIAGNOSTIC_PREFIX = "[event] " _EVENT_EXIT_REASON = re.compile(r"\(reason: (limit|timeout|signal)\)$") -_TEAM_PLAN_CALLBACK_SCHEMA = "loopx_team_plan_card_action_v0" def _active_profile_configs(snapshot: Mapping[str, Any]) -> dict[str, dict[str, str]]: @@ -148,43 +149,6 @@ def _active_profile_configs(snapshot: Mapping[str, Any]) -> dict[str, dict[str, return profiles -def _active_profile_chat_ids( - snapshot: Mapping[str, Any], profile: str -) -> list[str]: - binding_payloads = snapshot.get("binding_payloads") - binding_payloads = ( - binding_payloads if isinstance(binding_payloads, Mapping) else {} - ) - active_target_refs = { - str(binding.get("target_ref") or "") - for goal_id, payload in binding_payloads.items() - if isinstance(payload, Mapping) - for binding in bindings_for_goal(payload, str(goal_id)) - if binding.get("enabled") is True - } - targets = snapshot.get("target_payload") - targets = targets.get("targets") if isinstance(targets, Mapping) else None - if not isinstance(targets, Mapping): - return [] - chats: set[str] = set() - for target_ref, target in targets.items(): - if not isinstance(target, Mapping) or target.get("enabled") is not True: - continue - if str(target_ref) not in active_target_refs: - continue - identity = target.get("identity") - channel = target.get("channel") - if not isinstance(identity, Mapping) or not isinstance(channel, Mapping): - continue - chat_id = str(channel.get("chat_id") or "") - if ( - str(identity.get("sender_profile") or "") == profile - and re.fullmatch(r"oc_[A-Za-z0-9_-]+", chat_id) - ): - chats.add(chat_id) - return sorted(chats) - - def _default_simple_runner(args: list[str]) -> Mapping[str, Any]: try: completed = subprocess.run( @@ -460,79 +424,19 @@ def stream_lark_goal_topic_profile( _EVENT_PROJECTION, ] ) - callback_process = None - callback_thread: threading.Thread | None = None - callback_stop = threading.Event() callback_disconnected = threading.Event() - if review_callback_handler is not None: - chat_ids = _active_profile_chat_ids(snapshot, profile) - if chat_ids: - chat_filter = " or ".join( - f".chat_id == {json.dumps(chat_id)}" for chat_id in chat_ids - ) - callback_process = process_factory( - [ - cli_bin, - "--profile", - profile, - "event", - "consume", - "card.action.trigger", - "--as", - "bot", - "--timeout", - "30m", - "--max-events", - "0", - "--jq", - f"select({chat_filter})", - ] - ) - - def consume_review_callbacks() -> None: - stdout = callback_process.stdout - if stdout is None: - return - for callback_line in stdout: - if callback_stop.is_set() or stop.is_set(): - return - stripped = callback_line.strip() - if stripped.startswith(_EVENT_DIAGNOSTIC_PREFIX): - continue - try: - callback_event = json.loads(callback_line) - except json.JSONDecodeError: - continue - if not isinstance(callback_event, Mapping): - continue - raw_action = callback_event.get("action_value") - try: - callback_action = ( - json.loads(raw_action) - if isinstance(raw_action, str) - else raw_action - ) - except json.JSONDecodeError: - continue - if ( - not isinstance(callback_action, Mapping) - or callback_action.get("schema_version") - != _TEAM_PLAN_CALLBACK_SCHEMA - ): - continue - try: - review_callback_handler(callback_event) - except (OSError, RuntimeError, TypeError, ValueError): - logging.getLogger(__name__).warning( - "Lark manager review callback was rejected" - ) - - callback_thread = threading.Thread( - target=consume_review_callbacks, - name=f"loopx-lark-review-callback-{profile}", - daemon=True, - ) - callback_thread.start() + callback_stream = ( + start_team_plan_review_callback_stream( + snapshot=snapshot, + profile=profile, + cli_bin=cli_bin, + process_factory=process_factory, + parent_stop=stop, + handler=review_callback_handler, + ) + if review_callback_handler is not None + else None + ) if health_sink is not None: # A live child process is not proof that lark-cli registered a consumer # with its local event bus. Keep the connection non-ready until the @@ -544,14 +448,12 @@ def consume_review_callbacks() -> None: def stop_consumer() -> None: while not watcher_done.wait(1.0): if stop.is_set(): - for child in (process, callback_process): - if child is not None and child.poll() is None: - child.terminate() + if process.poll() is None: + process.terminate() + if callback_stream is not None: + callback_stream.terminate() return - if ( - callback_process is not None - and callback_process.poll() is not None - ): + if callback_stream is not None and callback_stream.disconnected(): callback_disconnected.set() if process.poll() is None: process.terminate() @@ -564,9 +466,10 @@ def stop_consumer() -> None: if not configured: configuration_removed.set() stop.set() - for child in (process, callback_process): - if child is not None and child.poll() is None: - child.terminate() + if process.poll() is None: + process.terminate() + if callback_stream is not None: + callback_stream.terminate() return watcher = threading.Thread( @@ -657,7 +560,6 @@ def stop_consumer() -> None: ) finally: watcher_done.set() - callback_stop.set() if process.poll() is None: process.terminate() try: @@ -666,16 +568,8 @@ def stop_consumer() -> None: process.kill() returncode = process.wait(timeout=3) watcher.join(timeout=1) - if callback_process is not None: - if callback_process.poll() is None: - callback_process.terminate() - try: - callback_process.wait(timeout=3) - except subprocess.TimeoutExpired: - callback_process.kill() - callback_process.wait(timeout=3) - if callback_thread is not None: - callback_thread.join(timeout=1) + if callback_stream is not None: + callback_stream.close() stopped = stop.is_set() # A bus can die after registering the consumer and tell the CLI to exit # successfully with reason=signal (e.g. a Feishu/Lark domain mismatch). @@ -720,369 +614,6 @@ def stop_consumer() -> None: } -class LarkGoalTopicRuntimeService: - """Own one event-consumer worker per reusable Lark App profile.""" - - def __init__( - self, - *, - snapshot_provider: SnapshotProvider, - runtime_root: str | Path, - runtime_controller: Any, - action_service: Any | None = None, - profile_poller: ProfilePoller | None = None, - manager_route_reconciler: ManagerRouteReconciler | None = None, - ) -> None: - self.snapshot_provider = snapshot_provider - self.runtime_root = Path(runtime_root).expanduser().resolve() - self.runtime_controller = runtime_controller - self.action_service = action_service - self._profile_poller = profile_poller or self._poll_profile - self.manager_route_reconciler = manager_route_reconciler - self._lock = threading.Lock() - self._workers: dict[str, tuple[threading.Event, threading.Thread]] = {} - self._health: dict[str, dict[str, Any]] = {} - self._closed = threading.Event() - self._startup_thread: threading.Thread | None = None - - def start(self) -> None: - """Discover existing bindings without blocking the HTTP readiness path.""" - - with self._lock: - if self._closed.is_set() or self._startup_thread is not None: - return - self._startup_thread = threading.Thread( - target=self._refresh_on_start, - name="loopx-lark-startup", - daemon=True, - ) - self._startup_thread.start() - - def _refresh_on_start(self) -> None: - while not self._closed.is_set(): - try: - self.refresh() - return - except Exception: - logging.getLogger(__name__).warning( - "Lark binding discovery failed; retrying in the background" - ) - self._closed.wait(5) - - @staticmethod - def _now() -> str: - return datetime.now(timezone.utc).isoformat() - - def _update_health(self, profile: str, **updates: Any) -> None: - with self._lock: - current = dict( - self._health.get( - profile, - { - "status": "starting", - "event_count": 0, - "replied_count": 0, - "last_event_status": None, - "error_code": None, - "restart_count": 0, - }, - ) - ) - current["event_count"] = int(current.get("event_count") or 0) + int( - updates.pop("event_count", 0) or 0 - ) - current["replied_count"] = int(current.get("replied_count") or 0) + int( - updates.pop("replied_count", 0) or 0 - ) - current.update(updates) - current["updated_at"] = self._now() - self._health[profile] = current - - def health_snapshot(self) -> dict[str, dict[str, Any]]: - """Return content-free listener health keyed by safe profile reference.""" - - with self._lock: - return {profile: dict(health) for profile, health in self._health.items()} - - def _poll_profile(self, profile: str, stop: threading.Event) -> None: - restart_count = 0 - try: - while not stop.is_set(): - self._update_health( - profile, - status="starting" if restart_count == 0 else "retrying", - error_code=None, - restart_count=restart_count, - ) - try: - - def answer( - route: Mapping[str, Any], text: str - ) -> Mapping[str, Any]: - effective_route = route - if ( - route.get("conversation_kind") == "manager" - and self.manager_route_reconciler is not None - ): - try: - effective_route = self.manager_route_reconciler(route) - except Exception as exc: - raise LarkGoalTopicTurnFailed( - "manager_channel_route_reconcile_failed", - _session_turn_effect(route), - ) from exc - snapshot = self.snapshot_provider() - contexts = snapshot.get("goal_contexts") - contexts = contexts if isinstance(contexts, Mapping) else {} - context = contexts.get( - str(effective_route.get("goal_id") or "") - ) - context = context if isinstance(context, Mapping) else {} - answer_result = answer_lark_goal_topic( - route=effective_route, - text=text, - work_dir=str(context.get("work_dir") or self.runtime_root), - objective=str( - context.get("objective") - or effective_route.get("goal_id") - or "" - ), - runtime_controller=self.runtime_controller, - ) - if isinstance(answer_result, Mapping): - response_text = str( - answer_result.get("response_text") or "" - ) - proposal_ids = list( - answer_result.get("proposal_ids") or [] - ) - else: - response_text = answer_result - proposal_ids = [] - return { - "response_text": response_text, - "effect_receipt": _session_turn_effect(effective_route), - "proposal_ids": proposal_ids, - } - - def deliver_proposals( - route: Mapping[str, Any], proposal_ids: list[str] - ) -> Mapping[str, Any]: - from .team_plan_confirmation import ( - deliver_team_plan_review_cards, - ) - - registry_path = getattr( - self.runtime_controller, "registry_path", None - ) - if not isinstance(registry_path, Path): - raise ValueError( - "Lark manager proposal delivery requires the active registry" - ) - return deliver_team_plan_review_cards( - proposal_ids=proposal_ids, - manager_route=route, - registry_path=registry_path, - runtime_root=self.runtime_root, - action_store_root=self.runtime_root - / "chat" - / "actions", - ) - - def handle_review_callback( - event: Mapping[str, Any], - ) -> Mapping[str, Any]: - if self.action_service is None: - raise ValueError( - "Lark manager review callbacks require Chat actions" - ) - from .team_plan_confirmation import ( - handle_lark_review_callback, - ) - - profile_config = _active_profile_configs( - self.snapshot_provider() - ).get(profile) - if not isinstance(profile_config, Mapping): - raise ValueError("Lark manager profile is unavailable") - return handle_lark_review_callback( - event, - action_service=self.action_service, - action_store_root=self.runtime_root - / "chat" - / "actions", - profile_app_id=str( - profile_config.get("bot_app_id") or "" - ), - cli_bin=str(profile_config.get("cli_bin") or "lark-cli"), - profile=profile, - ) - - result = stream_lark_goal_topic_profile( - profile=profile, - snapshot_provider=self.snapshot_provider, - stop=stop, - runtime_root=self.runtime_root, - answer=answer, - proposal_deliverer=deliver_proposals, - review_callback_handler=( - handle_review_callback - if self.action_service is not None - else None - ), - health_sink=lambda update: self._update_health( - profile, **dict(update) - ), - ) - if result.get("status") == "configuration_removed": - self._update_health( - profile, - status="inactive", - error_code="lark_route_configuration_removed", - restart_count=restart_count, - ) - break - if stop.is_set(): - break - restart_count += 1 - self._update_health( - profile, - status="retrying", - error_code=( - None - if result.get("ok") is True - else str( - result.get("error_code") - or "lark_event_listener_failed" - ) - ), - restart_count=restart_count, - ) - except Exception: - restart_count += 1 - self._update_health( - profile, - status="retrying", - error_code="lark_event_listener_failed", - restart_count=restart_count, - ) - stop.wait(min(5.0, 0.25 * (2 ** min(restart_count, 4)))) - if ( - not self._closed.is_set() - and self._health.get(profile, {}).get("status") != "inactive" - ): - self._update_health(profile, status="stopped", error_code=None) - finally: - current_thread = threading.current_thread() - with self._lock: - worker = self._workers.get(profile) - if worker is not None and worker[1] is current_thread: - self._workers.pop(profile, None) - if not self._closed.is_set(): - try: - reconfigured = profile in _active_profile_configs( - self.snapshot_provider() - ) - except Exception: - reconfigured = False - if reconfigured: - self.refresh() - - def refresh(self) -> None: - if self._closed.is_set(): - return - snapshot = self.snapshot_provider() - desired = set(_active_profile_configs(snapshot)) - if self._closed.is_set(): - return - self._resume_session_queues(snapshot) - # A filesystem read may outlive server shutdown (for example, while - # waiting for OS directory consent). Never start effects after close. - with self._lock: - if self._closed.is_set(): - return - stale = set(self._workers) - desired - missing = desired - set(self._workers) - for profile in stale: - stop, _thread = self._workers.pop(profile) - stop.set() - for profile in sorted(missing): - stop = threading.Event() - self._health[profile] = { - "status": "starting", - "event_count": 0, - "replied_count": 0, - "last_event_status": None, - "error_code": None, - "restart_count": 0, - "updated_at": self._now(), - } - thread = threading.Thread( - target=self._profile_poller, - args=(profile, stop), - name=f"loopx-lark-{profile}", - daemon=True, - ) - self._workers[profile] = (stop, thread) - thread.start() - - def _resume_session_queues(self, snapshot: Mapping[str, Any]) -> None: - binding_payloads = snapshot.get("binding_payloads") - contexts = snapshot.get("goal_contexts") - if isinstance(binding_payloads, Mapping) and isinstance(contexts, Mapping): - for goal_id, payload in binding_payloads.items(): - if not isinstance(payload, Mapping): - continue - for binding in bindings_for_goal(payload, str(goal_id)): - if self._closed.is_set(): - return - raw_routing = binding.get("routing") - routing: Mapping[str, Any] = ( - raw_routing if isinstance(raw_routing, Mapping) else {} - ) - if routing.get("ingress_mode") != "session_queue": - continue - context = contexts.get(str(goal_id)) - context = context if isinstance(context, Mapping) else {} - session_id = str(binding.get("session_id") or "") - work_dir = str(context.get("work_dir") or "") - try: - has_queued_turns = bool( - session_id - and self.runtime_controller.store.queued_turns(session_id) - ) - except KeyError: - has_queued_turns = False - if has_queued_turns and work_dir: - resolved_work_dir = Path(work_dir).expanduser().resolve() - # Discovery is slow I/O; only cancellation and the - # controller's I/O-free worker admission belong here. - with self._lock: - if self._closed.is_set(): - return - self.runtime_controller.resume_session_queue( - session_id=session_id, - work_dir=resolved_work_dir, - objective=MANAGER_AGENT_OBJECTIVE - if routing.get("conversation_kind") == "manager" - else str(context.get("objective") or goal_id), - ) - - def active_profiles(self) -> list[str]: - with self._lock: - return sorted(self._workers) - - def close(self) -> None: - self._closed.set() - with self._lock: - workers = list(self._workers.values()) - self._workers.clear() - for stop, _thread in workers: - stop.set() - for _stop, thread in workers: - thread.join(timeout=3) - - def answer_lark_goal_topic( *, route: Mapping[str, Any], @@ -1213,20 +744,11 @@ def answer_lark_goal_topic( raise RuntimeError("Lark Goal Topic turn returned no message") if not manager: return reply_text - proposal_ids: list[str] = [] - events_after = getattr(runtime_controller.store, "events_after", None) - if callable(events_after): - for event in events_after(session_id, str(turn["turn_id"]), None): - if not isinstance(event, Mapping) or event.get("kind") != "team_plan.projected": - continue - payload = event.get("payload") - proposal_id = ( - str(payload.get("proposal_id") or "") - if isinstance(payload, Mapping) - else "" - ) - if proposal_id and proposal_id not in proposal_ids: - proposal_ids.append(proposal_id) + proposal_ids = proposal_ids_after_turn( + runtime_controller, + session_id=session_id, + turn_id=str(turn["turn_id"]), + ) if proposal_ids: return { "response_text": reply_text, @@ -1801,42 +1323,21 @@ def process_lark_goal_topic_event( "inbox_config_ref": config_ref, "source_acknowledged": False, } - if proposal_ids and proposal_deliverer is None: + proposal_delivery_status = settle_team_plan_proposal_delivery( + delivery_state=delivery_state, + delivery_path=delivery_path, + route=route, + proposal_ids=proposal_ids, + proposal_deliverer=proposal_deliverer, + ) + if proposal_delivery_status is not None: return { "ok": False, - "status": "proposal_delivery_unavailable", + "status": proposal_delivery_status, "goal_id": route["goal_id"], "inbox_config_ref": config_ref, "source_acknowledged": False, } - if proposal_ids and proposal_deliverer is not None: - existing_proposal_delivery = delivery_state.get("proposal_delivery") - if not isinstance(existing_proposal_delivery, Mapping): - try: - proposal_delivery = validate_team_plan_delivery_receipt( - proposal_deliverer(route, proposal_ids), - proposal_ids=proposal_ids, - ) - except (OSError, ValueError, TypeError, KeyError): - return { - "ok": False, - "status": "proposal_delivery_pending", - "goal_id": route["goal_id"], - "inbox_config_ref": config_ref, - "source_acknowledged": False, - } - delivery_state["proposal_delivery"] = proposal_delivery - delivery_state["updated_at"] = datetime.now(timezone.utc).isoformat() - try: - _write_manager_delivery(delivery_path, delivery_state) - except OSError: - return { - "ok": False, - "status": "proposal_delivery_receipt_unavailable", - "goal_id": route["goal_id"], - "inbox_config_ref": config_ref, - "source_acknowledged": False, - } if connector is not None: ack_decision = decide_external_event_ack( event_id=canonical["event_id"], @@ -1921,3 +1422,10 @@ def process_lark_goal_topic_event( "goal_id": route["goal_id"], "inbox_config_ref": config_ref, } + + +# Preserve the established import path while keeping worker lifecycle out of +# the already hot message-processing module. +from .goal_topic_runtime_service import ( # noqa: E402,F401 + LarkGoalTopicRuntimeService, +) diff --git a/loopx/extensions/lark/goal_topic_runtime_service.py b/loopx/extensions/lark/goal_topic_runtime_service.py new file mode 100644 index 0000000000..4aa871563d --- /dev/null +++ b/loopx/extensions/lark/goal_topic_runtime_service.py @@ -0,0 +1,372 @@ +"""Worker lifecycle for the Lark Goal Topic runtime.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from datetime import datetime, timezone +import logging +from pathlib import Path +import threading +from typing import Any + +from ...chat_manager import MANAGER_AGENT_OBJECTIVE +from .goal_channel_contracts import bindings_for_goal +from .manager_context import session_turn_effect +from .team_plan_confirmation import ( + deliver_team_plan_review_cards_from_runtime, + handle_lark_review_callback_for_profile, +) + + +SnapshotProvider = Callable[[], Mapping[str, Any]] +ProfilePoller = Callable[[str, threading.Event], None] +ManagerRouteReconciler = Callable[[Mapping[str, Any]], Mapping[str, Any]] + + +class LarkGoalTopicRuntimeService: + """Own one event-consumer worker per reusable Lark App profile.""" + + def __init__( + self, + *, + snapshot_provider: SnapshotProvider, + runtime_root: str | Path, + runtime_controller: Any, + action_service: Any | None = None, + profile_poller: ProfilePoller | None = None, + manager_route_reconciler: ManagerRouteReconciler | None = None, + ) -> None: + self.snapshot_provider = snapshot_provider + self.runtime_root = Path(runtime_root).expanduser().resolve() + self.runtime_controller = runtime_controller + self.action_service = action_service + self._profile_poller = profile_poller or self._poll_profile + self.manager_route_reconciler = manager_route_reconciler + self._lock = threading.Lock() + self._workers: dict[str, tuple[threading.Event, threading.Thread]] = {} + self._health: dict[str, dict[str, Any]] = {} + self._closed = threading.Event() + self._startup_thread: threading.Thread | None = None + + def start(self) -> None: + """Discover existing bindings without blocking the HTTP readiness path.""" + + with self._lock: + if self._closed.is_set() or self._startup_thread is not None: + return + self._startup_thread = threading.Thread( + target=self._refresh_on_start, + name="loopx-lark-startup", + daemon=True, + ) + self._startup_thread.start() + + def _refresh_on_start(self) -> None: + while not self._closed.is_set(): + try: + self.refresh() + return + except Exception: + logging.getLogger(__name__).warning( + "Lark binding discovery failed; retrying in the background" + ) + self._closed.wait(5) + + @staticmethod + def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + def _update_health(self, profile: str, **updates: Any) -> None: + with self._lock: + current = dict( + self._health.get( + profile, + { + "status": "starting", + "event_count": 0, + "replied_count": 0, + "last_event_status": None, + "error_code": None, + "restart_count": 0, + }, + ) + ) + current["event_count"] = int(current.get("event_count") or 0) + int( + updates.pop("event_count", 0) or 0 + ) + current["replied_count"] = int(current.get("replied_count") or 0) + int( + updates.pop("replied_count", 0) or 0 + ) + current.update(updates) + current["updated_at"] = self._now() + self._health[profile] = current + + def health_snapshot(self) -> dict[str, dict[str, Any]]: + """Return content-free listener health keyed by safe profile reference.""" + + with self._lock: + return {profile: dict(health) for profile, health in self._health.items()} + + def _poll_profile(self, profile: str, stop: threading.Event) -> None: + # Resolve through the compatibility module at call time so existing + # integrations that patch its entrypoints keep working during the move. + from . import goal_topic_runtime as runtime + + restart_count = 0 + try: + while not stop.is_set(): + self._update_health( + profile, + status="starting" if restart_count == 0 else "retrying", + error_code=None, + restart_count=restart_count, + ) + try: + + def answer( + route: Mapping[str, Any], text: str + ) -> Mapping[str, Any]: + effective_route = route + if ( + route.get("conversation_kind") == "manager" + and self.manager_route_reconciler is not None + ): + try: + effective_route = self.manager_route_reconciler(route) + except Exception as exc: + raise runtime.LarkGoalTopicTurnFailed( + "manager_channel_route_reconcile_failed", + session_turn_effect(route), + ) from exc + snapshot = self.snapshot_provider() + contexts = snapshot.get("goal_contexts") + contexts = contexts if isinstance(contexts, Mapping) else {} + context = contexts.get( + str(effective_route.get("goal_id") or "") + ) + context = context if isinstance(context, Mapping) else {} + answer_result = runtime.answer_lark_goal_topic( + route=effective_route, + text=text, + work_dir=str(context.get("work_dir") or self.runtime_root), + objective=str( + context.get("objective") + or effective_route.get("goal_id") + or "" + ), + runtime_controller=self.runtime_controller, + ) + if isinstance(answer_result, Mapping): + response_text = str( + answer_result.get("response_text") or "" + ) + proposal_ids = list( + answer_result.get("proposal_ids") or [] + ) + else: + response_text = answer_result + proposal_ids = [] + return { + "response_text": response_text, + "effect_receipt": session_turn_effect(effective_route), + "proposal_ids": proposal_ids, + } + + def deliver_proposals( + route: Mapping[str, Any], proposal_ids: list[str] + ) -> Mapping[str, Any]: + return deliver_team_plan_review_cards_from_runtime( + proposal_ids=proposal_ids, + manager_route=route, + runtime_controller=self.runtime_controller, + runtime_root=self.runtime_root, + ) + + def handle_review_callback( + event: Mapping[str, Any], + ) -> Mapping[str, Any]: + if self.action_service is None: + raise ValueError( + "Lark manager review callbacks require Chat actions" + ) + profile_config = runtime._active_profile_configs( + self.snapshot_provider() + ).get(profile) + return handle_lark_review_callback_for_profile( + event, + action_service=self.action_service, + action_store_root=self.runtime_root + / "chat" + / "actions", + profile=profile, + profile_config=profile_config, + ) + + result = runtime.stream_lark_goal_topic_profile( + profile=profile, + snapshot_provider=self.snapshot_provider, + stop=stop, + runtime_root=self.runtime_root, + answer=answer, + proposal_deliverer=deliver_proposals, + review_callback_handler=( + handle_review_callback + if self.action_service is not None + else None + ), + health_sink=lambda update: self._update_health( + profile, **dict(update) + ), + ) + if result.get("status") == "configuration_removed": + self._update_health( + profile, + status="inactive", + error_code="lark_route_configuration_removed", + restart_count=restart_count, + ) + break + if stop.is_set(): + break + restart_count += 1 + self._update_health( + profile, + status="retrying", + error_code=( + None + if result.get("ok") is True + else str( + result.get("error_code") + or "lark_event_listener_failed" + ) + ), + restart_count=restart_count, + ) + except Exception: + restart_count += 1 + self._update_health( + profile, + status="retrying", + error_code="lark_event_listener_failed", + restart_count=restart_count, + ) + stop.wait(min(5.0, 0.25 * (2 ** min(restart_count, 4)))) + if ( + not self._closed.is_set() + and self._health.get(profile, {}).get("status") != "inactive" + ): + self._update_health(profile, status="stopped", error_code=None) + finally: + current_thread = threading.current_thread() + with self._lock: + worker = self._workers.get(profile) + if worker is not None and worker[1] is current_thread: + self._workers.pop(profile, None) + if not self._closed.is_set(): + try: + reconfigured = profile in runtime._active_profile_configs( + self.snapshot_provider() + ) + except Exception: + reconfigured = False + if reconfigured: + self.refresh() + + def refresh(self) -> None: + from . import goal_topic_runtime as runtime + + if self._closed.is_set(): + return + snapshot = self.snapshot_provider() + desired = set(runtime._active_profile_configs(snapshot)) + if self._closed.is_set(): + return + self._resume_session_queues(snapshot) + # A filesystem read may outlive server shutdown (for example, while + # waiting for OS directory consent). Never start effects after close. + with self._lock: + if self._closed.is_set(): + return + stale = set(self._workers) - desired + missing = desired - set(self._workers) + for profile in stale: + stop, _thread = self._workers.pop(profile) + stop.set() + for profile in sorted(missing): + stop = threading.Event() + self._health[profile] = { + "status": "starting", + "event_count": 0, + "replied_count": 0, + "last_event_status": None, + "error_code": None, + "restart_count": 0, + "updated_at": self._now(), + } + thread = threading.Thread( + target=self._profile_poller, + args=(profile, stop), + name=f"loopx-lark-{profile}", + daemon=True, + ) + self._workers[profile] = (stop, thread) + thread.start() + + def _resume_session_queues(self, snapshot: Mapping[str, Any]) -> None: + binding_payloads = snapshot.get("binding_payloads") + contexts = snapshot.get("goal_contexts") + if isinstance(binding_payloads, Mapping) and isinstance(contexts, Mapping): + for goal_id, payload in binding_payloads.items(): + if not isinstance(payload, Mapping): + continue + for binding in bindings_for_goal(payload, str(goal_id)): + if self._closed.is_set(): + return + raw_routing = binding.get("routing") + routing: Mapping[str, Any] = ( + raw_routing if isinstance(raw_routing, Mapping) else {} + ) + if routing.get("ingress_mode") != "session_queue": + continue + context = contexts.get(str(goal_id)) + context = context if isinstance(context, Mapping) else {} + session_id = str(binding.get("session_id") or "") + work_dir = str(context.get("work_dir") or "") + try: + has_queued_turns = bool( + session_id + and self.runtime_controller.store.queued_turns(session_id) + ) + except KeyError: + has_queued_turns = False + if has_queued_turns and work_dir: + resolved_work_dir = Path(work_dir).expanduser().resolve() + # Discovery is slow I/O; only cancellation and the + # controller's I/O-free worker admission belong here. + with self._lock: + if self._closed.is_set(): + return + self.runtime_controller.resume_session_queue( + session_id=session_id, + work_dir=resolved_work_dir, + objective=MANAGER_AGENT_OBJECTIVE + if routing.get("conversation_kind") == "manager" + else str(context.get("objective") or goal_id), + ) + + def active_profiles(self) -> list[str]: + with self._lock: + return sorted(self._workers) + + def close(self) -> None: + self._closed.set() + with self._lock: + workers = list(self._workers.values()) + self._workers.clear() + for stop, _thread in workers: + stop.set() + for _stop, thread in workers: + thread.join(timeout=3) + + +__all__ = ["LarkGoalTopicRuntimeService"] diff --git a/loopx/extensions/lark/team_plan_confirmation.py b/loopx/extensions/lark/team_plan_confirmation.py index 89caceba6a..951030118e 100644 --- a/loopx/extensions/lark/team_plan_confirmation.py +++ b/loopx/extensions/lark/team_plan_confirmation.py @@ -5,8 +5,11 @@ from datetime import datetime, timezone import hashlib import json +import logging import re -from collections.abc import Mapping, Sequence +import subprocess +import threading +from collections.abc import Callable, Mapping, Sequence from pathlib import Path from typing import Any @@ -43,7 +46,11 @@ goal_channel_target_for_name, read_goal_channel_targets, ) -from .manager_reply_delivery import TEAM_PLAN_DELIVERY_RECEIPT_SCHEMA_VERSION +from .manager_reply_delivery import ( + TEAM_PLAN_DELIVERY_RECEIPT_SCHEMA_VERSION, + validate_team_plan_delivery_receipt, + write_delivery as write_manager_delivery, +) from .presentation.kanban import CommandRunner, default_subprocess_runner from .presentation.team_plan import ( TEAM_PLAN_CARD_ACTION_SCHEMA_VERSION, @@ -57,6 +64,274 @@ _MESSAGE_ID = re.compile(r"^om_[A-Za-z0-9_-]+$") _CHAT_ID = re.compile(r"^oc_[A-Za-z0-9_-]+$") _OPEN_ID = re.compile(r"^ou_[A-Za-z0-9_-]+$") +_EVENT_DIAGNOSTIC_PREFIX = "[event] " + +ProcessFactory = Callable[[list[str]], Any] +ReviewCallbackHandler = Callable[[Mapping[str, Any]], Mapping[str, Any]] + + +class TeamPlanReviewCallbackStream: + """Own the companion card-callback consumer for one Lark profile.""" + + def __init__( + self, + *, + process: Any, + thread: threading.Thread, + stop: threading.Event, + ) -> None: + self.process = process + self.thread = thread + self.stop = stop + + def disconnected(self) -> bool: + return self.process.poll() is not None + + def terminate(self) -> None: + self.stop.set() + if self.process.poll() is None: + self.process.terminate() + + def close(self) -> None: + self.terminate() + try: + self.process.wait(timeout=3) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=3) + self.thread.join(timeout=1) + + +def active_profile_chat_ids( + snapshot: Mapping[str, Any], profile: str +) -> list[str]: + """Return active Goal-channel chats owned by one sender profile.""" + + binding_payloads = snapshot.get("binding_payloads") + binding_payloads = ( + binding_payloads if isinstance(binding_payloads, Mapping) else {} + ) + active_target_refs = { + str(binding.get("target_ref") or "") + for goal_id, payload in binding_payloads.items() + if isinstance(payload, Mapping) + for binding in bindings_for_goal(payload, str(goal_id)) + if binding.get("enabled") is True + } + targets = snapshot.get("target_payload") + targets = targets.get("targets") if isinstance(targets, Mapping) else None + if not isinstance(targets, Mapping): + return [] + chats: set[str] = set() + for target_ref, target in targets.items(): + if not isinstance(target, Mapping) or target.get("enabled") is not True: + continue + if str(target_ref) not in active_target_refs: + continue + identity = target.get("identity") + channel = target.get("channel") + if not isinstance(identity, Mapping) or not isinstance(channel, Mapping): + continue + chat_id = str(channel.get("chat_id") or "") + if ( + str(identity.get("sender_profile") or "") == profile + and _CHAT_ID.fullmatch(chat_id) + ): + chats.add(chat_id) + return sorted(chats) + + +def start_team_plan_review_callback_stream( + *, + snapshot: Mapping[str, Any], + profile: str, + cli_bin: str, + process_factory: ProcessFactory, + parent_stop: threading.Event, + handler: ReviewCallbackHandler, +) -> TeamPlanReviewCallbackStream | None: + """Start the filtered callback consumer paired with a message stream.""" + + chat_ids = active_profile_chat_ids(snapshot, profile) + if not chat_ids: + return None + chat_filter = " or ".join( + f".chat_id == {json.dumps(chat_id)}" for chat_id in chat_ids + ) + process = process_factory( + [ + cli_bin, + "--profile", + profile, + "event", + "consume", + "card.action.trigger", + "--as", + "bot", + "--timeout", + "30m", + "--max-events", + "0", + "--jq", + f"select({chat_filter})", + ] + ) + local_stop = threading.Event() + + def consume() -> None: + stdout = process.stdout + if stdout is None: + return + for callback_line in stdout: + if local_stop.is_set() or parent_stop.is_set(): + return + stripped = callback_line.strip() + if stripped.startswith(_EVENT_DIAGNOSTIC_PREFIX): + continue + try: + callback_event = json.loads(callback_line) + except json.JSONDecodeError: + continue + if not isinstance(callback_event, Mapping): + continue + raw_action = callback_event.get("action_value") + try: + callback_action = ( + json.loads(raw_action) + if isinstance(raw_action, str) + else raw_action + ) + except json.JSONDecodeError: + continue + if ( + not isinstance(callback_action, Mapping) + or callback_action.get("schema_version") + != TEAM_PLAN_CARD_ACTION_SCHEMA_VERSION + ): + continue + try: + handler(callback_event) + except (OSError, RuntimeError, TypeError, ValueError): + logging.getLogger(__name__).warning( + "Lark manager review callback was rejected" + ) + + thread = threading.Thread( + target=consume, + name=f"loopx-lark-review-callback-{profile}", + daemon=True, + ) + thread.start() + return TeamPlanReviewCallbackStream( + process=process, + thread=thread, + stop=local_stop, + ) + + +def proposal_ids_after_turn( + runtime_controller: Any, *, session_id: str, turn_id: str +) -> list[str]: + """Project canonical team-plan proposal ids emitted by one manager turn.""" + + events_after = getattr(runtime_controller.store, "events_after", None) + if not callable(events_after): + return [] + proposal_ids: list[str] = [] + for event in events_after(session_id, turn_id, None): + if ( + not isinstance(event, Mapping) + or event.get("kind") != "team_plan.projected" + ): + continue + payload = event.get("payload") + proposal_id = ( + str(payload.get("proposal_id") or "") + if isinstance(payload, Mapping) + else "" + ) + if proposal_id and proposal_id not in proposal_ids: + proposal_ids.append(proposal_id) + return proposal_ids + + +def deliver_team_plan_review_cards_from_runtime( + *, + proposal_ids: Sequence[str], + manager_route: Mapping[str, Any], + runtime_controller: Any, + runtime_root: Path, +) -> dict[str, Any]: + """Resolve the active registry before delivering canonical proposal cards.""" + + registry_path = getattr(runtime_controller, "registry_path", None) + if not isinstance(registry_path, Path): + raise ValueError("Lark manager proposal delivery requires the active registry") + return deliver_team_plan_review_cards( + proposal_ids=proposal_ids, + manager_route=manager_route, + registry_path=registry_path, + runtime_root=runtime_root, + action_store_root=runtime_root / "chat" / "actions", + ) + + +def handle_lark_review_callback_for_profile( + event: Mapping[str, Any], + *, + action_service: Any, + action_store_root: Path, + profile: str, + profile_config: Mapping[str, Any] | None, +) -> dict[str, Any]: + """Bind a callback to the exact configured sender identity.""" + + if not isinstance(profile_config, Mapping): + raise ValueError("Lark manager profile is unavailable") + return handle_lark_review_callback( + event, + action_service=action_service, + action_store_root=action_store_root, + profile_app_id=str(profile_config.get("bot_app_id") or ""), + cli_bin=str(profile_config.get("cli_bin") or "lark-cli"), + profile=profile, + ) + + +def settle_team_plan_proposal_delivery( + *, + delivery_state: dict[str, Any], + delivery_path: Path, + route: Mapping[str, Any], + proposal_ids: Sequence[str], + proposal_deliverer: Callable[ + [Mapping[str, Any], list[str]], Mapping[str, Any] + ] + | None, +) -> str | None: + """Persist one verified dual-audience delivery or return its retry status.""" + + normalized_ids = [str(value) for value in proposal_ids] + if not normalized_ids: + return None + if proposal_deliverer is None: + return "proposal_delivery_unavailable" + if isinstance(delivery_state.get("proposal_delivery"), Mapping): + return None + try: + receipt = validate_team_plan_delivery_receipt( + proposal_deliverer(route, normalized_ids), + proposal_ids=normalized_ids, + ) + except (OSError, ValueError, TypeError, KeyError): + return "proposal_delivery_pending" + delivery_state["proposal_delivery"] = receipt + delivery_state["updated_at"] = datetime.now(timezone.utc).isoformat() + try: + write_manager_delivery(delivery_path, delivery_state) + except OSError: + return "proposal_delivery_receipt_unavailable" + return None def _digest(value: object) -> str: From 34ea320d68e9a6ed38e908638a1abee261ce7cae Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:26:52 +0800 Subject: [PATCH 4/5] fix(lark): reconcile callback subscription chats Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../lark/goal_topic_runtime_service.py | 56 ++++++-- .../test_lark_goal_topic_runtime.py | 125 +++++++++++++++++- 2 files changed, 171 insertions(+), 10 deletions(-) diff --git a/loopx/extensions/lark/goal_topic_runtime_service.py b/loopx/extensions/lark/goal_topic_runtime_service.py index 4aa871563d..af67a78095 100644 --- a/loopx/extensions/lark/goal_topic_runtime_service.py +++ b/loopx/extensions/lark/goal_topic_runtime_service.py @@ -13,6 +13,7 @@ from .goal_channel_contracts import bindings_for_goal from .manager_context import session_turn_effect from .team_plan_confirmation import ( + active_profile_chat_ids, deliver_team_plan_review_cards_from_runtime, handle_lark_review_callback_for_profile, ) @@ -21,6 +22,8 @@ SnapshotProvider = Callable[[], Mapping[str, Any]] ProfilePoller = Callable[[str, threading.Event], None] ManagerRouteReconciler = Callable[[Mapping[str, Any]], Mapping[str, Any]] +ProfileWorkerFingerprint = tuple[str, str, tuple[str, ...]] +ProfileWorker = tuple[threading.Event, threading.Thread, ProfileWorkerFingerprint] class LarkGoalTopicRuntimeService: @@ -43,7 +46,7 @@ def __init__( self._profile_poller = profile_poller or self._poll_profile self.manager_route_reconciler = manager_route_reconciler self._lock = threading.Lock() - self._workers: dict[str, tuple[threading.Event, threading.Thread]] = {} + self._workers: dict[str, ProfileWorker] = {} self._health: dict[str, dict[str, Any]] = {} self._closed = threading.Event() self._startup_thread: threading.Thread | None = None @@ -107,6 +110,23 @@ def health_snapshot(self) -> dict[str, dict[str, Any]]: with self._lock: return {profile: dict(health) for profile, health in self._health.items()} + def _profile_worker_fingerprint( + self, + snapshot: Mapping[str, Any], + profile: str, + profile_config: Mapping[str, str], + ) -> ProfileWorkerFingerprint: + callback_chats = ( + tuple(active_profile_chat_ids(snapshot, profile)) + if self.action_service is not None + else () + ) + return ( + str(profile_config.get("cli_bin") or "lark-cli"), + str(profile_config.get("bot_app_id") or ""), + callback_chats, + ) + def _poll_profile(self, profile: str, stop: threading.Event) -> None: # Resolve through the compatibility module at call time so existing # integrations that patch its entrypoints keep working during the move. @@ -251,8 +271,16 @@ def handle_review_callback( restart_count=restart_count, ) stop.wait(min(5.0, 0.25 * (2 ** min(restart_count, 4)))) + current_thread = threading.current_thread() + with self._lock: + current_worker = self._workers.get(profile) + replaced_by_new_worker = ( + current_worker is not None + and current_worker[1] is not current_thread + ) if ( not self._closed.is_set() + and not replaced_by_new_worker and self._health.get(profile, {}).get("status") != "inactive" ): self._update_health(profile, status="stopped", error_code=None) @@ -278,7 +306,15 @@ def refresh(self) -> None: if self._closed.is_set(): return snapshot = self.snapshot_provider() - desired = set(runtime._active_profile_configs(snapshot)) + profile_configs = runtime._active_profile_configs(snapshot) + desired = { + profile: self._profile_worker_fingerprint( + snapshot, + profile, + profile_config, + ) + for profile, profile_config in profile_configs.items() + } if self._closed.is_set(): return self._resume_session_queues(snapshot) @@ -287,11 +323,15 @@ def refresh(self) -> None: with self._lock: if self._closed.is_set(): return - stale = set(self._workers) - desired - missing = desired - set(self._workers) + stale = (set(self._workers) - set(desired)) | { + profile + for profile in set(self._workers) & set(desired) + if self._workers[profile][2] != desired[profile] + } for profile in stale: - stop, _thread = self._workers.pop(profile) + stop, _thread, _fingerprint = self._workers.pop(profile) stop.set() + missing = set(desired) - set(self._workers) for profile in sorted(missing): stop = threading.Event() self._health[profile] = { @@ -309,7 +349,7 @@ def refresh(self) -> None: name=f"loopx-lark-{profile}", daemon=True, ) - self._workers[profile] = (stop, thread) + self._workers[profile] = (stop, thread, desired[profile]) thread.start() def _resume_session_queues(self, snapshot: Mapping[str, Any]) -> None: @@ -363,9 +403,9 @@ def close(self) -> None: with self._lock: workers = list(self._workers.values()) self._workers.clear() - for stop, _thread in workers: + for stop, _thread, _fingerprint in workers: stop.set() - for _stop, thread in workers: + for _stop, thread, _fingerprint in workers: thread.join(timeout=3) diff --git a/tests/extensions/test_lark_goal_topic_runtime.py b/tests/extensions/test_lark_goal_topic_runtime.py index a65e3e2c9d..6c0daeb779 100644 --- a/tests/extensions/test_lark_goal_topic_runtime.py +++ b/tests/extensions/test_lark_goal_topic_runtime.py @@ -1128,6 +1128,103 @@ def poller(profile: str, _stop: threading.Event) -> None: assert service.active_profiles() == [] +def test_runtime_service_restarts_profile_when_callback_chats_change( + tmp_path: Path, +) -> None: + from loopx.extensions.lark.goal_topic_runtime import LarkGoalTopicRuntimeService + from loopx.extensions.lark.team_plan_confirmation import active_profile_chat_ids + + snapshot: dict[str, Any] = { + "target_payload": { + "targets": { + "mew-product": { + "enabled": True, + "channel": {"chat_id": "oc_public_fixture"}, + "identity": { + "sender_profile": "mew", + "bot_app_id": "cli_public_fixture", + "cli_bin": "fake-lark", + }, + }, + "mew-second": { + "enabled": True, + "channel": {"chat_id": "oc_second_fixture"}, + "identity": { + "sender_profile": "mew", + "bot_app_id": "cli_public_fixture", + "cli_bin": "fake-lark", + }, + }, + } + }, + "binding_payloads": { + "goal-alpha": { + "bindings": { + "goal-alpha": { + "goal_id": "goal-alpha", + "provider": "lark", + "enabled": True, + "target_ref": "mew-product", + } + } + } + }, + "goal_contexts": {}, + } + starts: list[tuple[list[str], threading.Event]] = [] + stopped: list[threading.Event] = [] + lifecycle = threading.Condition() + + def poller(profile: str, stop: threading.Event) -> None: + with lifecycle: + starts.append((active_profile_chat_ids(snapshot, profile), stop)) + lifecycle.notify_all() + stop.wait(2) + with lifecycle: + stopped.append(stop) + lifecycle.notify_all() + + def wait_for_count(values: list[Any], count: int) -> None: + with lifecycle: + assert lifecycle.wait_for(lambda: len(values) >= count, timeout=1) + + service = LarkGoalTopicRuntimeService( + snapshot_provider=lambda: snapshot, + runtime_root=tmp_path, + runtime_controller=object(), + action_service=object(), + profile_poller=poller, + ) + service.refresh() + wait_for_count(starts, 1) + assert starts[0][0] == ["oc_public_fixture"] + + snapshot["binding_payloads"]["goal-beta"] = { + "bindings": { + "goal-beta": { + "goal_id": "goal-beta", + "provider": "lark", + "enabled": True, + "target_ref": "mew-second", + } + } + } + service.refresh() + wait_for_count(starts, 2) + wait_for_count(stopped, 1) + assert starts[0][1].is_set() + assert starts[1][0] == ["oc_public_fixture", "oc_second_fixture"] + + del snapshot["binding_payloads"]["goal-beta"] + service.refresh() + wait_for_count(starts, 3) + wait_for_count(stopped, 2) + assert starts[1][1].is_set() + assert starts[2][0] == ["oc_public_fixture"] + + service.close() + + def test_runtime_service_exposes_content_free_listener_health(tmp_path: Path) -> None: from loopx.extensions.lark.goal_topic_runtime import LarkGoalTopicRuntimeService @@ -1407,7 +1504,18 @@ def test_profile_stream_dispatches_only_team_plan_callbacks_for_bound_chats( "bot_app_id": "cli_public_fixture", "cli_bin": "fake-lark", }, - } + }, + "mew-second": { + "name": "mew-second", + "provider": "lark", + "enabled": True, + "channel": {"chat_id": "oc_second_fixture"}, + "identity": { + "sender_profile": "mew", + "bot_app_id": "cli_public_fixture", + "cli_bin": "fake-lark", + }, + }, } }, "binding_payloads": { @@ -1420,6 +1528,16 @@ def test_profile_stream_dispatches_only_team_plan_callbacks_for_bound_chats( "target_ref": "mew-product", } } + }, + "goal-beta": { + "bindings": { + "goal-beta": { + "goal_id": "goal-beta", + "provider": "lark", + "enabled": True, + "target_ref": "mew-second", + } + } } }, } @@ -1481,7 +1599,10 @@ def handle_callback(event: Mapping[str, Any]) -> dict[str, bool]: assert result["ok"] is True assert len(captured_args) == 2 callback_args = next(args for args in captured_args if "card.action.trigger" in args) - assert "select(.chat_id == \"oc_public_fixture\")" in callback_args + assert ( + 'select(.chat_id == "oc_public_fixture" or ' + '.chat_id == "oc_second_fixture")' in callback_args + ) assert handled == [callback] From b1cb0eeae6b429b90c828f7e8ed5a20fe9a84257 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 21 Sep 2026 03:03:17 +0800 Subject: [PATCH 5/5] fix(lark): resume partial team plan card delivery Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../extensions/lark/team_plan_confirmation.py | 37 ++++++ .../test_lark_team_plan_confirmation.py | 112 ++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/loopx/extensions/lark/team_plan_confirmation.py b/loopx/extensions/lark/team_plan_confirmation.py index 951030118e..ad5a9ce348 100644 --- a/loopx/extensions/lark/team_plan_confirmation.py +++ b/loopx/extensions/lark/team_plan_confirmation.py @@ -435,6 +435,43 @@ def _deliver_one( ) card = build_team_plan_review_card(proposal, audience_id=audience_id) card_digest = _digest(card) + current = store.load(str(proposal["proposal_id"])) + review_card = current.get("review_card") if isinstance(current, Mapping) else None + recorded_deliveries = ( + review_card.get("deliveries") if isinstance(review_card, Mapping) else None + ) + recorded = ( + recorded_deliveries.get(audience_id) + if isinstance(recorded_deliveries, Mapping) + else None + ) + if isinstance(recorded, Mapping): + expected = { + "provider": "lark", + "chat_id": str(route["chat_id"]), + "app_id": str(route["bot_app_id"]), + "cli_bin": str(route["cli_bin"]), + "sender_profile": str(route["sender_profile"]), + "binding_digest": goal_channel_binding_digest(binding), + "card_digest": card_digest, + "submitted_card": card, + "authorized_principal": authorized_principal, + } + if not _MESSAGE_ID.fullmatch(str(recorded.get("message_id") or "")) or any( + recorded.get(key) != value for key, value in expected.items() + ): + raise ActionConflictError( + "recorded team plan review audience drifted before retry" + ) + # The store only records an audience after exact native readback. Reuse + # that durable checkpoint instead of writing a duplicate actionable + # card when another audience made the prior attempt partial. + return { + "audience_id": audience_id, + "message_id": str(recorded["message_id"]), + "external_write_performed": False, + "readback_verified": True, + } def resolve_current() -> Mapping[str, Any]: payload = read_goal_channel_binding(binding_path) diff --git a/tests/extensions/test_lark_team_plan_confirmation.py b/tests/extensions/test_lark_team_plan_confirmation.py index 01e9d4b111..056caee17f 100644 --- a/tests/extensions/test_lark_team_plan_confirmation.py +++ b/tests/extensions/test_lark_team_plan_confirmation.py @@ -7,6 +7,8 @@ from collections.abc import Mapping from typing import Any +import pytest + from loopx.chat_action_store import ChatActionStore from loopx.extensions.lark.team_plan_confirmation import ( build_team_plan_review_card, @@ -336,6 +338,116 @@ def readback(self, message_id: str) -> dict[str, Any]: ] == "goal-profile" +def test_delivery_retry_resumes_after_the_first_audience_checkpoint( + tmp_path: Path, monkeypatch: Any +) -> None: + import loopx.extensions.lark.team_plan_confirmation as confirmation + + store = ChatActionStore(tmp_path / "actions") + proposal = _proposal(store) + + def binding(*, manager: bool) -> dict[str, Any]: + goal_id = "manager-goal" if manager else "goal-alpha" + return { + "goal_id": goal_id, + "provider": "lark", + "enabled": True, + "connection_id": "manager-connection" if manager else "goal-connection", + "target_ref": "manager-target" if manager else "goal-target", + "routing": { + "conversation_kind": "manager" if manager else "goal", + }, + "channel": { + "chat_id": "oc_manager" if manager else "oc_goal", + }, + "identity": { + "mode": "project_bot", + "sender_profile": "manager-profile" if manager else "goal-profile", + "sender_identity": "bot", + "bot_app_id": "cli_manager" if manager else "cli_goal", + "bot_display_name": "Manager Bot" if manager else "Goal Bot", + "cli_bin": "manager-lark" if manager else "goal-lark", + }, + } + + def resolve_binding(**kwargs: Any): + selected = binding(manager=kwargs["manager_audience"]) + return selected, tmp_path / "binding.json", tmp_path / "targets.json" + + send_calls: list[str] = [] + fail_goal_once = True + + class DeliverySession: + def __init__(self, **kwargs: Any) -> None: + self.route: Mapping[str, Any] | None = None + + def verify(self, route: Mapping[str, Any]) -> bool: + self.route = route + return True + + def send( + self, + _card: Mapping[str, Any], + _key: str, + route: Mapping[str, Any], + ) -> dict[str, Any]: + nonlocal fail_goal_once + chat_id = str(route["chat_id"]) + send_calls.append(chat_id) + if chat_id == "oc_goal" and fail_goal_once: + fail_goal_once = False + raise OSError("synthetic second-audience failure") + return { + "message_id": ("om_manager" if chat_id == "oc_manager" else "om_goal"), + "external_write_performed": True, + } + + def readback(self, message_id: str) -> dict[str, Any]: + assert self.route is not None + return { + "verified": True, + "message_id": message_id, + "chat_id": self.route["chat_id"], + "sender_app_id": self.route["bot_app_id"], + } + + monkeypatch.setattr(confirmation, "_resolved_binding", resolve_binding) + monkeypatch.setattr( + confirmation, "GoalChannelMessageDeliverySession", DeliverySession + ) + + kwargs = { + "proposal_ids": [proposal["proposal_id"]], + "manager_route": { + "goal_id": "manager-goal", + "connection_id": "manager-connection", + "source_sender_id": "ou_owner", + }, + "registry_path": tmp_path / "registry.json", + "runtime_root": tmp_path, + "action_store_root": store.root, + } + with pytest.raises(OSError, match="second-audience failure"): + deliver_team_plan_review_cards(**kwargs) + + partial = store.load(proposal["proposal_id"]) + assert partial is not None + assert set(partial["review_card"]["deliveries"]) == {"manager"} + + receipt = deliver_team_plan_review_cards(**kwargs) + + assert send_calls == ["oc_manager", "oc_goal", "oc_goal"] + assert receipt["audience_count"] == 2 + assert receipt["external_write_count"] == 1 + assert receipt["readback_verified"] is True + durable = store.load(proposal["proposal_id"]) + assert durable is not None + assert set(durable["review_card"]["deliveries"]) == { + "manager", + "goal:goal-alpha", + } + + def test_recovery_uses_the_first_durable_decision_not_a_later_click( tmp_path: Path, monkeypatch: Any ) -> None: