From af5a2c733cae5cb7ab7ce7bd493054c6acaaf3c0 Mon Sep 17 00:00:00 2001 From: Martin Sawczynski Date: Fri, 8 May 2026 17:00:13 +0100 Subject: [PATCH] fix(workflow): poll for WS_STARTED after checkout to break ready_to_start loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check_workflow_for_launch` calls `start_workflow_for_record` and then immediately re-validates via `validator.validate()`. Because the router processes `start_workflow` asynchronously, the validate call can return `WS_READY_TO_START` before the `WS_STARTED` transition is visible. At that point `handled_ready_to_start` is already `True`, so the outer loop falls to the "already-handled" guard and returns `WorkflowGate(allowed=False)`. The user sees "Workflow approved but not yet checked out" a second time and is prompted to check out again, creating an infinite loop until the session is restarted. Fix: after a successful `start_workflow_for_record`, poll with exponential back-off (0.5 → 1 → 2 → 2 s, ≤5.5 s total) until the router confirms `WS_STARTED` or retries are exhausted. On success the outer loop breaks immediately; on failure the outer loop continues to its normal handling. Confirmed in lab: `pam launch ` with auto-checkout no longer loops; gateway reaches "Establishing secure session" without error. Co-authored-by: Cursor --- keepercommander/commands/workflow/mfa.py | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/keepercommander/commands/workflow/mfa.py b/keepercommander/commands/workflow/mfa.py index d9a977eac..0d96355af 100644 --- a/keepercommander/commands/workflow/mfa.py +++ b/keepercommander/commands/workflow/mfa.py @@ -12,6 +12,7 @@ import datetime import getpass import logging +import time from typing import NamedTuple, Optional from ..pam.router_helper import _post_request_to_router @@ -742,6 +743,31 @@ def check_workflow_for_launch( except Exception as e: logging.error("Failed to check out: %s", sanitize_router_error(e)) return WorkflowGate(allowed=False) + + # Router propagation delay: the start_workflow call is processed + # asynchronously, so a validate() immediately after checkout can + # still return WS_READY_TO_START before the router has written the + # WS_STARTED transition. When that happens and handled_ready_to_start + # is already True the outer loop falls to the "already-handled" guard + # and returns WorkflowGate(allowed=False) — the user sees "Workflow + # approved but not yet checked out" again and re-triggers checkout, + # producing an infinite prompt loop. + # + # Fix: poll with exponential back-off (0.5 → 1 → 2 → 2 s, ≤5.5 s + # total) until the router confirms WS_STARTED or we exhaust retries. + # On success we break out of the outer loop immediately; on failure + # we fall through to let the outer loop decide. + _poll_delays = (0.5, 1.0, 2.0, 2.0) + for _delay in _poll_delays: + time.sleep(_delay) + _probe = validator.validate(silent_actionable=True) + if _probe.get('allowed') or _probe.get('block_reason') != 'ready_to_start': + result = _probe + break + else: + result = validator.validate(silent_actionable=True) + if result.get('allowed'): + break continue if block_reason == 'waiting' and not handled_waiting and wait: