DEV Community

StareBrain
StareBrain

Posted on

Why Your AI Agent Produces Duplicates (It’s a Modeling Error, Not a Logic Error)

Most duplicate-dispatch bugs in AI agents aren’t caused by missing retry guards or broken idempotency keys. They’re caused by collapsing two independent questions into one state field.

The question “did we get evidence of dispatch?” and the question “does that evidence satisfy the intended outcome?” are not the same question. When you force both into a single SUCCESS/ FAILEDenum, you lose the information you need to decide what to do next — and the wrong recovery path causes the duplicate.

Here’s how StareBrain models it instead.

The Two-Axis Model

Every action that produces an observable artifact tracks two independent states:

`enum class DeliveryState {
    PENDING,      // Artifact not yet confirmed by provider lookup
    CONFIRMED,    // Provider lookup returned the artifact
    FAILED        // Provider lookup returned nothing or error
}

enum class AcceptanceState {
    PENDING,      // Not yet evaluated (delivery must be CONFIRMED first)
    ACCEPTED,     // Artifact matches intended outcome
    REJECTED      // Artifact exists but fails semantic match
}

data class ActionRecord(
    val actionId: String,
    val dispatchId: String,
    val providerObjectId: String?,
    val delivery: DeliveryState,
    val acceptance: AcceptanceState
)`
Enter fullscreen mode Exit fullscreen mode

They progress independently. The combined status is derived, never stored directly:

`fun ActionRecord.resolvedStatus(): ActionStatus = when {
    delivery == DeliveryState.FAILED -> ActionStatus.FAILED
    delivery != DeliveryState.CONFIRMED -> ActionStatus.DENIED_UNRESOLVED
    acceptance == AcceptanceState.REJECTED -> ActionStatus.FAILED
    acceptance == AcceptanceState.ACCEPTED -> ActionStatus.CONFIRMED
    else -> ActionStatus.DENIED_UNRESOLVED
}`
Enter fullscreen mode Exit fullscreen mode

The Structural Invariant

The rule that makes this safe isn’t a convention — it’s enforced at the state machine level:

`

kotlin
fun ActionRecord.tryAccept(verdict: AcceptanceState): ActionRecord {
check(delivery == DeliveryState.CONFIRMED) {
"Cannot evaluate acceptance before delivery is confirmed. " +
"Run provider lookup first."
}
return copy(acceptance = verdict)
}

`

tryAccept()throws if delivery isn’t CONFIRMED. Acceptance cannot move out of PENDINGuntil a provider lookup has confirmed the artifact exists. There’s no path through the code where an unconfirmed dispatch gets accepted.

Why This Prevents Duplicates

Without this separation, the failure looks like this:

  1. Agent dispatches action
  2. Dispatch times out before confirmation arrives
  3. System sees no SUCCESS→ marks as failed
  4. Retry fires → second dispatch → duplicate

The problem is step 3. “No confirmation yet” and “action failed” are not the same condition. Collapsing them forces a binary choice: wait forever, or retry and risk duplicates.

With the two-axis model:

`

`kotlin
fun shouldRedispatch(record: ActionRecord): Boolean {
// Never redispatch while delivery is unresolved
if (record.delivery == DeliveryState.PENDING) return false

// Only redispatch on confirmed failure, not unconfirmed delivery
if (record.delivery == DeliveryState.FAILED) return true

// Semantic mismatch: fix the action, then redispatch
if (record.acceptance == AcceptanceState.REJECTED) return true

return false
Enter fullscreen mode Exit fullscreen mode

}
`
`

PENDINGdelivery blocks redispatch entirely. The action stays DENIED_UNRESOLVEDand goes to reconciliation — not retry.

The Two Failure Modes With Different Recovery Paths

Semantic mismatc*h *(CONFIRMED+ REJECTED):
The artifact exists. It’s confirmed. It’s just wrong — wrong recipient, wrong content, wrong payload. The recovery is: fix the action definition, re-run. The original dispatch is evidence of what happened; it doesn’t need to be re-checked.

Unconfirmed delivery (PENDING):
The artifact hasn’t been verified yet. Do not redispatch. Run the provider lookup. If the artifact is there, update delivery state to CONFIRMEDand evaluate acceptance. If it’s not, then — and only then — you have a real failure to act on.

`

`kotlin
suspend fun reconcile(record: ActionRecord, provider: ActionProvider): ActionRecord {
if (record.delivery != DeliveryState.PENDING) return record

val artifact = provider.lookup(record.dispatchId)

return if (artifact != null) {
    record.copy(
        providerObjectId = artifact.id,
        delivery = DeliveryState.CONFIRMED
    )
} else {
    record.copy(delivery = DeliveryState.FAILED)
}
Enter fullscreen mode Exit fullscreen mode

}
`
`

Persisting the Triple

The record that makes this queryable at any point — including across retries and process restarts:

`

kotlin
data class ActionRecord(
val actionId: String,
val dispatchId: String, // Scoped to this specific dispatch attempt
val providerObjectId: String?, // Null until delivery confirmed
val delivery: DeliveryState,
val acceptance: AcceptanceState,
val dispatchedAt: Instant,
val confirmedAt: Instant?,
val acceptedAt: Instant?
)

`

dispatchId, providerObjectId, and acceptancestored together means a retry cannot accidentally borrow proof from a different run. Each dispatch is a separate record with its own artifact binding.

What This Looks Like in Practice

For StareBrain’s SMS dispatch on Android:

`val record = ActionRecord(
    actionId = "sms-${UUID.randomUUID()}",
    dispatchId = pendingIntent.creatorUid.toString(),
    providerObjectId = null,
    delivery = DeliveryState.PENDING,
    acceptance = AcceptanceState.PENDING,
    dispatchedAt = Instant.now(),
    confirmedAt = null,
    acceptedAt = null
)

// After accessibility service confirms send button tap:
val afterDispatch = record.copy(
    providerObjectId = sentMessageId,  // Message ID from sent folder
    delivery = DeliveryState.CONFIRMED,
    confirmedAt = Instant.now()
)

// After verifying recipient + content match:
val accepted = afterDispatch.tryAccept(
    if (messageMatchesIntent(afterDispatch)) AcceptanceState.ACCEPTED
    else AcceptanceState.REJECTED
)`
Enter fullscreen mode Exit fullscreen mode

The sent folder check isn’t the proof. Binding the confirmed sentMessageId to the specific dispatchId from that dispatch is the proof. Without that link, you know something was sent — not that it was the right thing.

Two axes, not one enum. The structural invariant does the work so your retry logic doesn’t have to remember to.

Top comments (0)