Skip to Field Notes content
Field Note 01512 min readLeer en español

Task recovery does not make email delivery exactly-once

A persisted lease can recover a background task without making an external effect exactly-once. A 50-run fault-injection test separates task ownership, HTTP requests, and accepted effects.

01 / What the lease recovers

What the lease recovers

Mastra's background-task recovery and an email provider's delivery state are separate state machines. A worker can lose ownership after a process crash while the destination has already accepted its request. A lease coordinates workers that share task storage; it cannot roll back an effect committed by another system.

In @mastra/core@1.72.0, a running task records an owner and lease expiry (ownerId and leaseExpiresAt). The owner renews that lease while it works. If the process disappears, another worker can reclaim the task after the lease expires. The matching storage adapter must persist and enforce those fields; the core package cannot provide that boundary by itself.

The difficult interval is not the time before the request. It is the gap after a destination accepts an operation but before Mastra saves the tool result and marks the task complete. If the worker dies there, storage says the task is unfinished while the destination may already have acted. Recovery has to make progress from the state it can observe, not from a transaction it never shared.

That leaves two different questions:

  • Task ownership: which worker may execute and complete this task now?

  • External effect: has the destination already applied this logical operation?

A lease answers the first question. It does not answer the second. A recovered task can therefore be working as designed and still issue a request that causes a duplicate effect.

