feat(routines): generation fencing, stagger offsets, and reconcile for the Action Scheduler bridge - #540
Conversation
…r the Action Scheduler bridge Closes Automattic#539. Generation fencing: bridge::register() mints a schedule generation, persists it in agents_routine_generation_<id>, and stamps it into the scheduled action args as a trailing metadata element so the logical args (routine_id) stay stable. Fetched routine actions are wrapped in WP_Agent_Generation_Fenced_Action, which no-ops execution (and reports its schedule as canceled) once the stamped generation is no longer current; action_scheduler_stored_action cancels recurrence successors born with a stale generation. The wake listener resolves routines by logical args. Stagger: WP_Agent_Routine accepts stagger => bool|int and computes a deterministic id-derived first-run offset (crc32 seed, capped by MAX_STAGGER_SECONDS and the interval); the bridge applies it to the first run of interval routines. Cron expressions are never staggered. Reconcile: WP_Agent_Routine_Registry::reconcile() compares registry coverage against pending routine actions by logical identity, enqueues missing schedules, removes orphans (including actions left behind for durably paused routines), supports dry runs, and serializes runs behind an add_option() CAS lock with stale-takeover. Pause state is now durable (agents_routine_paused) so reconcile can tell intentional unscheduling from drift. Exposed as the agents/reconcile-routines ability (show_in_rest, manage_options, destructive + idempotent). Covered by tests/routines-durability-smoke.php against a fake in-memory Action Scheduler store.
|
Read the whole diff, not just the body. This is well-built: fencing sits at the right AS seam ( Blocking — The first consumer has ~700 routines. Bulk registration (activation, Fix without giving up correctness: query by logical identity, not by scanning. AS stores
I'd take (1) — it also removes Non-blocking, worth a follow-up issue: stagger uses Also confirm in the PR body that the |
|
Recreated as an in-repo branch (no fork needed) — see the replacement PR linked below. |
|
Replacement: #541 |
Closes #539.
What this adds
Generation fencing. Every
WP_Agent_Routine_Action_Scheduler_Bridge::register()mints a generation (wp_generate_uuid4()), persists it in the non-autoloadedagents_routine_generation_<id>option, and stamps it into the scheduled action's args as a trailing metadata element so the logical args (array( 'routine_id' => ... )) remain stable for identity work — Action Scheduler matches args by exact JSON equality, so nothing in the bridge ever queries by stamped args.action_scheduler_stored_action_instancewraps every fetched routine action inWP_Agent_Generation_Fenced_Action, whoseexecute()no-ops (firingagents_routine_action_fenced) and whoseget_schedule()reports a canceled schedule once the stamped generation is no longer current — so a superseded action neither wakes the routine nor spawns a recurrence successor.action_scheduler_stored_actionadditionally cancels successors stored with a stale generation (the race where an in-flight old-chain action finishes after re-registration and AS'srepeat()clones the superseded args). The wake listener resolves the routine by logical args; legacy unstamped actions drain as fenced no-ops instead of double-firing beside their stamped replacements.Stagger offsets.
WP_Agent_Routineacceptsstagger => bool|int(defaulttruefor interval routines,falsefor cron expressions, where the expression already is the slot; an int is an explicit max window in seconds).stagger_offset()computescrc32( 'agents_routine_stagger_' . $id ) % min( interval, window ), capped by the newMAX_STAGGER_SECONDS(3600) — deterministic, so re-registration always lands the routine back in the same slot, and co-scheduled interval routines no longer all fire in the same second. The bridge adds the offset to the first-run timestamp.Reconcile.
WP_Agent_Routine_Registry::reconcile( array $opts = [] )repairs drift between the registry and the AS store: for every registered, non-paused routine it checks pending-action coverage by logical identity (hook + logical args + group) and enqueues a fresh schedule when missing; pending routine actions whose logicalroutine_idis not registered (or is durably paused) are unscheduled as orphans. Returnsarray( 'enqueued' => [ids], 'removed' => [ids], 'unchanged' => [ids], 'errors' => [id => message] );$opts['dry_run']reports the same shape without writing. Runs are serialized behind anadd_option( 'agents_routine_reconcile_lock', time(), '', false )CAS lock —add_optionfails when the row exists; a lock older than 5 minutes is treated as stale and taken over; release happens infinally. To make "non-paused" well-defined across requests, pause state is now durable: the bridge maintains anagents_routine_pausedoption (a small, generic change to the previously stateless pause contract — the registry itself stays in-memory).New public surface
WP_Agent_Routine_Action_Identity—with_generation(),generation_from_args(),logical_args().WP_Agent_Generation_Fenced_Action(declared only when\ActionScheduler_Actionexists, so the bootstrap can require the file unconditionally).WP_Agent_Routine—staggerarg,stagger_offset(),get_stagger_window(),MAX_STAGGER_SECONDS;to_array()round-trips the stagger window.WP_Agent_Routine_Action_Scheduler_Bridge—generation_option_name(),current_generation(),is_paused(),pending_routine_actions(),cancel_action_by_id(),register_generation_fence(), fence callbacksfence_stored_action/reconcile_stored_successor;unregister()now deletes the generation tombstone.WP_Agent_Routine_Registry—current_generation(),reconcile().agents/reconcile-routinesability (src/Routines/register-routine-abilities.php): input{ dry_run?: bool }, output the reconcile report,show_in_rest: true, permissioncurrent_user_can( 'manage_options' )filterable viaagents_reconcile_routines_permission, annotationsdestructive: true, idempotent: true.agents_routine_action_fenced(routine id, stale generation, action).Testing
tests/routines-durability-smoke.php(registered incomposer smoke) runs against a fake in-memory Action Scheduler: theas_*function shims are backed by an action-row store whosefetch_actionappliesaction_scheduler_stored_action_instanceand whose saves fireaction_scheduler_stored_action, plus minimalActionScheduler_Action/ schedule / store class fakes and a plain-array option layer. 52 assertions cover: the stamp/strip identity round-trip, deterministic stagger slots (including window capping and disabled cases), re-registration advancing the generation and fencing the superseded claimed instance (no callback, fenced event, non-recurring schedule), current-generation execution still firing, stale-vs-current successor reconciliation, reconcile enqueued/unchanged/idempotency, orphan removal, paused routines neither enqueued nor left pending, dry-run writing nothing (no action, no generation advance, no lock), fresh-lock blocking + stale-lock takeover, and unregister teardown. The existingtests/routine-smoke.phppasses unmodified — the bridge falls back toas_unschedule_all_actionswhen the granular store API is absent, which is what its fakes exercise.Consumer
Extra-Chill/data-machine#3458is the first consumer — it will register flows and system tasks as Routines and delete its private copies of this machinery.Docs: the routines section of
docs/channels-workflows-operations.mdgains fencing, stagger, and reconcile subsections.AI-generated with Claude Code via Homeboy worktree.