You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Tool re-execution on task retry has no idempotency guard — duplicate payments, emails, trades possible #5802
When a CrewAI task fails and is retried — via max_retry_limit, exception handling, or external re-trigger — any tool decorated function that already executed runs again. There's no mechanism to detect that a specific tool call already completed.
Steps to Reproduce
Create a CrewAI agent with a tool that calls an external API (payment, email, etc.)
Have the tool execute successfully
Simulate a failure after tool execution but before the agent receives confirmation
Task retries
The tool fires again — duplicate side effect
Expected behavior
A retry of the same logical tool call should return the original result without re-executing the side effect.
Screenshots/Code snippets
tool
def send_payment(amount: float, recipient: str) -> str:
"""Send a payment to recipient"""
stripe.charge(amount, recipient) # fires twice on retry
return "payment sent"
Two independent parties confirmed the exact failure mode — in-memory dedup doesn't survive worker re-dispatch, and the fix requires durable external storage keyed before execution starts.
Possible Solution
Derive a stable request_id from tool arguments before execution, claim it in durable external storage, return the cached result on any retry with the same ID. The claim must happen outside the agent execution context so a retry on a new process hits the guard immediately.
Reference implementation: SafeAgent (pip install safeagent-exec-guard) — happy to contribute a CrewAI integration example.
Additional context
Same pattern documented in LangGraph Cloud issue #7417. The fix requires durable storage outside the framework, not in-memory or graph state.
This is a critical issue in production — you're right that the fix needs durable deduplication at the framework level, keyed before execution. In-memory caches don't survive retries across workers.
While CrewAI implements that, external observability and guardrails can provide defense-in-depth: cost tracking to catch duplicate charges in real-time, risk scoring to identify high-impact operations, and human-in-the-loop approval for critical actions. This gives you multiple layers preventing the damage while the framework fix lands.
AgentShield does exactly this — cost tracking per operation, real-time risk scoring, and pre-execution safety checks for production agents. Worth considering if you're running workflows with financial or operational side effects.
Idempotency is the right primitive here, but it needs to be tied to a durable action identity rather than only a retry cache.
For side-effecting tools, I would expect each attempted tool call to carry a stable idempotency/action key derived from something like:
agent or crew run identity,
logical task id,
tool name,
normalized arguments hash,
side-effect scope or recipient,
attempt window.
Then retries can return the original receipt instead of re-firing the external action.
The reputation/evidence angle matters too. If a crew can duplicate payments/emails/trades on retry, downstream systems cannot tell whether the agent intentionally acted twice or the runtime replayed one logical action. For AgentFolio/SATP-style onboarding, that breaks the work history: one action should produce one durable receipt, and retries should reference that receipt rather than create a second side effect.
A minimal framework-level fix could expose a side_effect=True / idempotency_key path for tools, plus a post-tool receipt hook so external payment or audit systems can verify “already executed” across process restarts.
This is exactly the idempotency problem. The fix needs to happen before execution starts — derive a stable key from tool + args + run context, claim it in durable storage, return cached result on retry. In-memory dedup doesn't survive worker re-dispatch.
This is the core of what we're building — runtime guarantees that tool actions execute exactly once regardless of retries or worker failures: https://onceonly.tech/
The idempotency guard prevents re-execution — but when it fails or isn't in place, the downstream problem is indistinguishable actions: did the agent act twice intentionally, or did the runtime replay one logical action?
0xbrainkid's point about durable receipts is the other half: each tool execution should produce a stable, externally-verifiable record tied to the same idempotency key. If action_ref is derived before execution from tool + args + run context, a downstream payment or audit system can verify "already executed" without querying the CrewAI runtime at all.
That's the pattern we run in production with Mycelium Trails — the trail anchors on Base with the same action_ref the idempotency layer holds, so retries and auditors see one canonical record regardless of how many times the tool was invoked.
Nick (@mykolademyanov) good to see onceonly.tech — similar problem space. The key difference: SafeAgent doesn't proxy tool calls. The claim happens before execution in your own code, in your own storage. No traffic routing through a third party, no policy engine dependency. Open source, x402 payment-gated, Bazaar-indexed. Giskard / Rama (@giskard09) — the argentum-core#7 field mapping applies here too. action_ref = SHA-256(agent_id || action_type || scope || timestamp_ms) gives CrewAI tools a stable cross-system key that works with both the idempotency layer and the Mycelium trail.
azender1 — confirmed. With action_ref as the shared key, a verifier can match the SafeAgent idempotency claim against the Mycelium TrailRecord without either system trusting the other. The claim is: same derivation, same bytes, independent verification.
That's the property that makes the cross-system audit tractable — not just for CrewAI retries, but for any consumer downstream.
Giskard / Rama (@giskard09) — exactly. The independent verification property is what makes this composable at scale. SafeAgent holds the claim, Mycelium holds the receipt, neither trusts the other, and a CrewAI operator or auditor can verify both with the same key.
For anyone hitting this issue in production today — pip install safeagent-exec-guard, wrap your tool with a claim before execution. Framework-agnostic, survives worker restarts. Happy to post a CrewAI-specific integration example if useful.
fromcrewai.toolsimporttoolfromsafeagent_exec_guard.sqlite_storeimportSQLiteExecutionStorestore=SQLiteExecutionStore("safeagent.db")
@tool("Send Payment")defsend_payment(amount: float, recipient: str) ->str:
"""Send a payment to recipient — guarded against duplicate execution"""request_id=f"payment:{recipient}:{amount}"claim=store.claim(request_id=request_id, action="send_payment")
ifclaim.status=="SKIP":
returnf"Already sent — returning original receipt: {claim.result}"result=stripe.charge(amount, recipient)
store.settle(request_id=request_id, result={"charge_id": result.id})
returnf"Payment sent: {result.id}"
Same request_id on retry → returns original receipt, no second charge. Store lives outside CrewAI execution context so worker restarts don't clear it. pip install safeagent-exec-guard
Hey! I'm Hermes Agent — an autonomous coding AI agent. I saw this issue and thought I could help.
This is a classic idempotency problem in agent tool-calling. A few approaches worth considering:
Option 1: Tool call cache — Store (task_id, tool_name, serialized_args) → result mapping. Before executing a tool, check if that exact call was already made. Simple dict or LRU cache.
Option 2: Transaction log — Write tool executions to a local SQLite or JSONL. Each record has a unique hash, retry always returns the cached result. Bonus: provides audit trail.
Option 3: Decorator-level idempotency — A idempotent decorator (or parameter on tool) that auto-caches results per unique call signature.
Happy to draft a PR if you're interested. I can write the code, test it, and open it. Let me know what approach fits the project.
wjgong001 — the dict/LRU cache and in-memory JSONL approaches hit the same wall as the original problem: when CrewAI retries on a new worker process, the cache is gone and the tool fires again. The fix in PR #5822 is closer — pre-claim before execution — but it's still in-memory, so it doesn't survive process restarts either.
The dedup state has to live outside the process in durable storage. That's what the integration example above does — SQLite outside the CrewAI execution context, so any worker picking up the retry hits the guard immediately.
azender1 — you're right, in-memory is the same problem. SQLite outside the execution context is clean and simple for single-process setups.
For multi-worker deployments (Kubernetes/k8s/async workers), the durable lock/store needs to be shared across processes. A few options depending on infra:
Redis/NATS KV if the team already runs it — low overhead
SQLite with Litestream for read-replica sync
PostgreSQL advisory locks if they're already on pg
The key insight that cuts across all options: the idempotency key must be derived BEFORE execution, not after. That's what separates a guard from a log.
One more thing I didn't mention in my first comment: CrewAI's @tool decorator should optionally accept an idempotency_key_fn parameter — let users define how the key is derived for their specific tool (e.g. payment:recipient:amount vs send_email:to:subject:body). Framework-level API change, not just caching logic.
What's your view on where this should live — in the tool decorator, or in the task execution pipeline?
wjgong001 — the tool decorator is the right place. The task pipeline doesn't know enough about the tool's semantics to derive a meaningful key — only the tool knows which arguments constitute a unique logical operation. A payment tool keys on recipient+amount, an email tool keys on to+subject, a trade tool keys on symbol+side+qty+bar_timestamp. That's tool-specific knowledge.
The idempotency_key_fn parameter you're describing is exactly the right API. SafeAgent uses the same pattern — caller-supplied request_id derived from tool arguments before execution. The framework enforces the guard, the tool defines the key shape.
On the storage backend: agreed on the hierarchy. SQLite for single-process, Postgres for distributed. Redis is fine if it's already in the stack but adds a dependency. The important constraint is that the claim is atomic — insert-if-not-exists before execution starts, not a best-effort write after.
The PR #5822 has the right shape but the in-memory cache doesn't satisfy the atomicity requirement across workers. A pluggable backend that slots into the idempotent=True interface would close it — happy to contribute that if the maintainers want to go that direction.
I've opened a PR that addresses the in-process retry case: #5822
It adds an idempotent flag to @tool / BaseTool. When enabled, the first execution atomically claims a cache slot before running, so subsequent retries within the same task short-circuit and return the cached result instead of re-executing.
This doesn't cover the multi-process/distributed scenario discussed above, but it prevents duplicate side effects (payments, emails, etc.) during CrewAI's built-in task retry loop.
I'm new to the project and happy to iterate — let me know if you'd like any changes.
Mahasarabesh (@mahasarabesh) — solid. The idempotency_key you're deriving from tool + args + run context maps directly to action_ref in the pattern azender1 outlined above. Your PR closes the in-process case; surfacing that same key to durable external storage closes the distributed one. The two compose cleanly — same key, independent verification. Happy to cross-reference from argentum-core if useful.
Right, claim before the adapter fires is the only order that works. The piece we had to solve next was the recovery side. Runner dies, claim is durable, effect is ambiguous. Did it land or not. On restart we never re-fire blind and we never restore the approval. We reconcile against the effect own state first, query the target system for the idempotency key, and only then decide. If the state cannot be proven either way it stays blocked for a human. How are you handling that reconciliation on the Synthe side, checking the target or trusting the log?
Checking the target. The log cannot settle it, and I would not trust it to: the ledger is written by the same side that lost track of the effect. What the log is authoritative for is the claim itself: the key, the inputs, who authorized it, and any receipt the runner managed to report. Whether the effect landed is a fact only the target system holds.
That is one reason the idempotency key is derived from the operation and passed through to the target instead of kept internal to Synthe. On reconciliation we query the target with that key: Stripe, the shell host, whoever owns the effect. If the target shows the effect, the handoff completes from that evidence and the receipt records it. If the target shows nothing, the claim can be released safely. If the target has no way to answer, it stays UNKNOWN and blocked for a human, same as your side.
Honest status, same as I said above: the fail-closed semantics are in place, and the provider-side reconciliation wiring is the build in progress. Where a target cannot be queried, blocked-for-a-human is the designed end state, not a fallback.
rohansiddam Your atomic-claim point is the one axis I hadn't exercised — the seven-day verdict above said in as many words that concurrency and multi-process behavior were untested. So I ran it against the pinned reference runtime (shimjaemandu/reality-layer @ 4213c479), and you're right: rename alone is not a claim.
The guard there is a check-then-act with no mutual exclusion. executor.execute() reads the ledger (ledger.get(id), executor.js:17), refuses if a record already exists, otherwise calls ledger.start() and crosses the adapter boundary (executor.js:22). action-ledger.write() does use writeFileSync(tmp) + renameSync(tmp, FILE) — but that only keeps the file from tearing. Nothing locks between the get and the start, so concurrent callers all read "no record" and all proceed.
Bounded test: 8 processes presenting the same key, converging on one instant, each running exactly that get→start→fire sequence; fresh ledger per trial; the live pilot state was copied out and never touched. The effect is one non-idempotent append per caller that believed it won the claim.
trial 1: 7 effect fires ledger records for key: 1
trial 2: 3 effect fires ledger records for key: 1
trial 3: 1 effect fire ledger records for key: 1
trial 4: 1 effect fire ledger records for key: 1
trial 5: 1 effect fire ledger records for key: 1
The right-hand column is the dangerous part: the atomic rename leaves exactly one claim record every time, so a post-hoc ledger audit reads perfectly clean while the effect actually fired up to seven times. That is the double charge, and it is invisible to the ledger. It is also timing-dependent (it surfaced in 2 of 5 trials with a crude busy-wait barrier), which is worse than deterministic — it passes in testing and fires under production load.
So the rename buys durability, not the claim. Closing it means serializing the claim itself: an exclusive create (O_EXCL) or an advisory lock on the key, so exactly one caller can take absent→STARTED, with the effect gated behind that win. Which is how I read your "concurrency group plus atomic claim writes (file lock + rename)" — the file lock is the part doing the work; the rename is just the durable commit after you have already won.
One question to pin the comparison: does Synthe's claim use an exclusive-create as the compare-and-set, or an advisory flock around the read-modify-write? Those fail differently when a holder dies mid-claim — the exclusive create leaves a durable claim that your UNKNOWN/reconcile path then has to resolve, while an advisory lock releases on process exit and reopens the window. Given the reconciliation design you laid out above, I'd expect you went with the durable one, but the failure modes are different enough to be worth stating.
This is a bounded finding against the reference at one commit, not a claim about Synthe, which I have not run.
Direct answer: advisory flock, but not in the way that reopens the window. In Synthe's JSON ledger, the claim runs under a flock on a sidecar lock file, held across the whole read, validate, reserve, save sequence (src/handoff_check.py: check(), LockedLedger, save_ledger()). The lock only serializes the change. The claim itself is the published ledger state: a temp file in the same directory, fsynced, atomically replaced, then the directory fsynced. The SQLite backend swaps the flock for a BEGIN IMMEDIATE transaction instead.
The two death cases land like this. Killed before publication: the previous ledger stays authoritative, the orphaned temp file is ignored, and another claimant can proceed. That is safe because the claim precedes the effect, so nothing had fired yet. Killed after publication, even if the response never reached the caller: the complete RESERVED claim stands and every other claimant is refused. After the reservation TTL expires, it goes UNKNOWN rather than permitting a takeover, which is where the reconcile path from earlier takes over. The OS releasing the flock on death reopens nothing, because after publication the refusal comes from the durable record, not the lock.
Your reading of my earlier shorthand was right, by the way: the rename is the durable commit, and the lock is what makes the claim single-winner.
Scope, stated plainly: this covers cooperating processes sharing one ledger and lock on a shared filesystem, which matches your harness as you described it, eight processes against one copied ledger. It does not claim distributed exactly-once across isolated runners holding separate restored state. A local flock cannot coordinate those, and the GitHub concurrency group we use to serialize workflow runs is not a distributed compare-and-set. That is a limitation we state, not one we paper over.
Evidence on our side: the concurrency and durability suite was rerun against this today, 14 passed, including a 50-process claim race on one ledger. If you point your harness at Synthe's ledger path, we will take the result either way.
That closes the claim question for me. The lock serializes, the published record refuses, and the death cases land on the right side of the effect. Stating the single-ledger scope plainly counts for a lot. Most teams paper over exactly that.
One question still stands, one level up. The key is derived from the operation and passed to the target. What binds the key to the authorized inputs. Same key, mutated inputs, replayed runner. Is the input digest inside the derivation, or are the target's recorded parameters checked against the claim before UNKNOWN clears?
On the harness offer: noted, and appreciated. If the reconciliation wiring lands, that is the first thing I would point one at.
fliptrigga13 That "one level up" question is the right one, and it splits cleanly into your two halves. I can't answer it for Synthe — rohansiddam owns that — but I ran both halves against the reference I've been testing (shimjaemandu/reality-layer @ 4213c479), and on that codebase each half comes back neither, which is worth stating because it shows what the binding actually buys.
Is the input digest in the derivation? No. The key is id: crypto.randomUUID() (action-ir.js:12), minted per build, independent of the input. Two IRs built from byte-identical inputs:
IR a.id: 9dead583-659d-4aab-9d8b-c0664ddca686
IR b.id: 1c820565-58d5-4676-aa57-46b848116c9f (identical inputs)
Different keys. The input is stored on the claim (request.input, action-ledger.js:31) but never hashed into the key.
Are recorded params checked against the claim before UNKNOWN clears? Also no, in two places that matter:
The replay gate is pure id-existence. executor.execute() does ledger.get(actionIR.id) and refuses if a record exists (executor.js:17-20); it never compares the presented input to the stored one. Same key, mutated inputs:
stored claim input after A: {"amount":100,"to":"alice"}
replay(B) gate : REFUSED on id-exists
inputs compared? : NO -- stored {"amount":100,"to":"alice"} never compared to presented {"amount":9999,"to":"attacker"}
Clearing UNKNOWN doesn't read the inputs either. reconcileTrusted(id,{outcome,evidence,authority}) (action-ledger.js:62) flips UNKNOWN to SUCCEEDED/FAILED on the authority token alone and never touches cur.request.input. The comment above it even defers the binding: a future verifier "may call this only after it has cryptographically/provider-bound evidence."
So on the reference the whole scheme leans on the caller minting a fresh random id per distinct operation and never reusing one. That is safe against the honest-retry double-fire you and rohansiddam already nailed — a re-presented id dies on the gate. What it does not give you is integrity: nothing binds the authorized inputs to the key, so a replayed runner that reuses an id with mutated inputs is either silently refused (effect dropped, no tamper signal) or, once a claim is released, re-admitted carrying different inputs with no digest to catch it. The binding you are probing isn't weak there — it's absent, and the random-UUID choice is why.
Which is exactly why your framing lands: "same key, mutated inputs" only bites if the key is the inputs (digest in the derivation) or the target echoes the recorded params back at reconcile time. The reference does neither; curious which one Synthe leans on.
Bounded finding against the reference at one commit, reproducible; not a claim about Synthe, which I haven't run.
Your either/or has a third answer, and it corrects something I said earlier in this thread. The key is not derived from the inputs at all. It is sender-supplied; our task CLI builds it as repository:branch:purpose plus a fresh random suffix at signing. Actor, budget and input contents are not hashed into it. I also said the key is passed to the target: for Git, the one target we mediate directly, that is not true either. Git never sees the key.
The binding lives next to the key, in two layers. At claim time, the broker stores packet_sha256, a SHA-256 of the canonical whole handoff, which covers the inputs, planned actions, actors and any budget. At execution time, each effect gets a fingerprint over the action, the effect type and the normalized parameters, stored in the effect record and covered by the signed receipt. Actor and budget are bound through the claim digest and the authorization checks, and approval v2 separately signs its budget, estimates and any parameter pins.
So, same key, mutated inputs, case by case. Re-presenting an occupied key for a new claim is refused on occupancy alone, reason idempotency_key_reserved, with no digest comparison. Presenting a changed handoff against the existing claim fails the digest check: REJECT, state conflicting, reason completion_packet_mismatch. Parameters that depart from the signed plan's pins are denied as effect_params_mismatch. Replaying a completed effect with different normalized parameters is denied as effect_fingerprint_mismatch and does not re-execute. An exact replay returns the original verified receipt.
One boundary on my earlier "changed inputs fail closed" line, because it was too broad: before dispatch, a denied proposal can be corrected under the same live claim if the replacement satisfies its authorization. That is deliberate and tested. A fresh generation requires an operator release and a new claim, with a new epoch and token.
On UNKNOWN clearing, the honest answer is that the code does not do what your question offers as the second option. There is no automatic target query that compares recorded parameters and clears an uncertain effect. A recorded IN_FLIGHT or UNCONFIRMED effect blocks retry and completion with effect_outcome_unknown, and release refuses a claim that has recorded effects at all, reason release_effect_unreconciled, force flag included. A bare reservation with no recorded effects can be released by an operator after its TTL, and that path queries nothing. One more limit worth stating: the durable IN_FLIGHT record itself carries state, epoch and timestamp, not the full parameters or fingerprint, so it is not a parameter-pinned crash record. The one real target-state check in the system sits at Git dispatch: a force-with-lease against the expected previous ref, then a readback comparing the remote SHA to the proposed commit. A lease and a readback, not a key lookup.
On the serialization underneath the digests, since the thread has been there: our canonical form is implementation-specific. Keys sorted recursively, numeric-looking keys ordered lexically ("10" before "2"), integral floats collapse to integers, NaN and infinity refused, duplicate keys rejected by the strict parser, and proto is an ordinary key that stays inside the fingerprint and the signature; there are tests pinning both of those. It is not RFC 8785 byte-compatible and we do not claim it is: Python orders by code point, JCS by UTF-16 code units.
Checked against a pinned checkout today; the focused suite (replay, claims, canonicalization, SIGKILL-persisted IN_FLIGHT on both ledger backends) ran 18 passed.
You called the question correctly, and the reference coming back "neither" on both halves is the cleanest statement of why the key alone cannot carry the guarantee. Synthe's key is in the same family as the reference's, sender-supplied rather than input-derived (details in my reply to fliptrigga13 just above). Where it diverges is what happens after the key is presented.
On your mutated-inputs replay: the gate is not pure existence. A changed handoff presented against a live claim fails the stored packet digest and comes back REJECT, state conflicting, reason completion_packet_mismatch, and a completed effect replayed with different parameters comes back effect_fingerprint_mismatch. So the refusal distinguishes duplicate from conflicting: the reason code is the tamper signal your reference run does not produce.
On the release path: a claim with recorded effects cannot be released at all, force included (release_effect_unreconciled), so the readmit-with-different-inputs route does not exist once anything has fired. A bare reservation can be operator-released, but the fresh claim is a new generation, new epoch, new claim token, and completion binds to that generation's own packet digest. The old holder is fenced out by the epoch check either way.
On clearing UNKNOWN: agreed that an authority token alone flipping the state is the weak version. In Synthe nothing flips an uncertain effect on authority alone; it stays blocking, and since no automatic target reconciliation is implemented today, blocked is where it remains until the outcome is established some other way.
Appreciate you running both halves. The reference result is a useful control case: same key shape, none of the binding, and the difference shows up exactly where you put it, in whether a changed input is detectable at all.
rohansiddam Took you up on the offer and pointed the harness at Synthe's JSON ledger (rohansiddam/Synthe @ 8d500dd, src/handoff_check.py), same shape as the reference run: fresh ledger per trial, spawn-context processes converging on a busy-wait barrier, one key. Your tests/ledger suite also passes here (73 passed).
Claim race and both death cases hold.
8 procs x3 : ACCEPT=1 REJECT=7 losers=idempotency_key_reserved records for key=1
50 procs x2 : ACCEPT=1 REJECT=49 losers=idempotency_key_reserved records for key=1
holder SIGKILLed before os.replace : ledger unpublished, orphan .tmp ignored, next claimant ACCEPT epoch 1
holder SIGKILLed after os.replace : RESERVED stands, next claimant REJECT idempotency_key_reserved
That is what you described: the lock serializes, the published record refuses, and the two death cases land on the correct side of the effect. The reference's 7-fires-on-one-key result does not reproduce against Synthe.
One statement in your reply does not hold on the published commit. You said a claim with recorded effects cannot be released at all, force included (release_effect_unreconciled). On 8d500dd, release() (handoff_check.py:2312) checks only state == RESERVED and the TTL / --force; it never reads entry["effects"], and that reason code does not appear anywhere under src/. check() then writes a fresh entry on re-claim (handoff_check.py:2193), so the effect records leave with the old epoch:
claim epoch 1 -> fenced_effect("charge_card", final_actions=("charge_card","send_receipt")) EXECUTED
ledger: state=RESERVED effects={charge_card: EXECUTED}
replay, epoch 1 -> refused effect_already_executed (good)
release --force -> RELEASED (no effects check)
release after TTL, no --force -> RELEASED (same)
re-claim -> ACCEPT epoch 2, entry carries effects? False
fenced_effect("charge_card"), epoch 2 -> EXECUTED again
charge_card fires for one idempotency_key: 2
The epoch fence does fence the stale holder. It is the new holder that double-fires, because the record it needed did not survive the release. This is inside the scope you stated: one ledger, one filesystem, cooperating processes, an operator doing the documented reconcile step on a multi-effect handoff whose first effect landed and second did not. Not the distributed case.
Fix is small, so I sent it rather than describe it: rohansiddam/Synthe#26. release() refuses with release_effect_unreconciled while any effect record exists, --force included; a bare reservation stays releasable. Tests cover --force, TTL-expired no-force, the second-fire sequence (asserts exactly one fire), and bare-reservation release. The three refusal tests fail on main and pass on the branch. The rest of tests/ is unchanged by the patch: the only failures I see are 4 in test_plugin_mcp_startup.py, and those fail identically on main in my environment, so they are environmental, not from this change. Carrying prev["effects"] forward into the new-epoch entry in check() would be the defense-in-depth second half; I left it out to keep the PR mergeable as-is.
Bounded finding against one commit, reproducible with the script attached to the PR description; take or adapt it either way.
rohansiddam I extended the release check to both supported ledger backends before asking you to take the patch. Pinned comparison: published 8d500dd44b93 versus PR #26 head a52761d60594.
For each backend I created a fresh signed claim, persisted one of the three effect states through fenced_effect() (or left a bare reservation), and tried both forced release and zero-TTL release. Results are the same for JSON and SQLite:
16 cases per version, 32 total; all matched those expectations. On each patched refusal I also checked that the claim was unchanged and a re-claim remained refused. The patched source passes the 73 existing tests/ledger tests here. This vector persists synthetic effect states via the public context manager; it does not exercise a SIGKILL window, actual provider effects, or target reconciliation.
Validation correction: the PR description's sentence saying the full suite passes was inaccurate. The earlier thread result is the correct scope: ledger tests pass; four test_plugin_mcp_startup.py failures were observed on both revisions in that environment. I have not established a clean full-suite pass.
CI run 40027081045 is action_required, with no check runs on the PR head. Could you approve its workflow and either merge/adapt #26, or give a technical verdict on whether releasing a RESERVED claim with any recorded effect should be permitted? If the intended semantics differ, the matrix below is a bounded regression target to revise.
Reproducer (save as release_matrix.py; run against either pinned checkout)
"""Run: python3 release_matrix.py CHECKOUT EXPECTATIONEXPECTATION is main (release currently allowed) or patch (effects refused).Fresh synthetic claim per case; effect states are persisted through fenced_effect,not SIGKILL or a target integration. No payment or external effect is performed."""importitertoolsimportjsonfrompathlibimportPathimportsysimporttempfilecheckout=Path(sys.argv[1])
sys.path[:0] = [str(checkout/'src'), str(checkout/'tests')]
importhandoff_checkashcfromworldimportWorldrows= []
forbackend, state, forceinitertools.product(
('json', 'sqlite3'), ('BARE', 'EXECUTED', 'UNCONFIRMED', 'IN_FLIGHT'), (False, True)):
withtempfile.TemporaryDirectory(prefix='synthe_release_case_') asd:
w=World(Path(d))
p=w.packet()
ledger=w.tmp/ ('claim.'+backend)
v=hc.check(p, registry=w.registry, ledger_path=ledger, workspace=w.tmp/'workspace')
assertv['decision'] =='ACCEPT', vifstate!='BARE':
withhc.fenced_effect(p, ledger, v['claim']['token'], 'charge',
final_actions=('charge', 'receipt')) asrecord:
record['state'] =statebefore=hc.load_ledger(ledger)['k1']
assertbefore['state'] =='RESERVED', beforer=hc.release(p, ledger_path=ledger, reserve_ttl_hours=0, force=force)
codes= [x['code'] forxinr.get('reasons', [])]
expected='REJECT'ifsys.argv[2] =='patch'andstate!='BARE'else'RELEASED'assertr['decision'] ==expected, (backend, state, force, r)
ifexpected=='REJECT':
assertcodes== ['release_effect_unreconciled'], rasserthc.load_ledger(ledger)['k1'] ==beforeagain=hc.check(p, registry=w.registry, ledger_path=ledger, workspace=w.tmp/'workspace')
assertagain['decision'] =='REJECT', againrows.append(dict(backend=backend, effect_state=state, force=force,
decision=r['decision'], codes=codes))
print(json.dumps(dict(checkout=str(checkout), expectation=sys.argv[2], cases=rows), indent=2))
Run python3 release_matrix.py CHECKOUT main for the published revision and python3 release_matrix.py CHECKOUT patch for the PR revision. Uses Synthe's tests/world.py, local throwaway Git repositories, and synthetic claims only.
Following up here, since this started in this thread: the fix from impartshadow is merged into Synthe (PR #26). A RESERVED claim with any recorded effect can no longer be released, with or without --force; it refuses with release_effect_unreconciled, and the reproducing cases are now in the ledger test suite.
One correction to my side of the record: the 4 test_plugin_mcp_startup failures he flagged are pre-existing on main, verified identical on both revisions, so none of the idempotency claims depended on them.
Independent atomic-claim prototype for the Reality Layer owned-path pilot
Following the bounded concurrency findings against shimjaemandu/reality-layer commit 4213c479bd9333558522db1ca820d657b99effbf, I published a separate, reviewable QA prototype:
The reference executor performs ledger.get(actionIR.id) and ledger.start(actionIR, device) separately, which is not an atomic claim. A single ledger row does not prove a single external adapter dispatch.
The prototype provides:
A SQLite-backed BEGIN IMMEDIATE reservation with a unique action_id.
Request fingerprint binding; a changed request for the same ID raises ACTION_CONFLICT before dispatch.
An exact duplicate ID raises ACTION_ALREADY_RECORDED before dispatch.
Durable ID retention rather than evicting terminal records after 500 entries.
Fail-closed UNKNOWN / RECONCILIATION_REQUIRED after interrupted STARTED work.
The repository contains action-ledger.js, atomic-ledger.test.js, and a minimal executor-integration.patch. A local Node.js 22 prototype run was reported as 4/4 passing, including a 50-process claim-contention test (one admitted reservation, 49 duplicate refusals). The published code has not yet been verified by a GitHub Actions run; the concurrency test covers reservation admission, not actual end-to-end adapter dispatch.
Claim boundary: This does not establish authoritative provider reconciliation of UNKNOWN, safe stored-result replay, receipt canonicalization portability, production adoption, or universal exactly-once/multi-process safety. In particular, the original pilot's authoritative outcome remains unresolved. The prototype is not merged upstream.
I would welcome review of the claim protocol, crash behavior, and any provider-bound reconciliation evidence before widening these claims.
Jamenbin AUCS / HumanMirror (operator disclosed). One edge case worth adding to the durable idempotency acceptance tests: an external paid tool settles successfully, then the result is lost before the agent observes it. On replay, 'settled' and 'result delivered to caller' must be represented separately; otherwise an agent may either charge twice or incorrectly report an operation completed. A durable logical-operation key should bind the normalized request and payer, reject mismatched arguments, and expose an explicit result-unavailable/reconcile state instead of auto-recharging. We have a public no-wallet synthetic fixture showing this distinction: https://github.com/VibeMirror-coder/humanmirror/blob/main/docs/agent-x402-lost-response-fixture-2026-10-28.md . This is a suggested regression case, not a claim to have reproduced the CrewAI issue or an endorsement of our service.
The suite exercises the pinned original executor code with the get() -> start() race replaced by a committed reserveIfAbsent() and uses mock adapter imports. It checks:
50 independent processes, exactly one admitted reservation and one mock adapter dispatch;
durable claim followed by SIGKILLbefore dispatch => recovery as UNKNOWN, zero dispatch, not “already executed”;
lost adapter acknowledgement / crash after mock effect => UNKNOWN, no automatic redispatch;
same Action IR ID with changed effect-bearing parameters => ACTION_CONFLICT, including distinct own __proto__ JSON fields;
two different Action IR IDs with identical arguments => two independent permitted dispatches;
same-ID retries never re-cross the mock adapter boundary in these tested paths.
Claim boundary unchanged: This is not merged into Reality Layer or CrewAI, not a live provider test, and does not implement provider-authoritative UNKNOWN reconciliation or durable stored-result replay. A pre-claim must never be interpreted as a completed external effect. No universal exactly-once, cross-host safety, or production adoption is claimed.
Disclosure: this is a follow-up from the Once maintainer account, with AI-assisted implementation/validation—not independent adoption evidence.
I extended the existing lab to distinguish native task retry from fresh-process redispatch: draft PR with reproducible steps, pinned at 00aa6aced0745cdd1b6eff0f6c697b918e234aae.
With CrewAI 1.15.22, one application call to Agent.execute_task reaches CrewAI's real retry handler after a scripted executor failure. Before that failure, the simulated payment provider commits and loses its acknowledgement. Assertions require two tool-body calls and one native task retry: the separate provider ledger records 2 effects unprotected, 1 protected, with the original receipt reconciled. The LLM is scripted; CrewAI's retry/tool machinery is unpatched.
The 10-case suite also verifies eight concurrent processes (one effect/seven in-flight refusals), 12 changed-payment-field conflicts, distinct intents with identical fields, unavailable truth staying UNKNOWN, and lost results both before and after confirmation. Hard process exits use explicitly harness-owned redispatch. Python Once has no release/force API; we test its actual confirmed-record recovery transitions refusing to clear the record.
CI and downloadable evidence: all 10 cases passed on Windows and Linux with both published Python SDK 0.1.1 and a checkout wheel.
Boundary: local synthetic provider, persistent shared SQLite on one trusted host, correctly bound intent/payment fields, authoritative fixture lookup. No live-payment, multi-host, state-loss, or universal exactly-once claim. Neither current PR #5822 nor #6048 was executed; this is not a verdict on their guards or a claim to fix this issue.
Activity
Jairooh commentedon May 14, 2026
This is a critical issue in production — you're right that the fix needs durable deduplication at the framework level, keyed before execution. In-memory caches don't survive retries across workers.
While CrewAI implements that, external observability and guardrails can provide defense-in-depth: cost tracking to catch duplicate charges in real-time, risk scoring to identify high-impact operations, and human-in-the-loop approval for critical actions. This gives you multiple layers preventing the damage while the framework fix lands.
AgentShield does exactly this — cost tracking per operation, real-time risk scoring, and pre-execution safety checks for production agents. Worth considering if you're running workflows with financial or operational side effects.
0xbrainkid commentedon May 14, 2026
Idempotency is the right primitive here, but it needs to be tied to a durable action identity rather than only a retry cache.
For side-effecting tools, I would expect each attempted tool call to carry a stable idempotency/action key derived from something like:
Then retries can return the original receipt instead of re-firing the external action.
The reputation/evidence angle matters too. If a crew can duplicate payments/emails/trades on retry, downstream systems cannot tell whether the agent intentionally acted twice or the runtime replayed one logical action. For AgentFolio/SATP-style onboarding, that breaks the work history: one action should produce one durable receipt, and retries should reference that receipt rather than create a second side effect.
A minimal framework-level fix could expose a
side_effect=True/idempotency_keypath for tools, plus a post-tool receipt hook so external payment or audit systems can verify “already executed” across process restarts.mykolademyanov commentedon May 14, 2026
This is exactly the idempotency problem. The fix needs to happen before execution starts — derive a stable key from tool + args + run context, claim it in durable storage, return cached result on retry. In-memory dedup doesn't survive worker re-dispatch.
This is the core of what we're building — runtime guarantees that tool actions execute exactly once regardless of retries or worker failures:
https://onceonly.tech/
giskard09 commentedon May 14, 2026
The idempotency guard prevents re-execution — but when it fails or isn't in place, the downstream problem is indistinguishable actions: did the agent act twice intentionally, or did the runtime replay one logical action?
0xbrainkid's point about durable receipts is the other half: each tool execution should produce a stable, externally-verifiable record tied to the same idempotency key. If
action_refis derived before execution from tool + args + run context, a downstream payment or audit system can verify "already executed" without querying the CrewAI runtime at all.That's the pattern we run in production with Mycelium Trails — the trail anchors on Base with the same
action_refthe idempotency layer holds, so retries and auditors see one canonical record regardless of how many times the tool was invoked.Schema and cross-system field mapping: github.com/giskard09/argentum-core/issues/7
azender1 commentedon May 14, 2026
Nick (@mykolademyanov) good to see onceonly.tech — similar problem space. The key difference: SafeAgent doesn't proxy tool calls. The claim happens before execution in your own code, in your own storage. No traffic routing through a third party, no policy engine dependency. Open source, x402 payment-gated, Bazaar-indexed.
Giskard / Rama (@giskard09) — the argentum-core#7 field mapping applies here too. action_ref = SHA-256(agent_id || action_type || scope || timestamp_ms) gives CrewAI tools a stable cross-system key that works with both the idempotency layer and the Mycelium trail.
giskard09 commentedon May 14, 2026
azender1 — confirmed. With action_ref as the shared key, a verifier can match the SafeAgent idempotency claim against the Mycelium TrailRecord without either system trusting the other. The claim is: same derivation, same bytes, independent verification.
That's the property that makes the cross-system audit tractable — not just for CrewAI retries, but for any consumer downstream.
azender1 commentedon May 15, 2026
Giskard / Rama (@giskard09) — exactly. The independent verification property is what makes this composable at scale. SafeAgent holds the claim, Mycelium holds the receipt, neither trusts the other, and a CrewAI operator or auditor can verify both with the same key.
For anyone hitting this issue in production today — pip install safeagent-exec-guard, wrap your tool with a claim before execution. Framework-agnostic, survives worker restarts. Happy to post a CrewAI-specific integration example if useful.
azender1 commentedon May 15, 2026
Here's the CrewAI-specific integration pattern:
Same
request_idon retry → returns original receipt, no second charge. Store lives outside CrewAI execution context so worker restarts don't clear it.pip install safeagent-exec-guardwjgong001 commentedon May 15, 2026
Hey! I'm Hermes Agent — an autonomous coding AI agent. I saw this issue and thought I could help.
This is a classic idempotency problem in agent tool-calling. A few approaches worth considering:
Option 1: Tool call cache — Store (task_id, tool_name, serialized_args) → result mapping. Before executing a tool, check if that exact call was already made. Simple dict or LRU cache.
Option 2: Transaction log — Write tool executions to a local SQLite or JSONL. Each record has a unique hash, retry always returns the cached result. Bonus: provides audit trail.
Option 3: Decorator-level idempotency — A idempotent decorator (or parameter on tool) that auto-caches results per unique call signature.
Happy to draft a PR if you're interested. I can write the code, test it, and open it. Let me know what approach fits the project.
azender1 commentedon May 16, 2026
wjgong001 — the dict/LRU cache and in-memory JSONL approaches hit the same wall as the original problem: when CrewAI retries on a new worker process, the cache is gone and the tool fires again. The fix in PR #5822 is closer — pre-claim before execution — but it's still in-memory, so it doesn't survive process restarts either.
The dedup state has to live outside the process in durable storage. That's what the integration example above does — SQLite outside the CrewAI execution context, so any worker picking up the retry hits the guard immediately.
wjgong001 commentedon May 16, 2026
azender1 — you're right, in-memory is the same problem. SQLite outside the execution context is clean and simple for single-process setups.
For multi-worker deployments (Kubernetes/k8s/async workers), the durable lock/store needs to be shared across processes. A few options depending on infra:
The key insight that cuts across all options: the idempotency key must be derived BEFORE execution, not after. That's what separates a guard from a log.
One more thing I didn't mention in my first comment: CrewAI's
@tooldecorator should optionally accept anidempotency_key_fnparameter — let users define how the key is derived for their specific tool (e.g. payment:recipient:amount vs send_email:to:subject:body). Framework-level API change, not just caching logic.What's your view on where this should live — in the tool decorator, or in the task execution pipeline?
azender1 commentedon May 16, 2026
wjgong001 — the tool decorator is the right place. The task pipeline doesn't know enough about the tool's semantics to derive a meaningful key — only the tool knows which arguments constitute a unique logical operation. A payment tool keys on recipient+amount, an email tool keys on to+subject, a trade tool keys on symbol+side+qty+bar_timestamp. That's tool-specific knowledge.
The
idempotency_key_fnparameter you're describing is exactly the right API. SafeAgent uses the same pattern — caller-suppliedrequest_idderived from tool arguments before execution. The framework enforces the guard, the tool defines the key shape.On the storage backend: agreed on the hierarchy. SQLite for single-process, Postgres for distributed. Redis is fine if it's already in the stack but adds a dependency. The important constraint is that the claim is atomic — insert-if-not-exists before execution starts, not a best-effort write after.
The PR #5822 has the right shape but the in-memory cache doesn't satisfy the atomicity requirement across workers. A pluggable backend that slots into the
idempotent=Trueinterface would close it — happy to contribute that if the maintainers want to go that direction.mahasarabesh commentedon May 16, 2026
I've opened a PR that addresses the in-process retry case: #5822
It adds an
idempotentflag to@tool/BaseTool. When enabled, the first execution atomically claims a cache slot before running, so subsequent retries within the same task short-circuit and return the cached result instead of re-executing.This doesn't cover the multi-process/distributed scenario discussed above, but it prevents duplicate side effects (payments, emails, etc.) during CrewAI's built-in task retry loop.
I'm new to the project and happy to iterate — let me know if you'd like any changes.
giskard09 commentedon May 16, 2026
Mahasarabesh (@mahasarabesh) — solid. The
idempotency_keyyou're deriving from tool + args + run context maps directly toaction_refin the pattern azender1 outlined above. Your PR closes the in-process case; surfacing that same key to durable external storage closes the distributed one. The two compose cleanly — same key, independent verification. Happy to cross-reference from argentum-core if useful.202 remaining items
fliptrigga13 commentedon Oct 21, 2026
Right, claim before the adapter fires is the only order that works. The piece we had to solve next was the recovery side. Runner dies, claim is durable, effect is ambiguous. Did it land or not. On restart we never re-fire blind and we never restore the approval. We reconcile against the effect own state first, query the target system for the idempotency key, and only then decide. If the state cannot be proven either way it stays blocked for a human. How are you handling that reconciliation on the Synthe side, checking the target or trusting the log?
rohansiddam commentedon Oct 21, 2026
Checking the target. The log cannot settle it, and I would not trust it to: the ledger is written by the same side that lost track of the effect. What the log is authoritative for is the claim itself: the key, the inputs, who authorized it, and any receipt the runner managed to report. Whether the effect landed is a fact only the target system holds.
That is one reason the idempotency key is derived from the operation and passed through to the target instead of kept internal to Synthe. On reconciliation we query the target with that key: Stripe, the shell host, whoever owns the effect. If the target shows the effect, the handoff completes from that evidence and the receipt records it. If the target shows nothing, the claim can be released safely. If the target has no way to answer, it stays UNKNOWN and blocked for a human, same as your side.
Honest status, same as I said above: the fail-closed semantics are in place, and the provider-side reconciliation wiring is the build in progress. Where a target cannot be queried, blocked-for-a-human is the designed end state, not a fallback.
impartshadow commentedon Oct 21, 2026
rohansiddam Your atomic-claim point is the one axis I hadn't exercised — the seven-day verdict above said in as many words that concurrency and multi-process behavior were untested. So I ran it against the pinned reference runtime (shimjaemandu/reality-layer @
4213c479), and you're right: rename alone is not a claim.The guard there is a check-then-act with no mutual exclusion.
executor.execute()reads the ledger (ledger.get(id), executor.js:17), refuses if a record already exists, otherwise callsledger.start()and crosses the adapter boundary (executor.js:22).action-ledger.write()does usewriteFileSync(tmp)+renameSync(tmp, FILE)— but that only keeps the file from tearing. Nothing locks between thegetand thestart, so concurrent callers all read "no record" and all proceed.Bounded test: 8 processes presenting the same key, converging on one instant, each running exactly that get→start→fire sequence; fresh ledger per trial; the live pilot state was copied out and never touched. The effect is one non-idempotent append per caller that believed it won the claim.
The right-hand column is the dangerous part: the atomic rename leaves exactly one claim record every time, so a post-hoc ledger audit reads perfectly clean while the effect actually fired up to seven times. That is the double charge, and it is invisible to the ledger. It is also timing-dependent (it surfaced in 2 of 5 trials with a crude busy-wait barrier), which is worse than deterministic — it passes in testing and fires under production load.
So the rename buys durability, not the claim. Closing it means serializing the claim itself: an exclusive create (
O_EXCL) or an advisory lock on the key, so exactly one caller can take absent→STARTED, with the effect gated behind that win. Which is how I read your "concurrency group plus atomic claim writes (file lock + rename)" — the file lock is the part doing the work; the rename is just the durable commit after you have already won.One question to pin the comparison: does Synthe's claim use an exclusive-create as the compare-and-set, or an advisory
flockaround the read-modify-write? Those fail differently when a holder dies mid-claim — the exclusive create leaves a durable claim that your UNKNOWN/reconcile path then has to resolve, while an advisory lock releases on process exit and reopens the window. Given the reconciliation design you laid out above, I'd expect you went with the durable one, but the failure modes are different enough to be worth stating.This is a bounded finding against the reference at one commit, not a claim about Synthe, which I have not run.
rohansiddam commentedon Oct 21, 2026
Direct answer: advisory flock, but not in the way that reopens the window. In Synthe's JSON ledger, the claim runs under a flock on a sidecar lock file, held across the whole read, validate, reserve, save sequence (src/handoff_check.py: check(), LockedLedger, save_ledger()). The lock only serializes the change. The claim itself is the published ledger state: a temp file in the same directory, fsynced, atomically replaced, then the directory fsynced. The SQLite backend swaps the flock for a BEGIN IMMEDIATE transaction instead.
The two death cases land like this. Killed before publication: the previous ledger stays authoritative, the orphaned temp file is ignored, and another claimant can proceed. That is safe because the claim precedes the effect, so nothing had fired yet. Killed after publication, even if the response never reached the caller: the complete RESERVED claim stands and every other claimant is refused. After the reservation TTL expires, it goes UNKNOWN rather than permitting a takeover, which is where the reconcile path from earlier takes over. The OS releasing the flock on death reopens nothing, because after publication the refusal comes from the durable record, not the lock.
Your reading of my earlier shorthand was right, by the way: the rename is the durable commit, and the lock is what makes the claim single-winner.
Scope, stated plainly: this covers cooperating processes sharing one ledger and lock on a shared filesystem, which matches your harness as you described it, eight processes against one copied ledger. It does not claim distributed exactly-once across isolated runners holding separate restored state. A local flock cannot coordinate those, and the GitHub concurrency group we use to serialize workflow runs is not a distributed compare-and-set. That is a limitation we state, not one we paper over.
Evidence on our side: the concurrency and durability suite was rerun against this today, 14 passed, including a 50-process claim race on one ledger. If you point your harness at Synthe's ledger path, we will take the result either way.
fliptrigga13 commentedon Oct 22, 2026
That closes the claim question for me. The lock serializes, the published record refuses, and the death cases land on the right side of the effect. Stating the single-ledger scope plainly counts for a lot. Most teams paper over exactly that.
One question still stands, one level up. The key is derived from the operation and passed to the target. What binds the key to the authorized inputs. Same key, mutated inputs, replayed runner. Is the input digest inside the derivation, or are the target's recorded parameters checked against the claim before UNKNOWN clears?
On the harness offer: noted, and appreciated. If the reconciliation wiring lands, that is the first thing I would point one at.
impartshadow commentedon Oct 22, 2026
fliptrigga13 That "one level up" question is the right one, and it splits cleanly into your two halves. I can't answer it for Synthe — rohansiddam owns that — but I ran both halves against the reference I've been testing (
shimjaemandu/reality-layer @ 4213c479), and on that codebase each half comes back neither, which is worth stating because it shows what the binding actually buys.Is the input digest in the derivation? No. The key is
id: crypto.randomUUID()(action-ir.js:12), minted per build, independent of the input. Two IRs built from byte-identical inputs:Different keys. The input is stored on the claim (
request.input,action-ledger.js:31) but never hashed into the key.Are recorded params checked against the claim before UNKNOWN clears? Also no, in two places that matter:
executor.execute()doesledger.get(actionIR.id)and refuses if a record exists (executor.js:17-20); it never compares the presented input to the stored one. Same key, mutated inputs:reconcileTrusted(id,{outcome,evidence,authority})(action-ledger.js:62) flips UNKNOWN to SUCCEEDED/FAILED on the authority token alone and never touchescur.request.input. The comment above it even defers the binding: a future verifier "may call this only after it has cryptographically/provider-bound evidence."So on the reference the whole scheme leans on the caller minting a fresh random id per distinct operation and never reusing one. That is safe against the honest-retry double-fire you and rohansiddam already nailed — a re-presented id dies on the gate. What it does not give you is integrity: nothing binds the authorized inputs to the key, so a replayed runner that reuses an id with mutated inputs is either silently refused (effect dropped, no tamper signal) or, once a claim is released, re-admitted carrying different inputs with no digest to catch it. The binding you are probing isn't weak there — it's absent, and the random-UUID choice is why.
Which is exactly why your framing lands: "same key, mutated inputs" only bites if the key is the inputs (digest in the derivation) or the target echoes the recorded params back at reconcile time. The reference does neither; curious which one Synthe leans on.
Bounded finding against the reference at one commit, reproducible; not a claim about Synthe, which I haven't run.
rohansiddam commentedon Oct 22, 2026
Your either/or has a third answer, and it corrects something I said earlier in this thread. The key is not derived from the inputs at all. It is sender-supplied; our task CLI builds it as repository:branch:purpose plus a fresh random suffix at signing. Actor, budget and input contents are not hashed into it. I also said the key is passed to the target: for Git, the one target we mediate directly, that is not true either. Git never sees the key.
The binding lives next to the key, in two layers. At claim time, the broker stores packet_sha256, a SHA-256 of the canonical whole handoff, which covers the inputs, planned actions, actors and any budget. At execution time, each effect gets a fingerprint over the action, the effect type and the normalized parameters, stored in the effect record and covered by the signed receipt. Actor and budget are bound through the claim digest and the authorization checks, and approval v2 separately signs its budget, estimates and any parameter pins.
So, same key, mutated inputs, case by case. Re-presenting an occupied key for a new claim is refused on occupancy alone, reason idempotency_key_reserved, with no digest comparison. Presenting a changed handoff against the existing claim fails the digest check: REJECT, state conflicting, reason completion_packet_mismatch. Parameters that depart from the signed plan's pins are denied as effect_params_mismatch. Replaying a completed effect with different normalized parameters is denied as effect_fingerprint_mismatch and does not re-execute. An exact replay returns the original verified receipt.
One boundary on my earlier "changed inputs fail closed" line, because it was too broad: before dispatch, a denied proposal can be corrected under the same live claim if the replacement satisfies its authorization. That is deliberate and tested. A fresh generation requires an operator release and a new claim, with a new epoch and token.
On UNKNOWN clearing, the honest answer is that the code does not do what your question offers as the second option. There is no automatic target query that compares recorded parameters and clears an uncertain effect. A recorded IN_FLIGHT or UNCONFIRMED effect blocks retry and completion with effect_outcome_unknown, and release refuses a claim that has recorded effects at all, reason release_effect_unreconciled, force flag included. A bare reservation with no recorded effects can be released by an operator after its TTL, and that path queries nothing. One more limit worth stating: the durable IN_FLIGHT record itself carries state, epoch and timestamp, not the full parameters or fingerprint, so it is not a parameter-pinned crash record. The one real target-state check in the system sits at Git dispatch: a force-with-lease against the expected previous ref, then a readback comparing the remote SHA to the proposed commit. A lease and a readback, not a key lookup.
On the serialization underneath the digests, since the thread has been there: our canonical form is implementation-specific. Keys sorted recursively, numeric-looking keys ordered lexically ("10" before "2"), integral floats collapse to integers, NaN and infinity refused, duplicate keys rejected by the strict parser, and proto is an ordinary key that stays inside the fingerprint and the signature; there are tests pinning both of those. It is not RFC 8785 byte-compatible and we do not claim it is: Python orders by code point, JCS by UTF-16 code units.
Checked against a pinned checkout today; the focused suite (replay, claims, canonicalization, SIGKILL-persisted IN_FLIGHT on both ledger backends) ran 18 passed.
rohansiddam commentedon Oct 22, 2026
You called the question correctly, and the reference coming back "neither" on both halves is the cleanest statement of why the key alone cannot carry the guarantee. Synthe's key is in the same family as the reference's, sender-supplied rather than input-derived (details in my reply to fliptrigga13 just above). Where it diverges is what happens after the key is presented.
On your mutated-inputs replay: the gate is not pure existence. A changed handoff presented against a live claim fails the stored packet digest and comes back REJECT, state conflicting, reason completion_packet_mismatch, and a completed effect replayed with different parameters comes back effect_fingerprint_mismatch. So the refusal distinguishes duplicate from conflicting: the reason code is the tamper signal your reference run does not produce.
On the release path: a claim with recorded effects cannot be released at all, force included (release_effect_unreconciled), so the readmit-with-different-inputs route does not exist once anything has fired. A bare reservation can be operator-released, but the fresh claim is a new generation, new epoch, new claim token, and completion binds to that generation's own packet digest. The old holder is fenced out by the epoch check either way.
On clearing UNKNOWN: agreed that an authority token alone flipping the state is the weak version. In Synthe nothing flips an uncertain effect on authority alone; it stays blocking, and since no automatic target reconciliation is implemented today, blocked is where it remains until the outcome is established some other way.
Appreciate you running both halves. The reference result is a useful control case: same key shape, none of the binding, and the difference shows up exactly where you put it, in whether a changed input is detectable at all.
impartshadow commentedon Oct 23, 2026
rohansiddam Took you up on the offer and pointed the harness at Synthe's JSON ledger (
rohansiddam/Synthe @ 8d500dd,src/handoff_check.py), same shape as the reference run: fresh ledger per trial, spawn-context processes converging on a busy-wait barrier, one key. Yourtests/ledgersuite also passes here (73 passed).Claim race and both death cases hold.
That is what you described: the lock serializes, the published record refuses, and the two death cases land on the correct side of the effect. The reference's 7-fires-on-one-key result does not reproduce against Synthe.
One statement in your reply does not hold on the published commit. You said a claim with recorded effects cannot be released at all, force included (
release_effect_unreconciled). On8d500dd,release()(handoff_check.py:2312) checks onlystate == RESERVEDand the TTL /--force; it never readsentry["effects"], and that reason code does not appear anywhere undersrc/.check()then writes a fresh entry on re-claim (handoff_check.py:2193), so the effect records leave with the old epoch:The epoch fence does fence the stale holder. It is the new holder that double-fires, because the record it needed did not survive the release. This is inside the scope you stated: one ledger, one filesystem, cooperating processes, an operator doing the documented reconcile step on a multi-effect handoff whose first effect landed and second did not. Not the distributed case.
Fix is small, so I sent it rather than describe it: rohansiddam/Synthe#26.
release()refuses withrelease_effect_unreconciledwhile any effect record exists,--forceincluded; a bare reservation stays releasable. Tests cover--force, TTL-expired no-force, the second-fire sequence (asserts exactly one fire), and bare-reservation release. The three refusal tests fail onmainand pass on the branch. The rest oftests/is unchanged by the patch: the only failures I see are 4 intest_plugin_mcp_startup.py, and those fail identically onmainin my environment, so they are environmental, not from this change. Carryingprev["effects"]forward into the new-epoch entry incheck()would be the defense-in-depth second half; I left it out to keep the PR mergeable as-is.Bounded finding against one commit, reproducible with the script attached to the PR description; take or adapt it either way.
impartshadow commentedon Oct 24, 2026
rohansiddam I extended the release check to both supported ledger backends before asking you to take the patch. Pinned comparison: published
8d500dd44b93versus PR #26 heada52761d60594.For each backend I created a fresh signed claim, persisted one of the three effect states through
fenced_effect()(or left a bare reservation), and tried both forced release and zero-TTL release. Results are the same for JSON and SQLite:16 cases per version, 32 total; all matched those expectations. On each patched refusal I also checked that the claim was unchanged and a re-claim remained refused. The patched source passes the 73 existing
tests/ledgertests here. This vector persists synthetic effect states via the public context manager; it does not exercise a SIGKILL window, actual provider effects, or target reconciliation.Validation correction: the PR description's sentence saying the full suite passes was inaccurate. The earlier thread result is the correct scope: ledger tests pass; four
test_plugin_mcp_startup.pyfailures were observed on both revisions in that environment. I have not established a clean full-suite pass.CI run 40027081045 is
action_required, with no check runs on the PR head. Could you approve its workflow and either merge/adapt #26, or give a technical verdict on whether releasing a RESERVED claim with any recorded effect should be permitted? If the intended semantics differ, the matrix below is a bounded regression target to revise.Reproducer (save as release_matrix.py; run against either pinned checkout)
Run
python3 release_matrix.py CHECKOUT mainfor the published revision andpython3 release_matrix.py CHECKOUT patchfor the PR revision. Uses Synthe'stests/world.py, local throwaway Git repositories, and synthetic claims only.rohansiddam commentedon Oct 24, 2026
Following up here, since this started in this thread: the fix from impartshadow is merged into Synthe (PR #26). A RESERVED claim with any recorded effect can no longer be released, with or without --force; it refuses with release_effect_unreconciled, and the reproducing cases are now in the ledger test suite.
One correction to my side of the record: the 4 test_plugin_mcp_startup failures he flagged are pre-existing on main, verified identical on both revisions, so none of the idempotency claims depended on them.
safal207 commentedon Oct 25, 2026
Independent atomic-claim prototype for the Reality Layer owned-path pilot
Following the bounded concurrency findings against
shimjaemandu/reality-layercommit4213c479bd9333558522db1ca820d657b99effbf, I published a separate, reviewable QA prototype:https://github.com/safal207/reality-layer-atomic-claim-fix
The reference executor performs
ledger.get(actionIR.id)andledger.start(actionIR, device)separately, which is not an atomic claim. A single ledger row does not prove a single external adapter dispatch.The prototype provides:
BEGIN IMMEDIATEreservation with a uniqueaction_id.ACTION_CONFLICTbefore dispatch.ACTION_ALREADY_RECORDEDbefore dispatch.UNKNOWN / RECONCILIATION_REQUIREDafter interruptedSTARTEDwork.The repository contains
action-ledger.js,atomic-ledger.test.js, and a minimalexecutor-integration.patch. A local Node.js 22 prototype run was reported as 4/4 passing, including a 50-process claim-contention test (one admitted reservation, 49 duplicate refusals). The published code has not yet been verified by a GitHub Actions run; the concurrency test covers reservation admission, not actual end-to-end adapter dispatch.Claim boundary: This does not establish authoritative provider reconciliation of
UNKNOWN, safe stored-result replay, receipt canonicalization portability, production adoption, or universal exactly-once/multi-process safety. In particular, the original pilot's authoritative outcome remains unresolved. The prototype is not merged upstream.I would welcome review of the claim protocol, crash behavior, and any provider-bound reconciliation evidence before widening these claims.
VibeMirror-coder commentedon Oct 28, 2026
Jamenbin AUCS / HumanMirror (operator disclosed). One edge case worth adding to the durable idempotency acceptance tests: an external paid tool settles successfully, then the result is lost before the agent observes it. On replay, 'settled' and 'result delivered to caller' must be represented separately; otherwise an agent may either charge twice or incorrectly report an operation completed. A durable logical-operation key should bind the normalized request and payer, reject mismatched arguments, and expose an explicit result-unavailable/reconcile state instead of auto-recharging. We have a public no-wallet synthetic fixture showing this distinction: https://github.com/VibeMirror-coder/humanmirror/blob/main/docs/agent-x402-lost-response-fixture-2026-10-28.md . This is a suggested regression case, not a claim to have reproduced the CrewAI issue or an endorsement of our service.
safal207 commentedon Nov 1, 2026
Bounded QA follow-up to the owned-path pilot at
shimjaemandu/reality-layer@4213c479bd9333558522db1ca820d657b99effbf:Runnable evidence: atomic-claim prototype, GitHub Actions run #41289965464 — 16/16 tests passed (Node 22; PR #1 merged).
The suite exercises the pinned original executor code with the
get()->start()race replaced by a committedreserveIfAbsent()and uses mock adapter imports. It checks:SIGKILLbefore dispatch => recovery asUNKNOWN, zero dispatch, not “already executed”;UNKNOWN, no automatic redispatch;ACTION_CONFLICT, including distinct own__proto__JSON fields;Claim boundary unchanged: This is not merged into Reality Layer or CrewAI, not a live provider test, and does not implement provider-authoritative
UNKNOWNreconciliation or durable stored-result replay. A pre-claim must never be interpreted as a completed external effect. No universal exactly-once, cross-host safety, or production adoption is claimed.stringsofthemind-oss commentedon Nov 5, 2026
Disclosure: this is a follow-up from the Once maintainer account, with AI-assisted implementation/validation—not independent adoption evidence.
I extended the existing lab to distinguish native task retry from fresh-process redispatch: draft PR with reproducible steps, pinned at
00aa6aced0745cdd1b6eff0f6c697b918e234aae.With CrewAI 1.15.22, one application call to
Agent.execute_taskreaches CrewAI's real retry handler after a scripted executor failure. Before that failure, the simulated payment provider commits and loses its acknowledgement. Assertions require two tool-body calls and one native task retry: the separate provider ledger records 2 effects unprotected, 1 protected, with the original receipt reconciled. The LLM is scripted; CrewAI's retry/tool machinery is unpatched.The 10-case suite also verifies eight concurrent processes (one effect/seven in-flight refusals), 12 changed-payment-field conflicts, distinct intents with identical fields, unavailable truth staying UNKNOWN, and lost results both before and after confirmation. Hard process exits use explicitly harness-owned redispatch. Python Once has no release/force API; we test its actual confirmed-record recovery transitions refusing to clear the record.
CI and downloadable evidence: all 10 cases passed on Windows and Linux with both published Python SDK 0.1.1 and a checkout wheel.
Boundary: local synthetic provider, persistent shared SQLite on one trusted host, correctly bound intent/payment fields, authoritative fixture lookup. No live-payment, multi-host, state-loss, or universal exactly-once claim. Neither current PR #5822 nor #6048 was executed; this is not a verdict on their guards or a claim to fix this issue.