Problem

A task is offered to the audience its step resolved when the token parked, and those candidate tokens are frozen on the work item. Nothing asks again. When the last member leaves the role a pooled task names, or every account a task was offered to is blocked, the task stays open with candidates no live account carries: it appears in nobody's inbox, nobody can claim it, and the run stops there with nothing recorded.

#3621013 catches the step that could never be offered, at the moment it parks. #3621033 catches the holder of a claimed task going away, on that account's own save. Neither sees this one: nothing happened to the task, and what did happen happened to a role, or to an account that never held it.

The obstacle is that a candidate is an opaque token. role:editor and user:5 mean something only to the plugin that minted them, and the matcher compares tokens rather than resolving people, which is what keeps a new assignment dimension free of changes to storage and to the inbox query. So there is no way today to ask whether there is still anybody behind a stored token.

Proposed

  • Add that question to AssignmentInterface, per dimension: given a step's stored candidates, which of them does this audience no longer admit anybody for? An audience answers only about the tokens it minted and ignores the rest, and the base class answers with an empty list, so a dimension that cannot enumerate its membership (a custom org: plugin, an external directory) reports nothing and never has a step swept out from under it.
  • Answer it where the tokens are minted. The role audiences report a role no active account holds, with the authenticated and anonymous pseudo-roles exempt because no account stores them, so counting members says nothing about them. The user audiences report a uid that names no active account. The role answer is the same question the Roles assignment already asks when a workflow is saved ("No active account holds @roles"), so both read one implementation.
  • Sweep for it on cron, in InstanceRecovery beside the other backstops: a bounded batch of open work items, one read of their candidate rows, and one question per dimension. On a site where every audience still admits somebody the sweep stops there, and a state cursor walks the batches so a backlog cannot pin it to the same head rows.
  • A task every one of whose candidates is unmatched has its step halted (a guarded transition from parked to error) and raised as an incident naming the node and the audiences that emptied. That is what both sibling issues do with a step nobody can take, and it reaches the same operator actions: retry resolves the audience again from the corrected workflow, resume writes the variable a dynamic audience reads.
  • An open task whose step halts is canceled, so retrying mints one task rather than a second beside it. The engine knows nothing about work items, so that follows the halt rather than being done by the sweep: the guarded transition announces itself (#3621168), and the task side reacts to that announcement, the way it already reacts to a token being canceled. The reason travels with it, so the canceled task's trail says nobody is left in the audience rather than only that a step halted.
  • The halt, the task it takes with it and the incident commit together. Error is not a live state, so a token flipped out of parked with its incident not yet written reads as a run with no live tokens and nothing open on it, which is the run the completion check completes.

Tests

  • Blocking the last member of the role a pooled task names, then running the sweep: the token is in error, an incident names the role, the task is canceled, and the instance stays running.
  • The same for a task offered to two named users once both accounts are gone, and for a role emptied by revoking it rather than by blocking anybody.
  • A task whose role still has one active member is left alone, and so is a step open to everyone.
  • A task carrying a token of a dimension no plugin answers for is left alone.
  • Retrying the incident once the role has a member again produces a real task.
  • The sweep raises nothing on a healthy site, its cursor advances past the batch it has already read, and what it costs there is the distinct audiences in the batch rather than the tasks in it.

Follows

Follows #3621013, which raises an incident for a step that could never be offered, and #3621033, which releases a task whose holder can no longer act on it and named this shape as needing a periodic sweep and a per-dimension question. This is that sweep.

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-3621060

Command icon 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

mably created an issue. See original summary.

mably’s picture

Issue summary: View changes
Status: Active » Needs review
mably’s picture

Issue summary: View changes

  • mably committed 10f2a8a3 on 1.x
    fix: #3621060 Raise an incident for a parked step whose audience has...
mably’s picture

Status: Needs review » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.