Problem
StateTransitions is the one guarded state flip in the module: a conditional UPDATE that current-reads the row, so exactly one caller wins a contended transition. It is a raw write on purpose, and it fires no entity save hooks, which its own docblock states.
So a transition done this way is invisible to everything that needs to react to it. The task side reacts to a token being canceled through hook_orchestra_token_update, which reaches it only because that particular cancellation is an entity save; the halt to error, which is guarded, reaches nothing. In #3621060 the cascade that cancels the task of a halted step therefore hangs off the incident insert, the only entity saved on that path. It is conditioned on the token's state, so it does not depend on an incident implying a halt, but the trigger is still something that accompanied the halt rather than the halt.
Two things follow from that, beyond how it reads. A halt that raises no incident would cascade nothing; there is no such path today, and nothing in the code prevents one from being written. And every later consumer of "this token reached a terminal state" has to go looking for its own accompanying save to hang off.
What changes
- The primitive announces a flip it performed, as StateTransitionEvent: the entity as the flip left it, the state it left, the state it reached, and an optional reason the caller names. It already returns TRUE only for the caller that won, so the announcement is exactly once per transition however many callers raced for it, and never for one that found the row already moved on.
- It is an event rather than a hook, so a decorating module subscribes to it the way it already subscribes to the instance lifecycle events. It is dispatched inside the caller's transaction, where a listener that throws rolls the transition back.
- All three entities that take this route are covered: tokens, instances and incidents. A consumer of "this run ended" or "somebody is on this incident" no longer reads the state back to find out.
- The task side subscribes and cancels the open task of a halted step, with the reason the halting caller gave. OrchestraWorkItemHooks keeps the save-driven half; WorkItemCancellationSubscriber is the guarded half, and both reach the same idempotent cancellation. A completion whose own branch halts on the resume it caused is not one of them: the task its holder just finished stays finished.
- The departing-holder hook from #3621033 stops cancelling the task itself and lets its halt carry the cause instead.
- A parent resumed by its completed child was claiming its state with its own copy of the flip, so it announced nothing; it goes through the primitive now. One flip stays hand-written and silent, the timeout sweep's claim of a due join token, which guards on the deadline as well as the state, and the release that undoes it; both the primitive and the extending guide say where the announcements stop.
- getTransition() becomes claimState(). What it returns is whether the next move is the caller's to make, which is what the guarded row settles, and claim is the word the module already uses for that shape in claimIncident(), claimDueDeadline() and claim(), as core does in claimItem().
Tests
- A guarded flip announces once, and the caller that lost the race announces nothing.
- A token halted to error cancels its open task through the announcement, carrying the halting caller's reason to the audit entry.
- A flip that does not end the branch leaves the task where it is.
- A completion whose branch dead-ends on its own resume is recorded as a completion and nothing else.
- A parent put back to work by its child announces it like any other resume.
Follows
Follows #3621060, where the cascade for a halted step's task had to hang off the incident that accompanied the halt, because the halt itself is invisible.
AI-Generated: Yes (Claude Code was used to help draft this issue summary and to write the code and tests on the merge request. I reviewed and ran the work myself before posting it.)
Issue fork orchestra-3621168
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #3
mably commentedComment #4
mably commentedComment #6
mably commented