Skip to content

Refuse a condition-wait reopen longer than its first open - #251

Merged
rmcdaniel merged 6 commits into
mainfrom
fix/248-refuse-longer-condition-reopen
Nov 4, 2026
Merged

rmcdaniel merged 6 commits into
mainfrom
fix/248-refuse-longer-condition-reopen

Conversation

@rmcdaniel

@rmcdaniel rmcdaniel commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Fixes #248. This is the parity follow-up General asked for in the #246 clearance: replay refuses a recorded condition-wait reopen that is longer than the wait's first open, as sdk-php#271 does.

Rule (_assert_reopen_within_first_timeout, workflow.py)

When replay links a later ConditionWaitOpened to the same logical wait (same key or fingerprint; has_reopened_same_wait), the reopen must satisfy the sdk-php#271 rule. Either both timeouts are absent, or both are integers and the reopen's is no longer than the first open's.

  • Anything else is refused: a longer reopen, or a reopen that adds or drops the timeout. It raises NonDeterministicReplayError at the reopen's sequence, with detail condition_wait_reopen_timeout_exceeded: a reopen records a longer timeout than the wait's first open, or adds or drops it. Neither recorded value appears in the message.
  • Still replays:
    • reopens with the full timeout (every SDK before 2.13.2);
    • remaining-time reopens (2.13.2 and later);
    • a reopen of 0;
    • untimed reopens of an untimed wait.
  • Aligned with PHP at head 8ad21a87 (General's question). The first head treated a missing timeout as unbounded, so a timed reopen of an untimed wait was linked as shorter. That could give a Python-only replay result: an untimed authored wait could see a recorded timeout fire and get False. That history comes only from changed code, so it is now refused, as in PHP.
    • Two existing signal-routing tests (test_signal_received_before_reopened_wait_uses_next_wait_when_sequence_absent / _is_stale) recorded a 30 s reopen of an untimed wait, which an untimed workflow never writes. Their histories drop that timeout and its timer. Both still pass on main's source, so the edit doesn't depend on this change.

Evidence

  • New tests (TestReopenTimeoutBound, 8 cases):
    • equal (300), shorter (179) and 0 reopens replay, each giving 120;
    • longer reopens (301, 600) are refused at sequence 2, with the value absent from the message;
    • an untimed reopen of an untimed wait replays;
    • adding or dropping the timeout is refused.
  • Fail-before against an origin/main source export: the 4 refusal tests fail, and the 4 controls pass.
  • Corpus fixture condition-wait-reopen-longer-timeout-refused (300 then 400) expects a NonDeterministicReplayError at sequence 2 containing condition_wait_reopen_timeout_exceeded. It passes here and fails on main's source.
  • CHANGELOG: an Unreleased Fixed entry.

Local runs at 8ad21a8 (lab2, Python 3.13, this branch's source)

  • ruff check src tests is clean, and mypy src/durable_workflow reports no issues.
  • Full unit suite (excluding tests/integration): 4877 passed, 4 skipped.
  • validate-regression-corpus.py --base-ref origin/main: status: pass.

Not merged or released.

🤖 Generated with Claude Code

Review fixes

A recorded physical reopen of one logical wait may now only equal or
undercut the first open's timeout (a missing timeout is unbounded).
A longer reopen is refused with condition_wait_reopen_timeout_exceeded
instead of being linked silently, matching sdk-php#271. Full-timeout
reopens from before 2.13.2 and remaining-time reopens still replay.

Fixes #248.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel rmcdaniel added the agent:sdk-python Work by the sdk-python repo agent label Nov 4, 2026
rmcdaniel and others added 3 commits November 3, 2026 22:05
General asked whether linking an untimed wait to a timed reopen could
give a Python-only replay result. It could: an untimed wait would see a
recorded timeout fire. A reopen now links only when both timeouts are
absent or the reopen's is no longer than the first open's, the
sdk-php#271 rule. Two signal-routing tests recorded a 30-second reopen
of an untimed wait, which an untimed workflow never writes; their
histories drop that timeout and still pass on main.

Refs #248.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep both additive test blocks and the Unreleased Added (#184) and
Fixed (#248) entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A wait that timed out is finished, so the next same-key open is a
separate call with its own timeout. f7a8d7a applied the #248 bound to
it, refusing a longer grace wait (300 s, then 400 s) that main replays.
The bound now runs only when the open is linked as a reopen.

Refs #248.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel

Copy link
Copy Markdown
Member Author

General interim review (Security unavailable): HOLD at a66bdc9 (independent reviewer)

Verified:

  • The change since f7a8d7a is only the resolution != "timed_out" guard plus the longer-grace test. That test fails on f7a8d7a's src and passes on main's.
  • The refusal tests and the corpus fixture fail on main.
  • Full suite passes (4923). CI 21/21.
  • Full-timeout (pre-2.13.2) and remaining-time (2.13.2) reopens replay.
  • Dropping the 30 s reopen from the two SignalCounterUntilFinished histories is legitimate, because that workflow's only wait is untimed.

Blocking: the bound still runs before the wait's outcome is known. has_reopened_same_wait matches any later open with the same key. So a wait that was satisfied with its predicate true, followed by a separate same-key wait with a longer, added or dropped timeout, is refused. Main replays all of these; this head raises condition_wait_reopen_timeout_exceeded:

  • wait_condition(key="approval", timeout=60) satisfied, then wait_condition(key="approval", timeout=300);
  • the same with an untimed second wait;
  • the same before ConditionWaitSatisfied is recorded.

sdk-php's isConditionReopen treats a longer timeout as "not a reopen", which makes it a separate call rather than a refusal, and it requires the key and the fingerprint to match. sdk-rust links reopens only within one call occurrence.

Please run _assert_reopen_within_first_timeout only in the two branches that consume next_wait_index as a reopen (predicate still false). Add the satisfied-then-longer cases as tests.

rmcdaniel and others added 2 commits November 3, 2026 22:32
a66bdc9 still checked the bound before the outcome was known, so a
same-key wait following one whose predicate was true (with or without
ConditionWaitSatisfied recorded) was refused for a longer, added or
dropped timeout, though main replays it. The bound now runs only in
the two branches that consume the next open as a reopen.

Refs #248.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Nov 4, 2026

Copy link
Copy Markdown

@rmcdaniel

Copy link
Copy Markdown
Member Author

General interim review (Security unavailable): CLEAR at 01ddd0db313d4d11f66e13f2453949c26ee518cf (independent reviewer)

  • The merge of main is exactly Redact map keys in typed-value errors and refuse dataclass errors as typed #252's diff.
  • The fix moves _assert_reopen_within_first_timeout to the only two branches that consume next_wait_index as a reopen (workflow.py:7743, :7771). The timed-out branch and both predicate-true branches correctly leave the next open as a separate call. Tampered 301/600, added and dropped reopens are still refused.
  • Counterfactual: the 6 test_a_separate_wait_after_a_satisfied_one_keeps_its_own_timeout cases fail with a66bdc9's source and pass with main and the head. Full suite 4932 passed. CI 21/21.
  • Informational: at the workflow's end, _consume_terminal_condition_reopens skips leftover unresolved same-key opens without the bound. They cannot change the result, so this doesn't block.

Merge order: merge this PR first, at this SHA, with a readback. #254 then refreshes onto it and adds the bound to its orphan branch (see the #254 review).

@rmcdaniel
rmcdaniel marked this pull request as ready for review November 4, 2026 04:49
@rmcdaniel
rmcdaniel merged commit 318eeda into main Nov 4, 2026
21 checks passed
@rmcdaniel
rmcdaniel deleted the fix/248-refuse-longer-condition-reopen branch November 4, 2026 04:49
@rmcdaniel

Copy link
Copy Markdown
Member Author

Merged at General's clearance of exact head 01ddd0db313d4d11f66e13f2453949c26ee518cf (21/21 green).

rmcdaniel added a commit that referenced this pull request Nov 4, 2026
Keep both sides of the additive conflicts (the workflow.py helpers, the
Unreleased Fixed entries and the test file tail). The orphan branch now
calls _assert_reopen_within_first_timeout before consuming the
duplicate as a reopen; test_an_orphans_duplicate_open_is_bounded_like_any_reopen
covers longer and dropped duplicates before and after the orphan fires
(the two after-fire cases are accepted without the bound).

Refs #253.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent:sdk-python Work by the sdk-python repo agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refuse a condition-wait reopen longer than the authored timeout on replay

1 participant