AI Agent Run Leases: Stop Stale Workers From Repeating Side Effects A developer outlined a pattern for preventing stale AI agent workers from repeating side effects by combining a durable run lease with a monotonically increasing fencing token. The lease, stored in a database or queue with fields like lease_owner, lease_until and fence, decides which worker may act at a given moment, while the fence is checked at every side-effect boundary so an expired worker's writes are rejected even if it still holds credentials. The writeup recommends sizing leases just above the p99 time between durable checkpoints and passing run_id and fence to downstream services such as email or write proxies. An agent run can look dead, get claimed by a replacement worker, and then wake up just long enough to send the email you thought the replacement was handling. A queue lease helps, but it is not the whole safety boundary. The reliable pattern is a run lease plus a fencing token . The lease decides who may work right now . The fencing token lets the system that performs a side effect reject an older worker even if that worker still has credentials and a network connection. This guide shows how to use both for long-running AI agent jobs. Imagine an agent that prepares and sends a contract summary: r 42 for 60 seconds. Nothing has to be malicious or badly coded for this to happen. A slow garbage-collection pause, a network partition, a container reschedule, or a long tool call is enough. Message systems deliberately make work visible again after a visibility timeout so it can recover. AWS explicitly notes that a consumer that runs past that timeout can cause another consumer to process the same message; at-least-once delivery is a normal constraint, not an edge case AWS guidance https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/designing-for-outage-recovery-scenarios.html . For a read-only task, duplicate work wastes tokens. For a write, it can duplicate a ticket, overwrite a CRM field, or send a second customer message. Treat a run lease as an ownership protocol, not as a timer around a model call. A lease is a time-bounded claim on a specific run. Store the minimum state in a durable database or queue, never only in worker memory. | Field | Why it matters | |---|---| | run id | Stable idempotency and audit key for the whole task | | state | Queued, running, waiting, succeeded, failed, or cancelled | | lease owner | Identifies the process that currently owns the run | | lease until | Allows recovery when that process disappears | | fence | Monotonically increasing generation of ownership | | checkpoint | Durable progress that a replacement can safely resume | | side effect status | Evidence for whether an external action was attempted or confirmed | The core rule is simple: a worker may execute only while its lease is valid and its fence is the current fence for that run. Do not let a worker infer continued ownership because it started the task. Short leases recover quickly but need more renewals. Long leases reduce renewal traffic but leave work unavailable longer after a crash. Start with a duration slightly above the p99 time between durable checkpoints—not the whole run time. For example, a 45-second lease with a 15-second heartbeat works when the agent saves after each tool call or model stage. An eight-minute uncheckpointed tool call does not become safe because the lease is eight minutes; it merely delays recovery. The same trade-off appears in managed queues. Azure Service Bus returns a message for redelivery when its lock expires, and supports renewal for active processing Microsoft documentation https://learn.microsoft.com/en-us/azure/service-bus-messaging/message-transfers-locks-settlement . Use that behavior as a delivery primitive, then implement application-level ownership around agent state. A lease alone protects the scheduler. It does not protect a downstream API from a stale worker. Each successful claim increments a fence number: php Worker A claims r 42 - fence 17 Lease expires Worker B claims r 42 - fence 18 Worker A wakes up - must be rejected Pass run id and fence to every internal side-effect boundary. That boundary stores the highest fence it has accepted for the run and rejects lower values. This is called fencing because each replacement owner raises a barrier behind it. The critical comparison belongs where the effect happens, not only in the orchestrator. A stale worker can miss a failed heartbeat, keep an old HTTP client alive, or retry after a process restart. The email service, write proxy, or domain transaction must be able to say: “fence 17 is older than the accepted fence 18; no action.” Here is a compact PostgreSQL-style claim. The transaction makes availability, ownership, and the fence update one decision. UPDATE agent runs SET state = 'running', lease owner = :worker id, lease until = now + interval '45 seconds', fence = fence + 1, updated at = now WHERE run id = :run id AND state IN 'queued', 'waiting', 'running' AND lease until IS NULL OR lease until < now RETURNING run id, fence, lease until, checkpoint; If no row returns, do not work. Another worker owns the run, the run is terminal, or it is not ready. Avoid a read-then-write claim; two workers can both observe an expired timestamp before either writes. When a worker resumes from a checkpoint, record the checkpoint version and only move forward. Replaying a model call is usually acceptable. Repeating an external write is not. Split planning from committing so the side effect has its own durable operation record. Renewal must be conditional on the owner and fence you originally acquired. A timer that extends any lease on the row can revive an owner that has already been replaced. type Lease = { runId: string; workerId: string; fence: number }; async function renewLease db: Db, lease: Lease : Promise