Skip to content

feat(environments): add defer_ack to work poller to fix dispatch-mode strand (#1746) - #1751

Open
PranavMishra28 wants to merge 1 commit into
anthropics:mainfrom
PranavMishra28:fix/poller-defer-ack-dispatch-strand
Open

feat(environments): add defer_ack to work poller to fix dispatch-mode strand (#1746)#1751
PranavMishra28 wants to merge 1 commit into
anthropics:mainfrom
PranavMishra28:fix/poller-defer-ack-dispatch-strand

Conversation

@PranavMishra28

@PranavMishra28 PranavMishra28 commented Jul 11, 2026

Copy link
Copy Markdown

What was broken

In the poller's drain-and-dispatch shape (auto_stop=False — the webhook/handoff pattern the docstring describes), iter_work / aiter_work ack a claimed item (transitioning it queued → starting) before yielding it to the process that will actually run it. If that handoff process dies before its first heartbeat, the item is left in starting with a null heartbeat — and poll(reclaim_older_than_ms=…) only reclaims unacknowledged work, so the item is reachable by neither reclaim nor lease-TTL requeue. It's permanently stranded. (This is issue #1746, reported with 29 real items stuck in starting.)

Why not a nack

The issue proposed either a deferred-ack mode or a nack/fail API. The Work resource exposes only poll / ack / stop / heartbeat — there is no nack/fail endpoint — so a true nack can't be implemented client-side. That leaves deferred ack.

The fix

Adds an opt-in defer_ack: bool = False to iter_work / aiter_work and the public poller() entry point:

  • Default (False) — exact current behavior (ack before yield). Fully backward-compatible; no existing caller changes.
  • True — the poller yields the item still unacknowledged (queued); the consumer / handoff owner performs the ack (and stop) when it commits to processing. A handoff that dies before acking now leaves the item recoverable via reclaim_older_than_ms / lease TTL instead of stranded.
  • Guarddefer_ack=True requires auto_stop=False (the dispatch pattern where the consumer owns the lifecycle); pairing it with auto_stop=True raises ValueError, since the poller can't stop an item whose ack it deferred. Validated before the first poll, sync and async.

How it's tested

Uses the existing FakeWork / FakeAsyncWork harness in tests/lib/environments/test_poller_method.py — no live API. New tests (sync + async):

  • ..._defer_ack_yields_unacked — items are handed off with no ack/stop call by the poller.
  • ..._defer_ack_does_not_ack_on_body_raise — the strand path: a handoff that fails after the yield leaves the item unacknowledged (i.e. reclaimable), asserting ack_calls == [].
  • ..._defer_ack_requires_auto_stop_false — the ValueError guard fires before any poll.

Full local run for this PR: uv run pytest tests/lib/ (752 passed; the 2 failures in tests/lib/test_credentials.py reproduce identically on clean main and are unrelated to this change), ruff check / ruff format --check clean, mypy . clean (1052 files), pyright 0/0/0.

Scope / boundary note

The behavior change is entirely in the hand-written src/anthropic/lib/environments/_poller.py. The 8-line change to poller() in resources/beta/environments/work.py is a passthrough of the new kwarg; poller()/worker() are maintained custom methods there (they import from lib/ and have this dedicated test file), but if the codegen config needs a matching entry for the new parameter, happy to adjust.

What this deliberately does not change

  • No change to the default ack-before-yield behavior, the retry/backoff policy, or the auto_stop=True runner path.
  • Does not add a nack API (no server endpoint for it).

Fixes #1746

… strand

In the drain-and-dispatch shape (auto_stop=False), iter_work / aiter_work ack a
claimed item (queued -> starting) *before* yielding it to the handoff owner.
If that owner dies before its first heartbeat, the item is left in `starting`
with a null heartbeat, and poll(reclaim_older_than_ms=...) only reclaims
*unacknowledged* work — so the item is permanently stranded, unreachable by
both reclaim and lease-TTL requeue.

There is no server-side nack endpoint (only poll/ack/stop/heartbeat), so a
pure nack can't be done client-side. Instead add an opt-in defer_ack flag:
when True, the poller yields the item still unacknowledged and the consumer /
handoff owner performs the ack (and stop) when it commits to processing. A
failed handoff then leaves the item recoverable via reclaim_older_than_ms /
lease TTL. defer_ack defaults to False (exact current ack-before-yield
behavior, fully backward-compatible) and requires auto_stop=False — the
dispatch pattern where the consumer owns the lifecycle; pairing it with
auto_stop=True raises ValueError, since the poller can't stop an item it
deferred acking. Wired through both iter_work/aiter_work and the public
poller() entry point.

Tested with the existing FakeWork / FakeAsyncWork harness (no live API): the
new flag yields unacknowledged, never acks on a body raise (the recoverable
strand path), and rejects auto_stop=True — sync and async.

Fixes anthropics#1746

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@PranavMishra28
PranavMishra28 marked this pull request as ready for review July 11, 2026 02:37
@PranavMishra28
PranavMishra28 requested a review from a team as a code owner July 11, 2026 02:37
@PranavMishra28

Copy link
Copy Markdown
Author

@arcaputo3 shd fix your issue now

@PranavMishra28

Copy link
Copy Markdown
Author

@TomerAberbach you wrote the poller so sending this your way — small opt-in fix (defer_ack) for the drain-mode strand where a handoff that dies before its first heartbeat leaves the work item stuck in starting instead of reclaimable. CI's paused awaiting approval since it's my first PR here; an approve-and-run would surface the checks. whenever you've got a sec, thanks!

@PranavMishra28

Copy link
Copy Markdown
Author

Hi @TomerAberbach or @anthropics/sdk team - gently following up on this PR when you have a moment! Could a maintainer please approve the 2 pending workflows and provide a code owner review? Thanks!

@encisoale85-ship-it encisoale85-ship-it 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.

Aprobados

@PranavMishra28

Copy link
Copy Markdown
Author

I don't think you have the write access, and therefore I am unable to merge the PR.

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.

environments work poller: ack-before-yield makes any consumer failure permanently strand the work item — request deferred ack or a nack/fail API

2 participants