Repository navigation
fix(server): return task and workflow handoffs uncached from IdempotencyStore.wrap - #1795
Conversation
…ncyStore.wrap A method that may hand off to a workflow could not use the SDK's idempotency: IdempotencyStore.wrap coerced every completed result to a dict for the replay cache, and a TaskHandoff or WorkflowHandoff is a control marker for the dispatcher, not a response, so the call raised "Cannot coerce WorkflowHandoff to dict for idempotency caching" (#1781). Adopters copied the private wrapper to work around it. wrap now returns a handoff uncached, after the key, conflict, lock and cancellation handling, so the dispatcher allocates the task as usual. It also accepts cache_response=, a predicate a seller can use to keep its own control results out of the cache: @store.wrap(cache_response=...) caches a result only when the predicate returns True, and a handoff is never cached. Both forms register the wrapper for validate_idempotency_wiring. An uncached result stores no replay entry, so a retry with the same key runs the method again; the docs say to deduplicate the enqueued work on the key. Replaying the task id the dispatcher allocated needs dispatcher support and is left for a follow-up. Closes #1781 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean, additive fix for #1781 with no blocking or medium findings.
Reviewed the idempotency handoff fix: IdempotencyStore.wrap now returns TaskHandoff/WorkflowHandoff uncached (store.py:386-389) instead of raising TypeError on _to_dict coercion, and adds an optional cache_response= predicate via an overloaded decorator-factory (store.py:57-97). The early return sits inside _execute_locked's _hold, so the lock still releases; handoffs short-circuit the predicate matching the documented "a handoff is never cached"; both decorator forms register in _WRAPPED_FUNCTIONS; conflict detection is preserved because the cache read precedes handler execution. Tests are thorough (230 lines covering both handoff types, later-response caching, predicate both directions, predicate-cannot-cache-a-handoff, conflict detection, MCP end-to-end). Commit is fix(server): — purely additive and backward compatible, no semver signal issue.
Decision-table trace: no critical/high findings (row 1 n/a); gated_paths false (row 2 n/a); high_risk is true but the only reason is a (modified) file with NO medium-or-higher finding, so rows 3/5 do not fire; no medium findings in data-loss/schema/infra (row 4 n/a); no prior decision (row 6 n/a); no no-auto-approve team match (row 7 n/a); fewer than three medium findings (row 8 n/a). Falls through to row 9 → approve. Note: high_risk is a heuristic LOOK signal, not an auto-escalate — a modified sensitive file with no medium concern is presumed safe.
| @overload | ||
| def wrap( | ||
| self, handler: HandlerFn, *, cache_response: CacheResponsePredicate | None = None | ||
| ) -> HandlerFn: ... |
| @overload | ||
| def wrap( | ||
| self, handler: None = None, *, cache_response: CacheResponsePredicate | None = None | ||
| ) -> Callable[[HandlerFn], HandlerFn]: ... |
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean additive fix with no blocking or medium findings.
What I checked:
IdempotencyStore.wrapnow returnsTaskHandoff/WorkflowHandoffuncached, returning before_to_dict(store.py:349, the priorTypeErrorsite at store.py:579). This is the correct fix for the handoff-caching crash.- An optional
cache_responsepredicate is added with backward-compatible overloads; handoff short-circuits the predicate, and conflict/hold/cancellation handling all precede the new branch — preserving the existing contract. - Tests cover both handoff types, the predicate in both directions, conflict detection, and an end-to-end MCP handoff.
fix(server):is the correct semver signal for a non-breaking bug fix; no public-surface removal/rename/type change, so no breaking-prefix requirement triggers.
High-risk flag: src/adcp/server/idempotency/store.py is (modified) and matches a high-risk glob, but the reviewer surfaced no medium-or-higher concerns, so the modification is presumed safe (row 5 does not fire without a medium finding).
Gates: gated_paths is false; no no-auto-approve team match. review_decision is REVIEW_REQUIRED but gated_paths is false, so row 2 does not apply. Prior decision was approve with no findings — consistent with this clean pass.
Decision table: rows 1–8 do not fire (no critical/high findings, gated_paths false, no deletions, no medium findings, no team gate). Falls through to row 9 → approve.
Same change as the
nextPR, for SDK 9.x:store.pyis identical on both branches, and the fix is additive (a handoff used to raiseTypeError).Problem
A seller method that sometimes hands off to a workflow (
ctx.handoff_to_workflow(...), e.g. anupdate_media_buythat needs a person) could not also useIdempotencyStore.wrap. The wrapper coerces every completed result to a dict for the replay cache, and aTaskHandoff/WorkflowHandoffis a control marker for the dispatcher, so the call raisedTypeError: Cannot coerce WorkflowHandoff to dict for idempotency caching. ESA keeps a hand-copied wrapper (registered in the private_WRAPPED_FUNCTIONS) to work around it.Fix
wrapreturns aTaskHandoff/WorkflowHandoffuncached, after the usual key, conflict, lock and cancellation handling. The dispatcher then allocates the task as before.cache_response=predicate:@store.wrap(cache_response=lambda result: ...)caches a result only when the predicate returnsTrue, so a seller can keep its own control results out of the cache. A handoff is never cached, whatever the predicate says. Both decorator forms register the wrapper forvalidate_idempotency_wiring.docs/handler-authoring.mdexplains the retry semantics. An uncached result stores no replay entry, so a retry with the same key runs the method again, and the enqueued work should deduplicate on the key.Not in scope: replaying the task id the dispatcher allocated for the first call. The dispatcher deliberately rejects a plain-dict Submitted envelope (
_FrameworkSubmittedProjection), so this needs dispatcher support and is left as a follow-up.Tests
tests/test_idempotency_handoff.py:is_wrappedcreate_media_buywrapped with@store.wraphands off to a workflow, and the buyer gets the framework Submitted envelope, while the synchronous path on the same wrapper still replaysThe handoff and end-to-end tests fail without the change. The idempotency, workflow-handoff and public-API suites pass (235 tests);
make lintandmake typecheck-allpass.Closes #1781
🤖 Generated with Claude Code