Two state machines, one failure window {#failure-window}

The experiment makes the boundary visible by using separate databases: one local libSQL file for Mastra's task state and one SQLite file for a synthetic HTTP receiver. The worker can commit the task result only after the tool returns; the receiver can commit its effect before that happens. Neither database can atomically commit or undo the other's write.

Conceptual sequence: Mastra gives the task to worker A, the external receiver accepts effect one, A is killed before task completion is stored, and worker B recovers the task and causes effect two without destination idempotency
Explanatory schematic based on the experiment protocol, not a captured run. The two stores do not share a transaction; timing is not to scale.

This is a schematic, not a screen capture or a claim that every provider behaves this way. It describes the boundary S4 deliberately tested. If the receiver instead recognizes a stable idempotency key, it can accept B's repeated HTTP request as a duplicate without applying the logical effect a second time. The request still happened; the destination's behavior changed.

02 / A controlled 50-run experiment

A controlled 50-run experiment

The question was narrow: after interruption, does a second worker respect the current owner, and what happens if the external action was already accepted? The fixed protocol defines ten scenarios, each run five times. The experiment used two separate Node worker processes sharing one local libSQL file. Each worker had its own in-process pubsub, so coordination between workers came from persisted state, startup recovery and lease expiry—not a shared in-memory message bus.

Frame from the explainer video: Worker A and Worker B cards, one shared libSQL task table with owner and lease empty, and a fake email provider whose request and email counters are both zero
The experiment before any task runs, as drawn in the explainer video made for this test: two worker processes, one shared libSQL file, and a fake provider that counts requests and emails separately. A render composed from the experiment, not a screen recording. Local test, synthetic data.

Tasks were enqueued directly through mastra.backgroundTaskManager.enqueue() and run by a static executor registered with registerStaticExecutor(). The tool, send_receipt, posted to a local HTTP receiver and then waited 2,500 ms before returning. That delay preserved the interval in which the receiver could have acted but the task result had not yet been saved. The receiver used its own SQLite database. It recorded every HTTP request separately from each accepted effect. In keyed scenarios, a unique idempotency key and a transaction made the first request for that key effective; later requests with the same key were recorded as duplicates.

No actual email was sent. The receiver was a controlled stand-in, not a provider integration. No model was called and no paid inference occurred. This tests task and destination state transitions; it does not test an agent's judgment or a real email service's delivery rules.

The tested pair was @mastra/core@1.72.0 with @mastra/libsql@1.24.0. A compatibility control paired the same core with the older @mastra/libsql@1.23.3, whose declared peer range allowed installation without warning. The task lease was 4,000 ms, the heartbeat interval was approximately 1,333 ms, maxRetries was 1, and the task timeout was 60 seconds. The host was an Apple M5 with 24 GB RAM, macOS Darwin 27.0 and Node v26.0.0.

The run protocol was fixed before the five-repetition pass. All 50 runs were classified as observed: each had the runtime and receiver evidence needed for classification. A Rust build ran on the same machine during part of the experiment. Takeover times are therefore measurements from this loaded local machine, not timing guarantees or estimates of a production failure rate.

03 / What the ten scenarios show

What the ten scenarios show

The table reports requests received and effects accepted per run; every scenario had five runs. An effect counts only when the synthetic receiver recorded it. A and B are separate worker processes.

ScenarioSetup or interruptionRequestsEffectsObservation across five runs
S1One worker; no interruption11Control
S2B starts while A runs a 9 s task11B did not execute; A renewed its lease six times
S3Kill A before it sends11B took over 4,291–4,371 ms after interruption
S4Kill A after receiver acceptance; no key22A's accepted effect was followed by a second effect from B
S5Same boundary as S4; stable operationId key21B's request was recognized as a duplicate
S6Freeze A after acceptance; let B finish; resume A21The stored result remained B's
S7Send SIGTERM to A after task start11Lease release let B take over in 281–285 ms
P1Older adapter; key enabled; B starts while A runs21No owner or lease persisted; B ran while A was alive
P2Older adapter; keyed stale-owner case21B ran at startup; the stored result remained B's
P3Older adapter; no key and no crash22Both workers were eligible and both effects were accepted

The current core-and-adapter pair respected a live lease in S2 and recovered ownership after interruption in S3–S6. The exact takeover window varied by scenario: with a 4,000 ms lease, S4's five measured takeovers were 3,979–4,007 ms after interruption; graceful release in S7 reduced that to 281–285 ms. These are observed elapsed times, not the configured lease duration expressed as a service-level guarantee.

Run S3-1 in the explainer video: A is killed before it sends, B takes over about four seconds later, and the provider ends with one request and one email. The lease countdown is replayed on a compressed clock between the kill and the takeover; the owner IDs and expiry values are the recorded ones. Silent excerpt. Open video

The outcome counts also separate facts that are easy to collapse into a single “retry” number. S4 and S5 each made two requests per run; their effect counts differed because only S5's destination enforced the key. In all recovered runs, retry_count stayed at zero, including runs with two tool executions. That field did not tell us how many times the tool called the receiver.

The storage adapter is part of the guarantee {#adapter}

The older-adapter scenarios are not merely a comparison of two version numbers. They test whether the storage layer actually participates in the ownership protocol. In this experiment, core 1.72.0 installed alongside @mastra/libsql@1.23.3 without a peer warning, but the task rows did not persist an owner or lease. B could therefore run while A was still alive.

Terminal frame: npm ls lists @mastra/core 1.72.0 next to @mastra/libsql 1.23.3, installed with no peer warning; the 1.23.3 peer range is 1.68.0-0 up to below 2.0.0-0
The npm ls output from the older-adapter install, as shown in the explainer video. Core 1.72.0 falls inside the older adapter's declared peer range, so the pair installs without a warning.

P1 enabled the destination key. It recorded two requests and one effect; B ran while A was active, and the stored result came from A. P2 used the keyed stale-owner setup: B ran at startup, two requests reached the receiver, one effect was accepted, and the stored result came from B. The key prevented a second effect in those tests, but it did not restore the missing task-ownership state.

P3 removed the key and did not kill either worker. B still ran while A was alive; the receiver recorded two requests and two effects. Duplicate external work therefore did not require a process crash when the adapter failed to enforce ownership. In the tested pair, by contrast, S2's live owner kept B from executing.

Run P3-1 in the explainer video: older adapter, no key, nothing crashed. The task row shows status running with ownerId NULL while both workers run it, and the provider counts 2 requests and 2 emails
Run P3-1 as drawn in the video. With the older adapter the task row holds no owner, so Worker B runs the task while A is still alive, and the provider accepts two emails without any crash.

The practical check is compatibility across the boundary: verify that the selected adapter persists the fields and conditional ownership updates required by the core version. An install that completes quietly is not proof that the adapter implements the protocol. Check the task row and observed worker executions, rather than inferring lease enforcement from the core package version alone.

04 / One S4 run, event by event

One S4 run, event by event

The explainer video for this test follows run S4-1. A clip and two frames show the same boundary: A's request is accepted, A is killed, B starts about four seconds later and the provider counts a second email. The clip's camera tracking is a visual treatment; the event log it follows is the recorded one.

Run S4-1 in the explainer video: the camera locks onto the recorded event log until A's request is accepted and A is killed, then Worker B runs the tool again. The tracking effect is a cinematic treatment; the log lines are the recorded ones. Silent excerpt. Open video
Run S4-1 as drawn in the video: the final mastra_background_tasks row beside the recorded events.jsonl, where A sends with key none, the harness sends SIGKILL, and B starts at +4.730 s and sends again
The task row of run S4-1 (status completed, result.worker B, result.effectId 2, retry_count 0) beside its event log: A's send, the SIGKILL, B's attempt at +4.730 s, and B's second send with key none.
Run S4-1 final state as drawn in the video: Worker A killed, Worker B re-ran the tool, the task row reads completed with retry_count 0 and no record of A's send, and the provider shows 2 requests and 2 emails
Final state of S4-1 in the video: the task record contains nothing that says the first email went out, and the provider counted 2 requests and 2 emails. The pill reports the scenario result: 2 emails in 5 of 5 runs.

The frames above come from S4-1. The figure below opens a different repetition, S4-2, with exact times instead of an invented example: it is recorded as runs/S4-2/record.json, and the plotted t values are milliseconds relative to that run's start. All five S4 runs produced two requests and two effects.

Recorded S4 repetition 2: A's request is accepted, A receives SIGKILL, B starts after the lease expires, and the receiver accepts a second effect
Data visualization of the actual S4-2 record, not a screenshot. Relative milliseconds from run start; no idempotency key. This figure shows one repetition, not the five-run aggregate.

Inspect the sanitized event extract behind the timeline. The extract omits local process and owner identifiers; event names, relative times, worker labels, acceptance results and effect IDs are copied from the recorded run.

At t=711 ms, A sent the first request without an idempotency key. At t=721 ms, the recorded response was accepted as effect 1. The orchestrator sent SIGKILL at t=743 ms—22 ms after that response—and A exited three milliseconds later. The receiver's accepted effect was already outside the task store. Killing A could not retract it.

B began its attempt at t=4,731 ms, 3,988 ms after the interruption. B sent its request at t=5,033 ms; at t=5,055 ms, the receiver accepted effect 2 because no key was present. B finished at t=7,556 ms, and its result was stored. The task's final retryCount was still 0. In this run, recovery succeeded: B completed the task. The duplicate was also real in the receiver's recorded state.

This trace does not replace the five-run result or imply a universal timing. It demonstrates the precise order of events in one repetition. The aggregate result for S4 comes from all five recorded runs; the visual timeline makes one of them inspectable.

05 / Idempotency changes the effect count, not the request count

Idempotency changes the effect count, not the request count

S4 and S5 deliberately place the crash at the same boundary. In S4, the receiver accepted A's request and A was killed before the task result was saved. B recovered the task and sent the operation again. With no destination key, the receiver accepted effect 2. That happened in all five repetitions.

In S5, both attempts sent the same stable operationId as Idempotency-Key. The receiver still saw two HTTP requests in every run, because the key does not prevent a worker from making another call. It accepted only one effect and marked B's request as a duplicate in all five runs. The experiment therefore supports a narrower claim than “the task ran once”: the destination's implemented deduplication reduced two requests to one accepted effect.

Code frame from the explainer video: experiment/worker.mjs lines 34 to 39, where the idempotency-key header carries operationId when the scenario enables keys
experiment/worker.mjs, lines 34–39, as shown in the video: when the scenario enables keys, the request carries operationId as the idempotency-key header, and the request log records the key or null. The same operation ID is used on every attempt.
Run S5-1 as drawn in the video: same crash and same key, the response is marked duplicate true, and the provider shows 2 requests and 1 email
S5-1 in the video: same crash, same key. B's response is marked duplicate: true; the provider counted 2 requests and 1 email, in 5 of 5 runs.

The scope of that observation matters. The test receiver used a unique key and an atomic insert; it was designed to honor the key. Real providers define their own key scope, retention period, conflict handling and response behavior. A key that expires before a retry, changes between attempts, or is ignored by the destination does not establish the same result. The logical operation ID must remain stable across attempts, and its semantics must match the provider's contract.

S6 tested another independent property. A was paused after acceptance, B was allowed to recover and finish, and then A resumed. With a stable key, the receiver recorded two requests but one effect; the stored task result remained B's in every repetition. Idempotency limited the external effect. Persisted ownership prevented the superseded worker from replacing the completed result. Neither fact substitutes for the other.

06 / What to measure in an integration

What to measure in an integration

Treat task recovery, outbound calls and destination effects as separate observables. The experiment's retry_count stayed at zero while the receiver saw two requests and, in S4, two effects. A counter that describes one internal retry path is not a substitute for counting task attempts or external outcomes.

For a real email integration, keep a stable operation ID for the logical message and pass it as the provider's idempotency key on every attempt. Confirm the provider's documented key scope and retention instead of assuming that a header has universal semantics. A retry should not create a new logical operation ID simply because a different worker owns the task.

Validate the failure window directly: first verify that a second worker does not run while a compatible adapter records a live owner; then interrupt after the provider accepts but before the task result is persisted. Observe, separately, the task attempts, HTTP requests, provider responses and delivered effects. Also inspect the persisted owner and final result after a stale worker resumes. These checks correspond to different invariants; passing one does not prove the others.

The source reviewed for this experiment also distinguishes recovery policy from key behavior: stale-task redispatch requires maxRetries > 0; with maxRetries = 0, the task is marked failed rather than redispatched. The 50 runs all used maxRetries = 1, so that zero-retry behavior is a source-level finding, not an outcome measured in this run set.

07 / Reproduce the experiment

Reproduce the experiment

The fixed protocol describes the system, destination behavior, scenarios, evidence collected and classification rules. The machine-readable aggregate contains the five-run results for all ten scenarios. It does not include the raw per-run databases and logs; the linked S4-2 event extract is a small, sanitized view of one recorded run.

The runner, worker, receiver, analyzer and package manifests are available as runner, worker, receiver, analyzer, current-adapter manifest and older-adapter manifest. The .mjs.txt and .py.txt suffixes serve code as downloadable text; rename them to .mjs and .py in the directory layout described by the protocol.

Use Node v26.0.0 and record the versions actually resolved when repeating the run. The manifests pin direct dependencies, not a transitive lockfile. The runner deletes and recreates the matching runs/<scenario>-<rep> directory, so run it in a disposable working copy rather than beside evidence you need to retain. The analyzer produces a summary from the run records; keep the request count distinct from accepted effects when checking its output.

08 / What the result does—and does not—show

What the result does—and does not—show

The lease answers which worker may own and complete a task when the matching adapter persists the ownership boundary. Destination idempotency answers whether a destination that honors a stable key has already applied the logical operation. In this controlled test, the lease coordinated task execution; the receiver's key reduced accepted effects while leaving request counts at two. Calling task recovery itself “exactly-once delivery” would combine two guarantees the experiment kept separate.

The evidence covers one local libSQL adapter, one synthetic receiver, one Apple M5 machine, Node v26.0.0 and five repetitions per scenario. It is enough to show consistent behavior in these runs, not to estimate a production failure rate. No real email, provider, CRM, model, durable-agent recovery, multiple hosts or other storage adapters were tested. The receiver's idempotency behavior was implemented specifically for the experiment; other destinations may have different semantics.

The useful result is a design boundary, not a universal delivery claim: recover task ownership with a compatible adapter, and separately make the external operation safe to repeat using semantics the destination actually supports. Then test and observe both boundaries. The Mastra release and adapter change in PR #24841 describe the implementation context; the attached protocol and measurements show what this particular test did.

09 / Source trail

Primary, fresh, inspectable.

Product details move quickly. These sources were checked for this issue on October 14, 2026. Re-verify before changing production policy.

  1. 01@mastra/core 1.72.0 releaseMastraRelease notes for persisted background-task ownership and lease-based recovery.
  2. 02Background-task recovery and adapter support, PR #24841MastraImplementation and adapter changes reviewed for the experiment.

Weekly signal · biweekly email

Field-tested ideas. No content treadmill.

One substantial note when we have something worth showing: systems, receipts, prompts, and what failed. Confirm by email. Unsubscribe whenever you like.

Signup opens when the production Turnstile site key is configured.