Skip to content

Reopen a condition wait with its remaining timeout - #246

Merged
rmcdaniel merged 3 commits into
mainfrom
fix/245-condition-wait-remaining-timeout
Nov 4, 2026
Merged

rmcdaniel merged 3 commits into
mainfrom
fix/245-condition-wait-remaining-timeout

Conversation

@rmcdaniel

@rmcdaniel rmcdaniel commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Fixes #245. This is the Python counterpart of sdk-php#269 (a), following General's ruling: the deadline is fixed at the first open, and a reopen carries the remaining time.

Bug

ctx.wait_condition(predicate, key=..., timeout=300) opens a wait with timeout_seconds: 300. A signal that leaves the predicate false re-drives the workflow, and the SDK reopened the wait with the full 300 seconds again. Each such signal pushed the timeout later. A wait opened at 00:00 and signalled at 00:02 timed out at 00:07.

Fix (src/durable_workflow/workflow.py)

  • The replayer records each ConditionWaitOpened row's time (timestamp, else recorded_at), and the latest recorded time in the history.
  • When a yielded wait is still pending after it consumed its physical opens, pending gets a copy of the WaitCondition. The copy's timeout_seconds is clamp(ceil(authored - (latest history time - first open time)), 0, authored). This is the same rule as sdk-php#271.
    • A first open, an untimed wait, or a history without parseable times keeps the authored WaitCondition unchanged.
  • The latest history time is the only deterministic "now" a worker has, since Python's ctx.now() stays at the workflow start. The Server records the reopen at its completion time, which is at most one task latency later.
  • timeout_seconds on a recorded open is not compared on replay. So histories written with full reopen timeouts still replay; a control covers this.
  • The wait_condition docstring now says the deadline is fixed at the first open.

Not mirrored from sdk-php#271: refusing a recorded reopen whose timeout_seconds exceeds the authored timeout. Python does not compare a recorded open's timeout today, and this PR doesn't add that check.

Not in this PR: sdk-php#269 (b)

The duplicate open_condition_wait on a non-signal task is not changed here. sdk-php's analysis applies to Python too. A worker can't tell a signal-resume task from another task, and if it doesn't re-send on a signal-resume task, the wait is satisfied with nothing reopened. The accepted fix is condition_wait_occurrence_id plus the Server's idempotent reopen. Python can adopt that once the Server issue lands, as a separate change.

Evidence

  • New tests in tests/test_wait_condition.py (TestReopenedWaitRemainingTimeout):
    • reopen after a signal at 00:02:00.25 gives 180;
    • a second reopen keeps the first deadline (a signal at 00:04:30 gives 30);
    • a reopen past the deadline gives 0;
    • a history with full-timeout reopens still replays and gives 120;
    • sdk-php's falseWakeDeadlines vectors: a 300 s wait opened at 02:36:05.295 gives 180, 61 and 0 for signals at 02:38:05.345, 02:40:05 and 02:41:06.
    • Controls: a satisfying signal completes with no reopen; a first open keeps 300; a history without times keeps 300.
  • Fail-before: on main's source the 7 reopen tests fail (they get 300), and the 3 controls pass.
  • Corpus fixture condition-wait-reopen-remaining-timeout expects open_condition_wait with timeout_seconds: 180. It passes at this head and fails on main's source through the official corpus test. RemainingTimeoutApproval is registered in WORKFLOWS.

Local runs at 6e54e5d (lab2, Python 3.13, this worktree's source)

  • ruff check src tests: clean. mypy src/durable_workflow: no issues.
  • Full unit suite (tests, excluding tests/integration): 4804 passed, 4 skipped at bae15122. At this head, the wait-condition and corpus tests give 138 passed.
  • validate-regression-corpus.py --base-ref origin/main: status: pass.

Not merged or released.

🤖 Generated with Claude Code

A signal that left a wait_condition predicate false reopened the wait
with the full timeout_seconds, so each such signal moved the timeout
later. The deadline is now fixed at the first open: a reopen carries the
whole seconds left (rounded up, never below 0) measured at the latest
recorded history time, matching embedded semantics and the sdk-php#269
decision. Histories without recorded times keep the authored timeout,
and histories written with full reopen timeouts still replay.

Fixes #245.

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
The reopen's remaining time is now ceil(authored - (latest history time -
first open time)) clamped to 0..authored, the rule sdk-php#271 uses, and
sdk-php's falseWakeDeadlines vectors (180, 61, 0) are tests here.

Refs #245.

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

Copy link
Copy Markdown
Member Author

General interim review (Security unavailable): exact head 6e54e5dd3fedd394bea43ce2267bbaeac0ed5db8 is CLEARED. The reviewer was independent of the author.

Determinism

  • The time comes only from history timestamp/recorded_at; there's no wall-clock source.
  • Replaying twice, and again with time.time patched, gives identical commands.
  • A 2000-case sweep of sub-second times matches exact integer math.
  • All the timestamp formats the Server writes give the same result.

Parity with sdk-php#271 (cd346723)

Server behaviour

  • A 0 timeout is recorded as an already-fired timer.
  • The deadline lands later by the worker's task time plus rounding up. Both SDKs behave the same here.

Edge cases

  • Clock skew falls back to the authored timeout.
  • A second wait with the same key measures from its own first open.
  • First opens, untimed waits and histories without times keep the authored timeout.

Back-compat and tests

  • Histories whose reopens carried the full timeout still replay.
  • The full suite passes: 4800 passed, including the corpus.
  • With main's source, the 7 reopen tests and the fixture fail.

Non-blocking

  1. Python 3.10's fromisoformat rejects some timestamp formats the Server never writes. Those fall back to the authored timeout.
  2. A timestamp with no time zone is ignored here, while PHP reads it as local time.
  3. sdk-php refuses a reopen longer than the authored timeout; Python doesn't, as on main. Consider parity in a follow-up.

The CHANGELOG merges cleanly with main. Merge only this SHA, or refresh it and send the head for a tree check if strict mode requires it.

@rmcdaniel
rmcdaniel marked this pull request as ready for review November 4, 2026 03:19
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Nov 4, 2026

Copy link
Copy Markdown

@rmcdaniel
rmcdaniel merged commit 1310272 into main Nov 4, 2026
21 checks passed
@rmcdaniel
rmcdaniel deleted the fix/245-condition-wait-remaining-timeout branch November 4, 2026 03:28
@rmcdaniel

Copy link
Copy Markdown
Member Author

Merged at General's verification of refreshed head ec3546a29e0d248240a6235131ebcb6e096d5d7a (21/21 green).

rmcdaniel added a commit that referenced this pull request Nov 4, 2026
Resolve two additive conflicts: keep both new workflow.py helpers
(_history_event_time from #246 and _typed_activity_result) and both
corpus WORKFLOWS registrations.

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.

Reopen a condition wait with its remaining timeout, not the full timeout

1 participant