Issue #3621060: Raise an incident for a parked step whose audience has emptied
A task's candidates are resolved once, when its step parks, and then frozen on the item. Nothing asks again, so a pool that empties afterwards leaves the task offered to tokens nobody carries: it shows in no inbox, nobody can claim it, and the run waits on a step that can never be taken. #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.
The per-dimension question
AssignmentInterface::getUnmatchedCandidates() is new: given a step's stored candidates, an audience returns those it minted that no live account carries, and ignores the tokens of every other dimension. AssignmentBase answers with an empty list, so a plugin whose membership cannot be enumerated reports nothing, and one token nobody can speak for keeps its whole step out of the sweep.
- The role audiences answer from membership, with the
authenticatedandanonymouspseudo-roles exempt (no account stores them, so counting members says nothing). The question is the oneRolesalready asked when a workflow is saved, nowgetRolesNobodyHolds()on the base and read from both ends. - The user audiences answer from the accounts, and now share
UserAudienceBasethe way the role ones shareRoleAudienceBase: theuser:prefix, the viewer token, the active-accounts-only candidate mapping and the notify default were duplicated in both. AssignmentMatcher::getUnmatchedCandidates()unions the answers over the assignment plugins, and intersects with what was asked so a plugin cannot widen it.
The sweep
InstanceRecovery::reportUnreachableTasks() runs on the same cron pass as the other backstops. It reads a bounded batch of open, unassigned tasks behind a state cursor, reads that batch's candidate rows once (both questions asked of them come from the one read), and asks each dimension about the tokens the batch uses. A site where every audience still admits somebody stops there.
A task whose every candidate came back unmatched has its step halted, a guarded parked to error transition, and raised as an incident naming the node and the tokens that emptied. The halt, the task it takes with it and the incident commit together, the way a dead-lettered advance commits its own pair: ERROR is not a live state, so a token flipped out of PARKED with its incident not yet written leaves a run reading as one with no live tokens and nothing open on it, which is the run checkCompletion() completes. Retry resolves the audience afresh, which is the repair.
The halt is a guarded raw write, so it fires no save hook. It announces itself instead (#3621168, merged), and WorkItemCancellationSubscriber cancels the token's task off that announcement, which leaves the engine knowing nothing about work items. The cause travels on the announcement, so the canceled task's trail says nobody is left in the audience rather than only that a step halted: a holder who went away ends a step too, and the two read the same otherwise.
Tests
UnreachableTaskSweepTest, 12 cases: a role pool losing its last member to a block and to a revoked role; named accounts reported only once the last one has gone; a pool with a member left, a step open to everyone, a pool of all authenticated users and a token no dimension answers for all left alone; a run that has already ended left to the orphan sweep; retry offering the step again once the pool is staffed; and the cursor walking past a batch of healthy tasks to reach the stuck one behind them. One more arms orchestra_incident_test, which refuses to store an incident, since nothing in a passing run fails the second write: without the transaction the token stays in error with no incident beside it, the sweep that completes a run nothing is left to move then completes it, and the task the halt took with it stays canceled with nothing behind it. The last one counts the queries a healthy sweep runs and asserts that adding tasks pooled to audiences it already asks about does not add any, since what a site pays on every cron pass is the distinct audiences in the batch and not the tasks in it.
Each guard was checked by mutation: dropping the pseudo-role exemption, reading one unmatched token as enough, dropping the cursor, dropping the transaction, and reading the batch's candidate rows one task at a time each fail exactly the case that covers them. Rebased onto the merged #3621168: the incident-insert hook this branch carried was a stand-in for the announcement it landed, and is gone with the commit that refined it, since the merged subscriber and the cases it brought to WorkItemCancellationTest already cover that cascade.
Both audience bases opened their answer with the same six lines, so AssignmentBase::getTokenValues() picks a dimension's own tokens out of the set and reads what they name, where a custom plugin implementing the question can reach it too. The three test classes that ask whether a run raised an incident now share one reader (OpenIncidentTrait), which filters both conditions in the query so the answer comes from storage whatever the test has already loaded.
Docs: a new "a step nobody can take" section in incidents.md covering both moments an audience can find nobody, an "a human step whose task is in nobody's inbox" entry in troubleshooting.md, the page an operator reaches first because they meet this as a task that has vanished rather than as an incident, plus human-tasks.md and the assignment section of extending.md.