Skip to content

fix: enforce first-persisted terminal state under the task lock (#401) - #449

Open
chopmob-cloud wants to merge 2 commits into
a2aproject:mainfrom
chopmob-cloud:fix/terminal-state-precedence-401
Open

fix: enforce first-persisted terminal state under the task lock (#401)#449
chopmob-cloud wants to merge 2 commits into
a2aproject:mainfrom
chopmob-cloud:fix/terminal-state-precedence-401

Conversation

@chopmob-cloud

Copy link
Copy Markdown
Contributor

What

Closes #401. Prevents a racing forced terminal transition (background failure or
cancellation) from overwriting a terminal state another writer has already
persisted. Adds an atomic "first persisted terminal state wins" guard inside
A2AServer.ApplyEventAsync, under the per-task lock.

Why

CancelTaskAsync and TryTransitionToFailedAsync both check IsTerminal before
taking the per-task lock. A concurrent writer can persist a terminal state in the
window between that optimistic check and the locked apply, so the forced writer
then overwrites it:

  • Background failure can overwrite a Completed or Canceled task with Failed.
  • Cancellation can overwrite a Completed or Failed task with Canceled, so a
    task the agent actually completed is reported as canceled.

How

ApplyEventAsync is the single choke point through which every terminal write is
persisted, and it already holds the per-task lock. Re-reading the current state
under that lock and dropping a status update that would move an already-terminal
task to a different state closes the race for every forced writer at once,
including any added later, rather than patching each call site:

if (currentTask is not null
    && currentTask.Status.State.IsTerminal()
    && response.StatusUpdate is { } racingStatus
    && racingStatus.Status.State != currentTask.Status.State)
{
    return;
}

The existing pre-lock checks are kept as fast-path rejections for the common
(non-racing) case. The guard is scoped to status-update events, which are the
vector used by both forced terminal writers.

Tests

Two deterministic regression tests in tests/A2A.UnitTests/GitHubIssues/Issue401.cs,
one per forced writer. Each drives the race window between the pre-lock check and
the locked apply (the cancel test gates the cancel handler; the background-failure
test arms a task store that injects a terminal state on the drain's under-lock read
and signals when to await the drain safely). Both fail without the guard
(Actual: Canceled and Actual: Failed) and pass with it.

Validation

Clean container, base main, both target frameworks:

  • Build clean on net8.0 and net10.0 (0 warnings, 0 errors).
  • Full A2A.UnitTests suite: 422 passed / 0 failed on both frameworks (420 pre-existing
    plus the 2 new tests). No regressions.
  • Both new tests pass on both frameworks, zero flakiness across repeated and isolated
    runs. With the guard reverted, both fail with the expected actuals, confirming they
    exercise the fix.

Closes #401.

…roject#401)

CancelTaskAsync and TryTransitionToFailedAsync check IsTerminal before taking the per-task lock, so a concurrent writer can persist a terminal state in the window before the forced write is applied, letting a background failure overwrite Completed/Canceled with Failed or a cancellation overwrite Completed/Failed with Canceled. Re-check the current state under the lock in ApplyEventAsync (the single choke point every terminal write funnels through) and drop a status update that would move an already-terminal task to a different state. Adds two deterministic regression tests, one per forced writer.

Signed-off-by: AlgoVoi <chopmob@gmail.com>
@chopmob-cloud
chopmob-cloud force-pushed the fix/terminal-state-precedence-401 branch from 86dbee9 to bc24e17 Compare August 6, 2026 16:05
Signed-off-by: AlgoVoi <chopmob@gmail.com>
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.

[bug] Clarify and Enforce Terminal Task State Precedence During Races Between Canceled, Completed, and Failed Outcomes

1 participant