Problem
Routines is Action-Scheduler-optional in behavior (is_available() guards everywhere) but not in shape: the generic registry names the AS bridge directly.
src/Routines/class-wp-agent-routine-registry.php calls WP_Agent_Routine_Action_Scheduler_Bridge::{current_generation, is_available, pending_routine_actions, is_paused, register, cancel_action_by_id} in 7 places (lines 209–321 on main @ v0.10.0).
register-routine-bridge-sync.php wires the five lifecycle hooks (wp_agent_routine_{registered,unregistered,paused,resumed,run_now_requested}) straight to the bridge.
- Paused state and generation options are bridge-owned, yet the registry reads them as if they were registry state.
The write side already goes through hooks, which is the right seam. The read side and reconcile() bypass it. Net effect: there is exactly one backend and the generic layer knows its name. For a Core-bound substrate that's the difference between "Agents API depends on Action Scheduler" and "Agents API defines a scheduling contract that Action Scheduler satisfies." Only the second survives a Core review.
The Workflows module has the same coupling (register-workflow-bridge-sync.php, branch executor) — out of scope here; file separately once the Routines pattern is settled.
Ask
- Add
interface WP_Agent_Routine_Backend (in src/Routines/) with the minimal surface the registry actually needs:
public function is_available(): bool;
public function register( WP_Agent_Routine $routine ): bool;
public function unregister( string $routine_id ): void;
public function pause( string $routine_id ): void;
public function resume( WP_Agent_Routine $routine ): bool;
public function run_now( WP_Agent_Routine $routine ): bool;
public function is_paused( string $routine_id ): bool;
public function current_generation( string $routine_id ): ?string;
/** @return array<string, list<int>> routine_id => pending backend handles */
public function pending_by_routine(): array;
public function cancel( int $handle ): bool;
Handles are opaque ints; the registry never interprets them.
WP_Agent_Routine_Action_Scheduler_Bridge becomes an instance class implementing it (keep the static facade for one release as a thin deprecation shim if any consumer calls it directly — grep says only agents-api itself does).
WP_Agent_Routine_Registry::backend(): ?WP_Agent_Routine_Backend resolves via apply_filters( 'wp_agent_routine_backend', $default ) where $default is the AS bridge when AS is available, else null. Registry and reconcile() use only the interface. register-routine-bridge-sync.php becomes backend-agnostic (calls backend()), or is folded into the registry.
- Generation/paused options stay in the AS backend (they are how that backend implements fencing/pause); the interface exposes them as reads so a different backend can implement them differently.
- Smoke: a
tests/routines-backend-contract-smoke.php that registers a fake in-memory backend via the filter and proves register/pause/resume/run_now/reconcile all route through it with AS functions undefined.
Also (layer purity, found while reading)
src/Routines/register-action-scheduler-listener.php:85,95 adds/removes a openclawp_chat_ability_permission filter. That is a vendor/product hook name in the generic substrate — the exact thing the layer-purity rule forbids. It should be a generic hook the product subscribes to (or the product's own filter registered from the product). Fix in the same PR or file a one-liner; either way it should not survive to Core.
Non-goals
Problem
Routines is Action-Scheduler-optional in behavior (
is_available()guards everywhere) but not in shape: the generic registry names the AS bridge directly.src/Routines/class-wp-agent-routine-registry.phpcallsWP_Agent_Routine_Action_Scheduler_Bridge::{current_generation, is_available, pending_routine_actions, is_paused, register, cancel_action_by_id}in 7 places (lines 209–321 onmain@ v0.10.0).register-routine-bridge-sync.phpwires the five lifecycle hooks (wp_agent_routine_{registered,unregistered,paused,resumed,run_now_requested}) straight to the bridge.The write side already goes through hooks, which is the right seam. The read side and
reconcile()bypass it. Net effect: there is exactly one backend and the generic layer knows its name. For a Core-bound substrate that's the difference between "Agents API depends on Action Scheduler" and "Agents API defines a scheduling contract that Action Scheduler satisfies." Only the second survives a Core review.The Workflows module has the same coupling (
register-workflow-bridge-sync.php, branch executor) — out of scope here; file separately once the Routines pattern is settled.Ask
interface WP_Agent_Routine_Backend(insrc/Routines/) with the minimal surface the registry actually needs:WP_Agent_Routine_Action_Scheduler_Bridgebecomes an instance class implementing it (keep the static facade for one release as a thin deprecation shim if any consumer calls it directly — grep says only agents-api itself does).WP_Agent_Routine_Registry::backend(): ?WP_Agent_Routine_Backendresolves viaapply_filters( 'wp_agent_routine_backend', $default )where$defaultis the AS bridge when AS is available, elsenull. Registry andreconcile()use only the interface.register-routine-bridge-sync.phpbecomes backend-agnostic (callsbackend()), or is folded into the registry.tests/routines-backend-contract-smoke.phpthat registers a fake in-memory backend via the filter and proves register/pause/resume/run_now/reconcile all route through it with AS functions undefined.Also (layer purity, found while reading)
src/Routines/register-action-scheduler-listener.php:85,95adds/removes aopenclawp_chat_ability_permissionfilter. That is a vendor/product hook name in the generic substrate — the exact thing the layer-purity rule forbids. It should be a generic hook the product subscribes to (or the product's own filter registered from the product). Fix in the same PR or file a one-liner; either way it should not survive to Core.Non-goals