Skip to content

Concurrent parallel branch fan-out + terminal-completion hook#397

Merged
chubes4 merged 3 commits into
mainfrom
feat/concurrent-branch-fanout
Jul 3, 2026
Merged

Concurrent parallel branch fan-out + terminal-completion hook#397
chubes4 merged 3 commits into
mainfrom
feat/concurrent-branch-fanout

Conversation

@chubes4

@chubes4 chubes4 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

What

Make parallel workflow fan-out complete-able and concurrent as a property of the substrate, so every consumer (Site Forge, wp-codebox, Data Machine) gets it for free instead of hand-rolling it.

Two changes:

  1. Terminal-completion hook. do_action( 'wp_agent_workflow_run_completed', $result, $run_id ) fires at the single terminal funnel in the runner's step loop — whether a run completes straight through (run()) or via an async resume after its parallel branches reconcile (resume()). This lets an async consumer finalize on completion WITHOUT block-polling the recorder: dispatch a fan-out, return from the worker immediately (releasing the AS claim), and finalize from the hook — instead of holding a claim while waiting on the very branches it dispatched (which self-deadlocks a cooperative queue).

  2. Concurrent branch execution on Action Scheduler. The branch executor enqueued N branches but left them to drain through AS's default runner (one batch of 25 in one worker), so a fan-out's branches ran serially in one PHP process. Now:

    • dispatch() triggers the async queue runner itself after enqueuing, opening N loopback connections concurrently (Requests::request_multiple / curl_multi) so N distinct workers each claim one branch. Bounded by MAX_BRANCH_CONCURRENCY (8); serial fallback where the parallel Requests API is absent.
    • A loopback dispatch-URL rewrite points the trigger at 127.0.0.1 (real host in a Host header) — curl_multi bypasses WordPress's http_api_curl hook, so a .local custom domain would otherwise pay a ~5s multicast-DNS timeout per handle and serialize the concurrent handles.
    • A persistent AS concurrency policy: concurrent_batches raised to the pending branch count (capped), batch_size pinned to 1, only while this executor's own branches are pending (gated live on pending_branch_count() over BRANCH_HOOK). Together they turn N running workers into N distinct branches.

Not a bypass of Action Scheduler

Branches stay ordinary AS actions — enqueued, claimed, run, and reconciled through AS. This only triggers AS's async runner concurrently and tunes AS's own concurrency filters.

Blast radius

The concurrency filters are process-global (AS has no per-group knob), but both gate on this executor's pending branches: with none pending they pass their incoming value through unchanged, so other Action Scheduler workloads keep stock behavior (concurrent=1, batch=25). The concurrent_batches raise only ever raises (max of incoming and target), never lowers another plugin's ceiling.

Verification

Verified on a live native-PHP runtime backed by MySQL: a multi-page Site Forge fan-out completes end to end, the policy resolves correctly (idle concurrent=1 batch=25; with N branches pending concurrent=min(N,8) batch=1), and branches run under the shared substrate with the consumer holding no concurrency code of its own. The change is backend-agnostic — it functions on any Action Scheduler store; concurrent claims are simply more effective on MySQL's FOR UPDATE SKIP LOCKED than on SQLite's single-writer model.

AI assistance

  • AI assistance: Yes
  • Tool(s): Claude Opus 4.8 via Claude Code
  • Used for: Diagnosing the serial-drain and self-deadlock root causes from source and live runtime behavior, implementing the completion hook and the concurrency substrate, and verifying end to end.

chubes4 added 3 commits July 2, 2026 19:30
Add do_action( 'wp_agent_workflow_run_completed', $result, $run_id ) at the
single terminal funnel in the runner's step loop. It fires whether a run
completes straight through (run()) or via an async resume after its parallel
branches reconcile (resume()). This lets an async consumer finalize on
completion WITHOUT block-polling the recorder — it can dispatch a fanout,
return from its worker immediately (releasing the AS claim), and do its
finalization from this hook instead of holding a claim while waiting on the
very branches it dispatched.

## AI assistance
- **AI assistance:** Yes
- **Tool(s):** Claude Opus 4.8 via Claude Code
- **Used for:** Locating the terminal funnel and adding the hook.
The AS branch executor enqueued N branches but left them to drain through
Action Scheduler's default runner, which runs ONE batch of 25 actions in one
worker — so a fan-out's N branches ran serially in a single PHP process
instead of N workers each running one branch. Make concurrent branch
execution a property of the substrate so every fan-out consumer gets it for
free, rather than each hand-rolling it:

- dispatch() now triggers the async queue runner itself after enqueuing the
  branches (self::trigger_async_runner), opening N loopback connections
  CONCURRENTLY via Requests::request_multiple (curl_multi) so N distinct
  workers each accept one and claim one branch. Bounded by MAX_BRANCH_
  CONCURRENCY (8). Falls back to serial non-blocking POSTs where the parallel
  Requests API is absent; a no-loopback runtime still drains via AS's own
  cron path.
- A loopback dispatch-URL rewrite points the runner trigger at 127.0.0.1
  (carrying the real host in a Host header). curl_multi bypasses WordPress's
  http_api_curl hook, so a `.local` custom domain would pay a ~5s multicast-
  DNS timeout per handle and the concurrent handles would serialize; the
  rewrite lets every handle connect instantly.
- A persistent AS concurrency policy (registered in the present-only wiring):
  concurrent_batches is RAISED to the pending branch count (capped at
  MAX_BRANCH_CONCURRENCY) and batch_size PINNED to 1 while — and only while —
  this executor's own branches are pending (gated on pending_branch_count()
  over BRANCH_HOOK, read live per call). Together they turn N running workers
  into N distinct branches. On MySQL, each worker's stake_claim uses FOR
  UPDATE SKIP LOCKED, so concurrent claims pick distinct branches. Blast
  radius is bounded: with no branches pending both filters pass through
  unchanged, so other Action Scheduler workloads keep stock behavior.

Branches remain ordinary Action Scheduler actions — enqueued, claimed, run,
and reconciled through AS. This only triggers AS's async runner concurrently
and tunes AS's own concurrency filters; it does not bypass AS.

Verified on a live MySQL native-PHP runtime: a multi-page Site Forge fan-out
completes end to end with the policy resolving correctly (idle concurrent=1
batch=25; with branches pending concurrent=min(N,8) batch=1) and branches
running under the shared substrate, Site Forge holding no concurrency code of
its own.

## AI assistance
- **AI assistance:** Yes
- **Tool(s):** Claude Opus 4.8 via Claude Code
- **Used for:** Diagnosing the serial-drain root cause, relocating the
  concurrency mechanics from a consumer into the substrate, and verifying on
  the live runtime.
The concurrency policy and dispatch read values from apply_filters() and
$_COOKIE, which are `mixed` at PHPStan level max. Normalize each at the point
of use — is_numeric()/absint() coercion for the numeric filter values, and
$_COOKIE directly (it is always a set array) — instead of blind casts. No
behavior change.

## AI assistance
- **AI assistance:** Yes
- **Tool(s):** Claude Opus 4.8 via Claude Code
- **Used for:** Resolving PHPStan level-max findings on the new fan-out code.
@chubes4 chubes4 merged commit 446733e into main Jul 3, 2026
1 of 2 checks passed
@chubes4 chubes4 deleted the feat/concurrent-branch-fanout branch July 3, 2026 00:01
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.

1 participant