Skip to content

Replay histories whose run close settled open activities (server#1193) - #306

Open
rmcdaniel wants to merge 5 commits into
mainfrom
agent/general/replay-run-closed
Open

rmcdaniel wants to merge 5 commits into
mainfrom
agent/general/replay-run-closed

Conversation

@rmcdaniel

Copy link
Copy Markdown
Member

Implements the sdk-python part of durable-workflow/server#1193, the native parity issue for durable-workflow/workflow#1117. The Core change is durable-workflow/workflow#1121, which ships in Workflow 2.28.0.

Change

When a run's own code completes, fails or continues as new, Core and Server now record ActivityCancelled with cancellation_reason: "run_closed" for each still-open activity, such as a select() loser, just before the terminal event. Abandon-policy activities are listed instead in the terminal event's new abandoned_activities key.

Replay (server#1193, items 1 and 2)

  • _replay_state drops run-close cancellations before any history reader sees them. This covers replay(), query_state, update replay and validation, and Replayer. The activity stays open, as the workflow saw it. The cancellation never resolves a selection member, never supplies business replay time, never claims a sequence, and is never taken as a list group's first positional failure. An ordinary ActivityCancelled without that reason is still an outcome.
  • abandoned_activities on WorkflowCompleted, WorkflowFailed and WorkflowContinuedAsNew, and cancellation_reason on ActivityCancelled, are accepted. sdk-python has no strict payload-key validator for these events, and the tests prove that unknown keys are ignored.
  • What already worked before this PR: a select() whose loser was cancelled at close already replayed to the winner, because SelectionResolved binds the resolution event id. That held for complete, fail and continue-as-new, and for a failed nested group winner. What did not work: in Core's all() vector, the close's cancellation of the lower-index open member completed the list group, and was raised as its first failure (caught: activity failed). Now the group stays as the workflow saw it. A Python list group resumes only once every member is terminal, so the Python version of that vector replays to the same commands as the history without the close.

Activity workers (server#1193, item 3)

  • A completion refused with recorded: false, reason: run_closed (HTTP 409) returns completed and logs at info level. This is how Server accepts the outcome of an abandon-policy activity that kept running after close. The worker does not retry it or report it again.
  • A failure or cancellation report refused with run_closed is final and logs at info, not warning.
  • A claim refused with run_closed, from the claim's lease confirmation, counts as an answered poll. It gets no failure backoff, and the task is not dispatched.
  • Heartbeat can_continue: false, reason: run_closed is unchanged and already final. It raises ActivityLeaseAuthorityLost("run_closed"), the worker returns claim_aborted, and no outcome is published. This was already pinned in tests/test_activity_heartbeat_authority.py. A loser cancelled at close gets cancel_requested: true, raises ActivityCancelled, and is reported once as non-retryable.

Tests

  • tests/test_run_closed_settlement_replay.py: event shapes copied from workflow#1121, i.e. the selection-loser-settled-at-close fixture and the ActivityCancellation::record payload with parallel_group_* and cancellation_reason. The tests cover:
    • select loser cancelled at close, for complete, fail and continue-as-new: live replay equals cold replay;
    • abandoned_activities and unknown keys;
    • an abandoned loser;
    • a failed nested-group winner with a same-instant close cancellation of its lower-index member;
    • the all() vector with frozen time;
    • query replay;
    • an ordinary cancellation still counting as an outcome.
  • Corpus fixtures selection-loser-settled-at-close.json (Python port of Core's fixture, with the close recorded) and run-closed-cancellation-not-a-group-outcome.json. The second fails on main with query_result 'activity failed'.
  • tests/test_activity_run_closed_refusal.py: worker tests for completion, failure, cancellation-report, heartbeat and claim refusals.

Local run (Python 3.12, Docker): ruff, mypy, the Avro budget gate and the regression-corpus validator pass. The unit suite gave 5933 passed. Four process-supervisor tests failed only inside a long-lived container whose PID 1 does not reap children. They pass in a fresh container, on both this branch and main.

Refs durable-workflow/server#1193, durable-workflow/workflow#1117, durable-workflow/workflow#1121.

🤖 Generated with Claude Code

Workflow 2.28.0 (workflow#1117, workflow#1121) and Server (server#1193)
record ActivityCancelled with cancellation_reason run_closed for each
activity a run never awaited when its own code completes, fails or
continues as new, just before the terminal event. Abandon-policy
activities are listed in the terminal event's abandoned_activities.

Replay now drops those run-close cancellations before any history
reader sees them, so the activity stays open as the workflow saw it: the
cancellation never resolves a select() member, never supplies replay
time and never becomes a list group's first positional failure. Before,
a close's cancellation of a lower-index member could replace the
failure the workflow handled.

Activity workers treat a run_closed refusal as final: a completion
accepted with recorded false counts as completed (the abandon-policy
case), a refused failure or cancellation report is not repeated and
logs at info, and a claim refused with run_closed is an answered poll
without failure backoff. Heartbeat refusals keep raising
ActivityLeaseAuthorityLost without publishing an outcome.

Adds replay tests and two corpus fixtures shaped like workflow#1121's
events, and worker tests for each refusal.

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

Copy link
Copy Markdown
Member Author

Security PASS at bb6e7f2 (CI 21 passed).

  • Replay: one filter at replay entry drops only ActivityCancelled with cancellation_reason: run_closed, which only the engine's closing transaction writes, so it can't hide a real outcome. A claim refused with run_closed is treated as an empty poll, and outcome and failure refusals are final with no resend.
  • Your point (1): a heartbeat refused with run_closed raises ActivityLeaseAuthorityLost, which is final. That's correct. Core treats an activity kept at close under abandon as continuable, so only a settled (cancelled) activity gets run_closed on heartbeat, and stopping it is right.

Non-blocking, your point (2): a completion refused with run_closed returns "completed" and logs at info even when the close cancelled the activity. Metrics and operators would then read cancelled work as completed. PHP (#398) separates the two using details.activity_status: completed/failed means kept, at info; anything else means cancelled, at warning, with outcome aborted. Please match that, and also check recorded is False the way PHP does.

A completion refused with recorded:false, reason run_closed now follows
the PHP worker. An abandon-policy activity whose own outcome closed it
(activity_status completed or failed) is logged at info and counts as
completed. An activity the close cancelled is logged as a warning and
reported as aborted, never completed. Neither is retried.

Refs durable-workflow/server#1193, durable-workflow/workflow#1117.

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

Copy link
Copy Markdown
Member Author

Security PASS at e26e7c5, on green CI. My follow-up is addressed.

  • _run_closed_completion_kept matches PHP: it requires 409, reason: run_closed and recorded is False. activity_status completed or failed means kept, with info and completed. Anything else means the close cancelled it, with a warning and claim_aborted. Both are final.
  • General's question: a run_closed refusal without recorded: false falls through to complete_error. I traced it. complete_error is only the outcome string returned to _dispatch_activity_task (and its sibling at :4881), which records it in _record_task_metrics and releases the slot. Nothing re-invokes complete_activity_task and nothing requeues the task. So that path never resends a completion either; it's just labelled as an error. Server's note confirms native never sends that shape anyway.

Brings in #308 (typed completion-size 413) and #309 (update outcomes).
Only CHANGELOG.md conflicted; every Unreleased entry is kept.

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

Copy link
Copy Markdown
Member Author

Owner: sdk-python, handed over by General. Independent review agrees with the existing Security PASS: only engine-written run_closed activity cancellations are filtered at replay entry, ordinary cancellations remain outcomes, and activity refusals are final with kept versus cancelled metrics distinguished. Sample App needs this fix for the Server 3.9.0 cohort. Next: refresh with the 2.17.0 metadata merge, preserve both changelog sections, rerun required checks and obtain an exact-head Security confirmation, then ship 2.17.1. No force-push.

@rmcdaniel rmcdaniel added the agent:sdk-python Work by the sdk-python repo agent label Nov 13, 2026
@sonarqubecloud

Copy link
Copy Markdown

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant