Skip to content

fix(gateway): bind Discord exec-approval cards to their own request (#104915) - #104960

Open
liuhao1024 wants to merge 1 commit into
NousResearch:mainfrom
liuhao1024:liuhao/cron-bugfix-104915
Open

liuhao1024 wants to merge 1 commit into
NousResearch:mainfrom
liuhao1024:liuhao/cron-bugfix-104915

Conversation

@liuhao1024

@liuhao1024 liuhao1024 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Discord execution-approval cards resolved whatever request was oldest in the session's gateway approval queue, not the request shown on the card. With two pending approvals A then B, clicking Allow Once on card B approved A; clicking a stale card A after its request had been removed settled the newer request B. An operator could therefore authorize a different command than the one displayed.

The fix threads the immutable request_id (already generated for every queued entry by _ApprovalEntry) through the gateway notify path into ExecApprovalView, and resolves only that bound request. A card whose request no longer exists — or one constructed without a request id — fails closed into the existing "Approval expired" state instead of falling back to FIFO. Text /approve keeps its FIFO semantics and /approve all is unchanged.

Per review feedback (0638c85), the binding is converged across every native interactive approval surface, platform-independently: a rendered approval control resolves exactly its request_id × session × principal, and stale/unbound controls fail closed. The shared call site gates the new keyword through _accepts_keyword.

Per the follow-up re-review (6cceb5a), the two remaining authority/lifetime boundaries are closed:

  • Legacy external/plugin adapters now fail closed. An adapter whose send_exec_approval predates the converged signature is no longer rendered an interactive approval at all — its taps could only resolve through the session FIFO, silently re-opening the stale/overlapping-control hole at an authorization boundary. It degrades to the clearly separate typed-text prompt (with a warning naming the adapter) instead of an unbound card.
  • WhatsApp Cloud keeps (session_key, request_id) as ONE bounded FIFO record (the single-record tuple shape of the older fix: bind gateway approval buttons to request ids #87554 carrier): the binding is stored exactly when the card state is, and eviction beyond INTERACTIVE_STATE_CACHE_SIZE drops both halves together — no stranded request-id half, no unbounded side map.

The branch is now a single commit rebased onto live main (6927ff8), so every surviving commit is the head that CI runs on.

Authorship lineage (kept visible per review): the exact-request-binding design credit goes to #6105 (first implementation) and #87554 (native-adapter carrier, including the bounded tuple shape adopted here); the broader non-native-surfaces rewrite in #68080 remains complementary rather than superseded.

Related Issue

Fixes #104915

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • gateway/run_turn_runner.py_approval_notify_sync passes request_id=approval_data.get("request_id") to adapter.send_exec_approval; adapters whose signature predates the converged keyword fail closed to the typed-text prompt (warning logged) instead of rendering an unbound interactive control.
  • plugins/platforms/discord/adapter.pysend_exec_approval and ExecApprovalView accept and store request_id; _resolve calls resolve_gateway_approval(..., request_id=self.request_id) and treats an unbound card as resolved-zero (fail closed).
  • plugins/platforms/slack/adapter.py — button value carries session_key|request_id (slash-confirm convention); the click handler partitions it; unbound legacy cards fail closed.
  • plugins/platforms/teams/adapter.py — AdaptiveCard action data carries request_id; the resolver honors the request id and its return value instead of a session-level precheck (also fixes a stale-card "Approved" render window).
  • plugins/platforms/feishu/adapter.py — interactive-card prompt state carries request_id; _resolve_approval binds to it.
  • plugins/platforms/matrix/adapter.py_MatrixApprovalPrompt carries request_id; the reaction resolver binds to it.
  • plugins/platforms/telegram/adapter.py — the request id rides out-of-band next to the short monotonic callback id (64-byte callback_data cap); the callback resolver binds to it.
  • gateway/platforms/whatsapp_cloud.py — the tap state stores (session_key, request_id) as one FIFO-capped record via _bounded_put (12-hex payload id); the tap resolver unpacks the record and binds to it, so eviction drops state and binding together.
  • gateway/platforms/qqbot/keyboards.py + gateway/platforms/qqbot/adapter.py — button data appends the hex request id after the decision; legacy 3-segment data still parses but resolves fail-closed.
  • gateway/relay/adapter.py — minted prompt state carries request_id; _resolve_exec_approval binds to it.
  • tests/gateway/test_exec_approval_request_binding_convergence.py — AST-level contract test pinning all nine adapter signatures + the _accepts_keyword gate, plus behavior tests proving a legacy adapter's send_exec_approval is never called (typed-text prompt with the adapter's typed prefix fires instead) while a converged adapter still receives request_id.
  • Per-adapter binding + fail-closed behavior tests in test_qqbot.py, test_slack_approval_buttons.py, test_telegram_approval_buttons.py, test_whatsapp_cloud.py (including the beyond-cap eviction regression), relay/test_relay_interactive.py, test_feishu_approval_buttons.py, test_matrix_exec_approval.py, and the Discord binding suite.

How to Test

  1. pytest tests/gateway/test_exec_approval_request_binding_convergence.py tests/gateway/test_discord_exec_approval_request_binding.py tests/gateway/test_approval_prompt_redaction.py -q — Observed result: 29 passed (nine-signature contract + gate unit test + legacy fail-closed/converged counterpart behavior tests + Discord binding suite + redaction wiring), on the squashed head rebased onto main@6927ff8.
  2. pytest tests/gateway/test_qqbot.py tests/gateway/test_slack_approval_buttons.py tests/gateway/test_telegram_approval_buttons.py tests/gateway/test_whatsapp_cloud.py tests/gateway/test_feishu_approval_buttons.py tests/gateway/test_matrix_exec_approval.py tests/gateway/relay/test_relay_interactive.py -q — Observed result: 138 passed, including the new test_request_binding_evicts_with_state_beyond_cache_cap (1003 cards → capped at 1000, oldest evicted whole, newest record intact).
  3. pytest tests/gateway/relay/test_relay_slack_prompt_dm_root.py tests/gateway/test_discord_approval_mentions.py tests/gateway/test_discord_exec_approval_content.py tests/gateway/test_discord_prompt_content_siblings.py tests/tools/test_approval.py tests/gateway/test_approve_deny_commands.py tests/relay/test_relay_prompt_ack_stream_isolation.py tests/run_agent/test_authorization_gate.py -q — Observed result: 182 passed, 1 failed (TestDetectDangerousRm::test_nonrecursive_verification_artifact_cleanup_is_not_dangerous), which fails identically on a clean upstream/main checkout (pre-existing, unrelated to this change).
  4. ruff check on all touched files — all checks passed.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15 (arm64)

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

Screenshots / Logs

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery platform/discord Discord bot adapter platform/qqbot QQ Bot adapter platform/whatsapp WhatsApp Business adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Sep 7, 2026

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think this immutable head is landable yet, despite exact CI/Docker/Nix being green.

The Discord request-binding direction is correct: a rendered approval card must settle the exact request generation it represents, never the session FIFO. But this change widens the shared send_exec_approval(...) call site by passing request_id= to every adapter while only updating a subset of adapter signatures. On current main, Slack, Teams, Matrix, and Feishu still expose the old signature, so those approval-notification paths will raise TypeError before rendering any prompt. This is an interface/compatibility regression that the hosted graph is not exercising.

There is also a class-completeness problem: WhatsApp Cloud and QQ still resolve approval button taps with resolve_gateway_approval(session_key, choice) (session FIFO), and this patch explicitly discards the newly available request id on those adapters (relay also discards it). That leaves the original overlapping/stale-card misapproval class alive on other native-button surfaces. The invariant should be platform-independent: rendered approval control × request_id × session × principal must resolve exactly one still-pending request; stale/unbound controls fail closed. Text /approve may retain its intentional FIFO semantics as a separate projection.

Please converge the adapter interface first (including external-plugin compatibility), carry request identity through every native interactive approval surface that can outlive or overlap another request, and add cross-adapter regressions. Separately, the Discord approval-card state is still being extended inside a >6K physical owner; under the repository's bounded-owner acceptance work this should move to a dedicated approval-card owner rather than growing plugins/platforms/discord/adapter.py further.

Exact object reviewed: 5fa571773f2452c8cdb39b34172442910f477191.

Comment thread gateway/run_turn_runner.py Outdated
chat_id=ctx._status_chat_id, command=cmd, session_key=ctx.session_key or "",
description=desc, metadata=ctx._status_thread_metadata, **flags,
description=desc, metadata=ctx._status_thread_metadata,
request_id=approval_data.get("request_id"), **flags,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This widens the call ABI for every status adapter, but current-main Slack, Teams, Matrix, and Feishu send_exec_approval(...) methods do not accept request_id. Those paths will raise TypeError before an approval prompt is rendered. Please either migrate the complete adapter/plugin interface in this carrier (with compatibility coverage for external adapters) or capability-gate the new argument rather than unconditionally passing it.

Comment thread gateway/platforms/whatsapp_cloud.py Outdated
) -> SendResult:
"""Approve / Deny buttons; a tap resolves via ``tools.approval.resolve_gateway_approval``."""
del allow_permanent, allow_session # This adapter already offers one-shot Approve / Deny only.
del request_id # Request-bound buttons are wired for Discord only so far (#104915).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping request_id here leaves the same stale/overlapping-button bug alive on WhatsApp: its inbound callback still calls resolve_gateway_approval(session_key, choice) and therefore resolves the FIFO request, not the card's request. Persist the request id in the interactive state/payload and resolve by it; QQ/relay and the other native-button adapters need the same class-level treatment rather than a Discord-only fix.

Comment thread plugins/platforms/discord/adapter.py Outdated
request_id: Optional[str] = None,
):
super().__init__(allowed_user_ids, allowed_role_ids, timeout=_read_discord_prompt_timeout())
self.session_key = session_key

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The request-bound state is the right semantic fix, but this adds another approval-policy coordinate to an adapter file already far beyond the repository's 2K physical-owner ceiling. Please extract ExecApprovalView / approval-card state into a bounded Discord approval module and keep the transport adapter as the projection/wiring surface.

@liuhao1024

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — all three landability blockers are addressed in 0638c85 (rebased onto current main, which had moved gateway/whatsapp_cloud.py to gateway/platforms/whatsapp_cloud.py):

1. Interface convergence + external-plugin compatibility. All five remaining built-in signatures (slack/teams/matrix/feishu/telegram, plus telegram which the review had not listed) now declare request_id, and the shared call site gates the keyword through the existing agent.interrupt_compat._accepts_keyword helper — external/plugin adapters predating the converged signature keep the legacy call shape instead of raising TypeError. Pinned by an AST-level contract test across all nine adapters plus a unit test of the gate itself.

2. Request identity on every native interactive surface. No adapter discards the id anymore:

  • slack: button value carries session_key|request_id (the existing slash-confirm convention); the click handler partitions it.
  • teams: AdaptiveCard action data carries request_id. This also fixes a latent form of the misapproval class there: the old session-level has_blocking_approval precheck plus an ignored resolve return meant a stale card could render "Approved" while the FIFO settled a different request.
  • feishu / matrix: prompt state / prompt object carries request_id.
  • telegram / whatsapp_cloud: the id rides out-of-band next to the short callback id (Telegram caps callback_data at 64 bytes; the WhatsApp payload id is a 12-hex local id), so the binding can't bloat the wire format.
  • qqbot: button data appends the hex request id after the decision; legacy 3-segment data still parses but resolves fail-closed.
  • relay: minted prompt state carries request_id.

