Skip to content

fix(server): return task and workflow handoffs uncached from IdempotencyStore.wrap - #1795

Merged
bokelley merged 2 commits into
mainfrom
claude/issue-1781-idempotency-workflow-handoff-9x
Nov 2, 2026
Merged

bokelley merged 2 commits into
mainfrom
claude/issue-1781-idempotency-workflow-handoff-9x

Conversation

@bokelley

@bokelley bokelley commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

Same change as the next PR, for SDK 9.x: store.py is identical on both branches, and the fix is additive (a handoff used to raise TypeError).

Problem

A seller method that sometimes hands off to a workflow (ctx.handoff_to_workflow(...), e.g. an update_media_buy that needs a person) could not also use IdempotencyStore.wrap. The wrapper coerces every completed result to a dict for the replay cache, and a TaskHandoff / WorkflowHandoff is a control marker for the dispatcher, so the call raised TypeError: 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

  • wrap returns a TaskHandoff / WorkflowHandoff uncached, after the usual key, conflict, lock and cancellation handling. The dispatcher then allocates the task as before.
  • New cache_response= predicate: @store.wrap(cache_response=lambda result: ...) caches a result only when the predicate returns True, 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 for validate_idempotency_wiring.
  • docs/handler-authoring.md explains 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:

  • the issue repro for both handoff types
  • a later completed response being cached and replayed
  • the predicate, in both directions, plus is_wrapped
  • a predicate cannot force a handoff into the cache
  • conflict detection still applies
  • end to end over MCP: a decisioning create_media_buy wrapped with @store.wrap hands off to a workflow, and the buyer gets the framework Submitted envelope, while the synchronous path on the same wrapper still replays

The handoff and end-to-end tests fail without the change. The idempotency, workflow-handoff and public-API suites pass (235 tests); make lint and make typecheck-all pass.

Closes #1781

🤖 Generated with Claude Code

…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>

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]: ...

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — clean additive fix with no blocking or medium findings.

What I checked:

  • IdempotencyStore.wrap now returns TaskHandoff/WorkflowHandoff uncached, returning before _to_dict (store.py:349, the prior TypeError site at store.py:579). This is the correct fix for the handoff-caching crash.
  • An optional cache_response predicate 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.

@bokelley
bokelley merged commit 126f905 into main Nov 2, 2026
47 checks passed
@bokelley
bokelley deleted the claude/issue-1781-idempotency-workflow-handoff-9x branch November 2, 2026 05:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

IdempotencyStore.wrap fails on a WorkflowHandoff, so a method that may hand off to a workflow cannot use the SDK's idempotency

1 participant