Raise an incident when a join can no longer fire, instead of lowering a threshold's count or only logging a stall

Resolves #3629951.

A join that can no longer fire now halts on an incident, instead of firing on fewer branches than the workflow asks for, or hanging with only a log line.

What changes

  • Threshold join: its count is no longer lowered to the active branches. Once every branch it counts has arrived and they are still too few, ThresholdJoin::arrive() returns the new JoinDecision::Halt, and the arriving token halts on a token incident flagged IncidentInterface::DETAIL_UNFIRED_JOIN.
  • Quorum join: its count is no longer lowered either. A quorum fewer branches can reach is out of reach, and the existing "can no longer be reached" test settles it as soon as a branch arrives. No incident.
  • Stalled joins: WorkflowExecutor::recoverStalledInstance() no longer only logs. When nothing can be armed, it halts one waiting token per stuck join on the same kind of incident. A timeout join whose timeout is missing or not a duration names that as the cause. Nothing is raised while another token of the instance is halted on an incident (retried, it may still arrive). The cron sweep raises it too, and the $warn parameter is gone.
  • Resolving the incident: Skip reads Fire the join on these incidents. WorkflowExecutor::fireHaltedJoin() fires the join with the branches that arrived, under the join lock and in the claim's transaction, then queues the token that goes on: the node's work runs as any advance does (it passes the join it remembers firing), so a failure there is the step's own incident, not an exception in the operator's request. Retry and Resume ask the join again, and a stranded join retried unchanged halts again in the same request.
  • Save time: Workflow::preSave() refuses a threshold or quorum join requiring more branches than flows enter its node (ThresholdJoin::DEFAULT_COUNT / QuorumJoin::DEFAULT_COUNT when the settings name none).
  • Docs: new "A join that can no longer fire" section in incidents.md; joins-splits.md and troubleshooting.md updated; French for "Fire the join".

Cost

The threshold decision is the same arithmetic on the state it already had. The stall recovery adds one indexed query (hasHaltedToken()), and only on an instance that is already stalled; the open incident then takes the instance out of the sweep's candidates. Retry and Resume recheck the stall only for these join incidents.

Tests

  • UnfiredJoinIncidentTest (new): the halt, firing the join (the waiting branch is joined, the step runs once), a step failing after the fire, and a retry that halts again.
  • JoinStallRecoveryTest: rewritten for the incident, including one incident per join, the cron sweep, the halted-branch guard on the cancel path, and retrying after giving the join a timeout.
  • JoinCountRefusalTest (new): the save-time refusal.
  • JoinDecisionTest: threshold and quorum without lowering.
  • DiscriminatorConditionedFlowTest::testJoinSettlesInsideEmbeddedChild and CanceledMidStepTest::testJoinArrivalCanceledWhileTheJoinFiresFiresNothing now expect the halt.

Locally: every main-module kernel class, every Unit suite (module and submodules), and every submodule kernel class whose contrib dependencies the local root has, plus phpcs, phpstan (only the known $loggerFactory false positive of the newer local phpstan), cspell and scripts/check-translations.php. Each new guard was mutated and its test fails.

Overlaps !663 (merged), !664 (merged) and !665 (merged) (JoinCoordinator, WorkflowExecutor, the join docs, fr.po): whichever lands second needs a rebase.

AI-Generated: Yes (Claude Code was used to help write the change, its tests, the documentation and this description. I reviewed them, and each new test was confirmed to fail without the change it covers and to pass with it.)

Merge request reports

Loading
Loading