DEV Community

StareBrain
StareBrain

Posted on

Late Confirmation Race: Handling the Gap Between User Approval and Action Dispatch in Android AI Agents

There’s a timing problem in confirm-before-execute flows that’s easy to overlook until it causes a duplicate action: the window between when the user confirms and when the action actually dispatches.

In most implementations, the flow looks like this:

  1. Agent plans an action
  2. User is shown a confirmation prompt
  3. User taps confirm
  4. Action dispatches

The assumption is that step 3 leads directly to step 4. But in practice, there’s a gap — and things can happen in that gap.

This post covers what we call the late confirmation race in StareBrain, and how we handle it without introducing false blocks or silent duplicates.

What Can Go Wrong in the Gap

The most common case: the action was already dispatched by an earlier path before the confirmation came back.

How does that happen? A few ways:

  • The user confirmed on one device/session while a background task had already triggered the same action
  • A retry fired because the first dispatch timed out, but the timeout was too aggressive — the original dispatch was still in-flight
  • The auto-confirm threshold was met (for low-risk actions) at the same time the manual confirm came back

In all three cases, a naive implementation dispatches twice. One dispatch was right. The other is a duplicate.

Checking Terminal State Before Replacement Dispatch

The fix is straightforward: before honoring a confirmation by dispatching, check whether the action is already in a terminal delivery state.

`sealed class ConfirmationOutcome {
    data class Dispatched(val actionId: String) : ConfirmationOutcome()
    data class AlreadyCompleted(val actionId: String, val state: DeliveryState) : ConfirmationOutcome()
    data class Superseded(val actionId: String) : ConfirmationOutcome()
    data class Blocked(val actionId: String, val reason: String) : ConfirmationOutcome()
}

class ConfirmationHandler(
    private val actionRepository: ActionRepository,
    private val dispatcher: ActionDispatcher
) {
    fun handleConfirmation(actionId: String): ConfirmationOutcome {
        val record = actionRepository.find(actionId)
            ?: return ConfirmationOutcome.Blocked(actionId, "action record not found")

        // Terminal check before dispatch
        return when (record.delivery) {
            DeliveryState.CONFIRMED -> {
                ConfirmationOutcome.AlreadyCompleted(actionId, DeliveryState.CONFIRMED)
            }
            DeliveryState.FAILED -> {
                ConfirmationOutcome.AlreadyCompleted(actionId, DeliveryState.FAILED)
            }
            DeliveryState.PENDING -> {
                // Safe to dispatch — not yet in a terminal state
                dispatcher.dispatch(record)
                ConfirmationOutcome.Dispatched(actionId)
            }
        }
    }
}`
Enter fullscreen mode Exit fullscreen mode

If the delivery state is already CONFIRMED or FAILED, the confirmation is honored as a no-op. The user’s tap is acknowledged, but no second dispatch fires.

The DENIED_UNRESOLVED Case

The terminal check above handles CONFIRMED and FAILED cleanly. The harder case is DENIED_UNRESOLVED — the action dispatched, but the outcome is unknown.

This is not a terminal state in the sense that it’s resolved, but it is a state where a replacement dispatch is dangerous: if the first dispatch actually landed on the provider side and just failed to confirm back, a second dispatch is a duplicate.

`fun handleConfirmation(actionId: String): ConfirmationOutcome {
    val record = actionRepository.find(actionId)
        ?: return ConfirmationOutcome.Blocked(actionId, "action record not found")

    return when {
        record.delivery == DeliveryState.CONFIRMED ->
            ConfirmationOutcome.AlreadyCompleted(actionId, DeliveryState.CONFIRMED)

        record.delivery == DeliveryState.FAILED ->
            ConfirmationOutcome.AlreadyCompleted(actionId, DeliveryState.FAILED)

        record.actionStatus == ActionStatus.DENIED_UNRESOLVED -> {
            // Do NOT dispatch. Surface for reconciliation first.
            ConfirmationOutcome.Blocked(
                actionId,
                "prior dispatch unresolved — reconcile before retry"
            )
        }

        record.delivery == DeliveryState.PENDING ->
            dispatcher.dispatch(record).let {
                ConfirmationOutcome.Dispatched(actionId)
            }

        else -> ConfirmationOutcome.Blocked(actionId, "unexpected state")
    }
}`
Enter fullscreen mode Exit fullscreen mode

The DENIED_UNRESOLVED case blocks replacement dispatch and routes to reconciliation. The user sees that the previous attempt’s outcome is unknown and needs to be checked before proceeding — not a silent failure, not a silent retry.

Idempotency on the Dispatch Side

The terminal check on confirmation prevents duplicate dispatches from the confirmation handler. But the dispatch path itself should also be idempotent, because the confirmation handler isn’t the only entry point.

`class ActionDispatcher(
    private val repository: ActionRepository,
    private val provider: ActionProvider
) {
    fun dispatch(record: ActionRecord): DispatchResult {
        // Idempotency key scoped to action + attempt number
        val idempotencyKey = "${record.actionId}:${record.attemptNumber}"

        // Re-check delivery state inside dispatch — the terminal check
        // in ConfirmationHandler is a fast path, not the only guard
        if (record.delivery != DeliveryState.PENDING) {
            return DispatchResult.Skipped(record.actionId, "non-pending state at dispatch")
        }

        return try {
            val result = provider.execute(record, idempotencyKey)
            repository.markDelivered(record.actionId, result.providerObjectId)
            DispatchResult.Succeeded(record.actionId)
        } catch (e: TimeoutException) {
            repository.markUnresolved(record.actionId)
            DispatchResult.Unresolved(record.actionId)
        } catch (e: Exception) {
            repository.markFailed(record.actionId)
            DispatchResult.Failed(record.actionId, e.message)
        }
    }
}

sealed class DispatchResult {
    data class Succeeded(val actionId: String) : DispatchResult()
    data class Failed(val actionId: String, val reason: String?) : DispatchResult()
    data class Unresolved(val actionId: String) : DispatchResult()
    data class Skipped(val actionId: String, val reason: String) : DispatchResult()
}`
Enter fullscreen mode Exit fullscreen mode

The idempotency key scoped to actionId + attemptNumber means a retry on the same attempt doesn’t produce a second provider-side effect. A new attempt gets a new key.

What the User Sees

The goal is that the user never sees a duplicate action and never gets a silent failure. What they see instead:

  • Normal path: confirm → action runs → done
  • Already completed: confirm acknowledged, brief notice that it already ran
  • DENIED_UNRESOLVED: confirm blocked, clear message that the previous attempt’s outcome is unknown and needs to be checked

The third case is the uncomfortable one to surface. But a user who sees “we’re not sure if the last attempt ran” is in a better position than a user who just had a duplicate booking made silently.

What This Closes

The late confirmation race is a narrow window but it’s a real one — especially in mobile contexts where network conditions vary and the same action can be triggered from multiple paths. The terminal check before dispatch, combined with idempotent dispatch, closes both the happy-path duplicate and the unresolved-state duplicate without adding meaningful latency to the normal flow.

Top comments (0)