The invariant is now platform-independent as specified: a rendered control resolves exactly its request_id × session × principal; stale/unbound controls fail closed (existing entries always carry a request_id via _ApprovalEntry's setdefault, so unbound in practice means pre-deploy legacy cards, which correctly show "expired"). Text /approve keeps its intentional FIFO semantics.

3. Cross-adapter regressions. test_exec_approval_request_binding_convergence.py pins the nine-signature contract, and per-adapter binding + fail-closed behavior tests were added for qqbot / slack / telegram / whatsapp_cloud / relay / feishu / matrix alongside the existing Discord suite (438 relevant tests pass locally; the one failing test_approval.py::TestDetectDangerousRm case fails identically on clean upstream/main and is unrelated).

4. Approval-card owner extraction — treated as the separate work the review scoped it to (plugins/platforms/discord/adapter.py is untouched beyond the original binding); happy to follow up under the bounded-owner effort if you want it in this lane.

@liuhao1024
liuhao1024 force-pushed the liuhao/cron-bugfix-104915 branch from 5fa5717 to 0638c85 Compare September 7, 2026 12:29

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head 0638c857490f61016266c0fe662cb94ac2ea950e after the material response to the prior review. The built-in convergence is substantially stronger now: all nine native interactive approval surfaces carry request identity, stale/unbound built-in controls fail closed, the Teams precheck/ignored-return window is closed, and the exact-head CI / Docker / Nix workflows are green. I also checked the current core resolver/request generation, the per-adapter regressions, the prior review threads, and the overlapping repository history.

I still see two landing blockers on this head:

  1. The external/plugin compatibility path fixes the Python ABI by silently weakening the authorization invariant. _accepts_keyword(...) omits request_id for a legacy external adapter and then still renders that adapter's interactive approval. That adapter can only fall back to session FIFO, so the exact stale/overlapping-control class this PR closes for built-ins remains reachable for third-party interactive approval surfaces. At an authorization UI boundary, unsupported request binding needs to fail closed rather than degrade invisibly. Either refuse the interactive control, route to a clearly separate typed-text FIFO path, or provide a compatibility carrier that preserves opaque request identity. Please add a legacy-plugin regression proving a stale/unbound rendered control cannot settle another request.

  2. WhatsApp splits one approval authority record across two different lifetimes. _exec_approval_state is intentionally FIFO-capped by _bounded_put, while _exec_approval_request_ids is a plain dict that is only popped on a tap. Once unanswered approval state is evicted from the bounded cache, its request-id half remains indefinitely, so the two coordinates drift and the new map grows without bound. Store (session_key, request_id) in the same bounded record or evict both atomically, and cover eviction beyond INTERACTIVE_STATE_CACHE_SIZE. The older #87554 carrier already used the single bounded tuple shape here, so that implementation deserves explicit credit rather than losing the useful part of its design.

Current landing state: head workflows are green, but the first surviving commit 1fa3cb74c4759c14d952f10ee5c294f4580e1eea has no hosted workflow receipts, so this two-commit train is still 1/2 hosted-green rather than fully proven. Also, live main has advanced to 76af5ebf0978f9978ad01e9cc860647e02ffe5ae; this branch is now 2 ahead / 24 behind with merge base 0d08cd295fd73427833ee349eb858569d4d0dd3a, so the repaired carrier needs fresh current-main integration proof before landing. I am not duplicating the existing live Discord >2K physical-owner thread here; it remains unresolved on the current diff.

Interlock / provenance: #6105 is the earlier credited exact-request-binding implementation; #68080 is its broader rewrite spanning messaging plus TUI/Desktop/Runs, but is now stale/conflicting; #87554 is a direct older native-adapter carrier for the same stale-button invariant and is also stale/conflicting. If #104960 is selected, it is the current-main superseding carrier for the native messaging slice; the broader non-native surfaces in #68080 remain complementary rather than being erased, and the prior authorship/design lineage should stay visible.

The response to the first review did real work: especially the Teams correction, complete built-in sweep, and stale/unbound fail-closed coverage. Close the two remaining authority/lifetime boundaries, rebase onto live main, and get every surviving commit hosted-green; then this is much closer to a coherent repository-wide approval binding. 🚀

Comment thread gateway/run_turn_runner.py Outdated
# Built-in adapters accept request_id (#104915); external/plugin adapters that
# predate the converged signature must keep receiving the legacy call shape.
extra: Dict[str, Any] = {}
if _accepts_keyword(adapter.send_exec_approval, "request_id"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capability-gating the kwarg fixes the ABI crash, but this branch silently downgrades authorization semantics for any legacy external adapter: it still renders an interactive approval without request_id, so the stale/overlapping-control class remains reachable through that adapter's session-FIFO response path. For a rendered authorization control, unsupported request binding should fail closed (or fall back to the explicitly typed FIFO path), not compatibly drop the identity coordinate. Please add a legacy-plugin regression proving no stale/unbound rendered control can settle another request.

Comment thread gateway/platforms/whatsapp_cloud.py Outdated
self._clarify_state: "OrderedDict[str, str]" = OrderedDict()
self._exec_approval_state: "OrderedDict[str, str]" = OrderedDict()
# approval_id -> gateway request id for request-bound exec-approval cards (#104915).
self._exec_approval_request_ids: Dict[str, str] = {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This splits one approval record across two lifetimes. _exec_approval_state is FIFO-capped at INTERACTIVE_STATE_CACHE_SIZE via _bounded_put, but this new plain dict is only popped on a tap; cache eviction leaves the request-id half behind forever. After enough unanswered approvals this grows unbounded and the correlation maps drift. Keep (session_key, request_id) in the same bounded entry (the older #87554 carrier already used that shape) or evict both atomically, and add an over-cap eviction regression.

@liuhao1024
liuhao1024 force-pushed the liuhao/cron-bugfix-104915 branch from 0638c85 to 6cceb5a Compare September 7, 2026 13:44
@liuhao1024

Copy link
Copy Markdown
Contributor Author

Thank you for the re-review — both remaining authority/lifetime boundaries are closed in 6cceb5a (squashed to a single commit, rebased onto live main @ 6927ff8, so every surviving commit is the hosted-green head).

1. Legacy/plugin compatibility path now fails closed. _accepts_keyword no longer merely downgrades the call shape: an adapter whose send_exec_approval predates the converged keyword gets no interactive approval rendered at all — it degrades to the clearly separate typed-text prompt (same path as adapters without send_exec_approval), with a warning naming the adapter. Rationale: that adapter's taps can only resolve through the session FIFO, so any card we render would re-open the exact stale/overlapping-control class this PR closes; at an authorization UI boundary the unsupported binding fails closed instead of degrading invisibly. Covered by behavior tests in test_exec_approval_request_binding_convergence.py::TestLegacyAdapterFailsClosed: a legacy adapter's send_exec_approval raises if invoked (asserted never called), the typed-text prompt fires with the adapter's typed prefix and is_approval_prompt metadata, and the converged counterpart still receives request_id with no text fallback.

2. WhatsApp authority record unified into one bounded lifetime. _exec_approval_request_ids is gone; _exec_approval_state now stores (session_key, request_id) as a single record inserted by the same _bounded_put that stores the card state — binding and state are written atomically on send-success and evicted atomically beyond INTERACTIVE_STATE_CACHE_SIZE. Regression: test_request_binding_evicts_with_state_beyond_cache_cap sends 1003 cards and asserts the cache caps at 1000, the oldest card evicted whole (no stranded request-id half), and the newest record intact. Explicit credit for the single bounded tuple shape goes to the older #87554 carrier — noted in the commit message and PR body.

Provenance is kept visible in both the commit message and the PR body: #6105 as the first exact-request-binding implementation, #87554 as the native-adapter carrier whose bounded tuple design is adopted here, and #68080 as the complementary broader rewrite for the non-native surfaces.

Head CI is running on the squashed commit; local ruff check is clean and the approval-related suites pass (29 + 138; the one TestDetectDangerousRm failure reproduces identically on clean upstream/main and is unrelated).

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head 6cceb5a363cdad28092aee8915848fe7582a16e9 against the two remaining authority/lifetime blockers from my prior review. Both are actually closed on this object.

  1. Legacy adapter behavior now fails closed at the rendering boundary. TurnRunner._approval_notify_sync() checks whether the adapter's send_exec_approval accepts request_id. A legacy external/plugin adapter is no longer invoked to render an unbound interactive control; execution falls through to the explicitly separate typed-text approval path. The new convergence tests exercise both sides of that branch: the legacy adapter's interactive method is not called, while a converged adapter receives the immutable request id. That closes the silent authorization downgrade I identified.

  2. WhatsApp Cloud now gives card state and request authority one bounded lifetime. _exec_approval_state is a single FIFO-capped OrderedDict whose value is (session_key, request_id); send stores that tuple atomically, tap handling pops it atomically, an absent/unbound record resolves zero rather than FIFO, and the over-cap regression proves that 1,003 issued cards leave exactly the 1,000-entry cap with the oldest complete record evicted and the newest binding intact. There is no separate request-id map left to drift or grow without bound.

I also rechecked the surrounding request-generation and resolution path: every queued approval receives an immutable request id, the interactive path forwards it, and WhatsApp calls resolve_gateway_approval(..., request_id=request_id) only when bound. The two old bypasses are no longer reachable through these paths.

The submitted series is now one commit, and the hosted CI, Docker Build, Test, and Publish, and Nix flake check runs all concluded successfully for this exact head:

So the prior two blockers are resolved, and I found no replacement blocker in those fixes.

There is one separate landing-state condition: live main is now 1735ccde444355e1be11a2519f2224764e3f287e; this head is 119 commits behind it and GitHub currently reports the PR non-mergeable. The next rebase/conflict resolution will create a new exact object and invalidate these green receipts, so it needs fresh CI/Docker/Nix proof before landing. The existing Discord bounded-owner thread remains separate from the two blockers resolved here.

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head 6cceb5a363cdad28092aee8915848fe7582a16e9 against the two remaining authority/lifetime blockers from my 0638c85 review. Both are actually closed.

  1. Legacy external/plugin adapters now fail closed rather than silently weakening the authorization invariant. In gateway/run_turn_runner.py, the call site separates has_exec_approval from binds_requests; an adapter whose send_exec_approval does not accept request_id no longer enters the interactive path at all and falls through to the typed-text approval surface. TestLegacyAdapterFailsClosed exercises the behavior, not just the signature: the legacy interactive method raises if invoked and is asserted never to run, the typed-text prompt is delivered with approval metadata, and the converged counterpart still receives the exact request id. That closes the prior ABI-compatible-but-authority-unsafe downgrade.

  2. WhatsApp Cloud now has one bounded lifetime for card state and request identity. _exec_approval_request_ids is gone. _exec_approval_state owns (session_key, request_id) as one OrderedDict record inserted through the existing bounded state path; tap handling pops that same record, resolves only with the bound request id, and treats an unbound record as resolved-zero rather than FIFO. test_request_binding_evicts_with_state_beyond_cache_cap drives the cache past INTERACTIVE_STATE_CACHE_SIZE, proves the oldest record is evicted whole, and proves the newest tuple remains intact. This closes both the stranded-side-map leak and the lifetime drift, while preserving the useful bounded tuple design from #87554 with explicit provenance.

The branch is now one surviving commit and all three hosted workflows are green on that exact object:

Live main has moved since the squash, but neither repaired owner (gateway/run_turn_runner.py nor gateway/platforms/whatsapp_cloud.py) changed in that drift, and current-main resolve_gateway_approval(...) still selects an exact matching request_id before settlement. I do not see a reason to reopen either finding for churn-only rebasing.

Disposition for this re-review: both remaining authority/lifetime blockers are closed on 6cceb5a; I have no new inline finding on those repairs. The previously separate Discord physical-owner thread is not being marked resolved by this review.

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of exact head 6cceb5a363cdad28092aee8915848fe7582a16e9.

The two semantic blockers from the earlier review are fixed on this immutable object: the shared adapter ABI is converged without silently preserving unbound interactive controls for legacy adapters, and request identity now survives across the native interactive approval surfaces with stale/unbound controls failing closed. The exact required-check gate is green, and this is one surviving commit.

One landing blocker remains from the original review: the Discord authorization state still grows plugins/platforms/discord/adapter.py, already a >6K physical owner. That is not a cosmetic decomposition request; this code decides which physical effect an operator authorizes. Under the repository's bounded-owner acceptance contract, the request-bound approval-card owner needs to move into a dedicated bounded module with the transport adapter left as projection/wiring, and the existing Discord binding regressions should move with that owner.

I would keep this carrier and its now-correct cross-adapter semantics, do the bounded-owner extraction here, rerun the exact one-commit hosted graph, then re-review the immutable result. No second approval mechanism is needed.

Comment thread plugins/platforms/discord/adapter.py Outdated
# A card resolves only the request it was issued for; an unbound card or one
# whose request is gone must never settle a different queued command (#104915).
# Text /approve keeps its FIFO semantics inside resolve_gateway_approval.
count = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The request-specific resolution is now semantically correct, but this is still authorization policy/effect-binding behavior inside a >6K transport owner. Please move ExecApprovalView / request-bound approval-card state and resolution into a dedicated bounded Discord approval module, with adapter.py only constructing/wiring the projection. The request-ID/stale-card regressions should follow that owner so this fixes the class without extending the godfile.

…ers (NousResearch#104915)

Bind every interactive exec-approval card to the request generation it was
issued for, so a stale or overlapping control can no longer settle a different
pending approval:

- the entry side stamps an immutable request_id (uuid) onto every approval
  entry (tools/approval_gateway_wait.py) and the gateway notify path forwards
  it (gateway/run_turn_runner.py);
- all nine native interactive adapters (Discord, Slack, Teams, Matrix, Feishu,
  Telegram, QQBot, WhatsApp Cloud, relay) thread the keyword through
  send_exec_approval and resolve by request id, fail-closed when unbound;
- external/plugin adapters predating the converged signature fail closed to
  the typed-text prompt instead of rendering an unbound interactive control;
- WhatsApp Cloud keeps (session_key, request_id) as ONE bounded FIFO record
  (single-record tuple shape per the older NousResearch#87554 carrier), so eviction
  beyond INTERACTIVE_STATE_CACHE_SIZE drops both halves together;
- the Discord request-bound approval-card owner lives in its topical module
  plugins/platforms/discord/exec_approval.py (admin gate, card payload, and
  view factory on the adapter's shared component base), leaving the >6K-line
  transport adapter as projection/wiring; the Discord binding regressions
  moved with it to tests/plugins/platforms/.

Authorship lineage: exact-request-binding design credit goes to NousResearch#6105 (first
implementation) and NousResearch#87554 (native-adapter carrier incl. the bounded tuple
shape adopted here); the broader non-native surfaces rewrite in NousResearch#68080 stays
complementary.
@liuhao1024
liuhao1024 force-pushed the liuhao/cron-bugfix-104915 branch from 6cceb5a to 8931170 Compare September 7, 2026 16:24
@liuhao1024

Copy link
Copy Markdown
Contributor Author

Bounded-owner extraction done in 8931170 (still one surviving commit on top of the same base; the hosted graph re-runs on it now).

  • The request-bound approval-card owner moved to a dedicated topical module, plugins/platforms/discord/exec_approval.py: the admin-gate resolver, the card payload builder, and define_exec_approval_view() — the view class body is moved verbatim (request binding, fail-closed unbound resolve, admin overlay) and is built on the adapter's shared component base, so a lazy install re-registers it exactly like the sibling views.
  • plugins/platforms/discord/adapter.py keeps projection/wiring only: send_exec_approval() resolves adapter state and delegates to the owner module; the view factory registers the class via one call. The in-module admin-gate resolver is gone (net ~130 lines off the >6K owner).
  • The Discord binding regressions moved with the owner to tests/plugins/platforms/test_discord_exec_approval_request_binding.py; the shared comprehensive discord mock is triggered explicitly since the file no longer sits under tests/gateway/. test_discord_component_auth.py now imports the admin-gate resolver from the owner module (aliased, assertions unchanged).

Verified locally: the migrated binding regressions, the convergence suite, component-auth, view-base parity, lazy-install re-registration, prompt-timeout, and card-content tests all pass (68 tests); ruff clean on all touched files. No behavior change intended anywhere — this is a pure ownership move on top of the already-closed semantic fixes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists platform/discord Discord bot adapter platform/qqbot QQ Bot adapter platform/whatsapp WhatsApp Business adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Discord approval buttons resolve the oldest request instead of the displayed request

3 participants