diff --git a/api/route_approvals.py b/api/route_approvals.py index eb2761a0..6deb3217 100644 --- a/api/route_approvals.py +++ b/api/route_approvals.py @@ -558,7 +558,27 @@ def retire_gateway_pending_mirror( return changed for match in retired: entries.remove(match) - if normalized_run_id and not approval_id: + if normalized_run_id: + # A successful response may arrive with the exact approval_id. The + # mirror and its producer queue are one lifecycle: leaving the + # producer behind lets reconciliation recreate the just-retired + # mirror on the next poll (the completed-child resurrection bug). + # Remove only the matching approval for an active run; when the + # caller retires a run without an approval_id, retire the whole run. + def _same_retired_run_entry(entry) -> bool: + data = getattr(entry, "data", None) or {} + if str(data.get("run_id") or "").strip() != normalized_run_id: + return False + if not approval_id: + return True + queued_approval_id = str(data.get("approval_id") or "").strip() + return not queued_approval_id or queued_approval_id == approval_id + + retained_gateway_queue = [ + entry for entry in gateway_queue + if not _same_retired_run_entry(entry) + ] + gateway_queue_changed = len(retained_gateway_queue) != len(gateway_queue) if retained_gateway_queue: _gateway_queues[session_key] = retained_gateway_queue else: diff --git a/tests/test_gateway_approval_legacy_path.py b/tests/test_gateway_approval_legacy_path.py index dabbf7ec..6a365d55 100644 --- a/tests/test_gateway_approval_legacy_path.py +++ b/tests/test_gateway_approval_legacy_path.py @@ -873,3 +873,45 @@ def test_legacy_approval_without_run_id_retires_locally(): r._pending.pop(session_id, None) r._gateway_queues.pop(session_id, None) _STREAM_RUN_IDS.pop(stream_id, None) + + +def test_deny_retires_run_producer_so_reconciliation_cannot_resurrect_mirror(): + """Deny must consume the producer as well as its WebUI mirror.""" + from types import SimpleNamespace + from api import route_approvals as ra + + sid = "sess-deny-no-resurrection" + approval_id = "approval-deny-no-resurrection" + run_id = "run-deny-no-resurrection" + approval = { + "approval_id": approval_id, + "run_id": run_id, + "command": "echo pricing", + "description": "Run pricing probe", + } + producer = SimpleNamespace(data=dict(approval), event=threading.Event(), result=None) + try: + with ra._lock: + ra._pending.pop(sid, None) + ra._gateway_queues[sid] = [producer] + ra.submit_gateway_pending_mirror(sid, dict(approval)) + mirror = ra.gateway_pending_mirror(sid, approval_id=approval_id, run_id=run_id) + assert mirror is not None + + assert ra.retire_gateway_pending_mirror( + sid, + approval_id=approval_id, + run_id=run_id, + mirror_token=mirror[ra._GATEWAY_MIRROR_TOKEN], + ) + + # These are the same observations made by subsequent HTTP polling. + for _ in range(2): + with ra._lock: + ra.reconcile_gateway_pending_mirror_locked(sid) + assert sid not in ra._pending + assert sid not in ra._gateway_queues + finally: + with ra._lock: + ra._pending.pop(sid, None) + ra._gateway_queues.pop(sid, None)