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
)`
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
}`
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:
- Agent dispatches action
- Dispatch times out before confirmation arrives
- System sees no
SUCCESS→ marks as failed - 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
}
`
`
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)
}
}
`
`
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
)`
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)