Coordination

Task Queues for Long-Running Agent Jobs

⏱ 16 min

Every ability so far in this course has returned inside one request/response cycle, fast enough that an MCP client just waits for the answer. Some fulfillment work isn’t like that. Actually reshipping an order might mean calling a shipping carrier’s API, waiting on a response, updating inventory, and sending a customer notification, real work that can take seconds to minutes, not milliseconds. Blocking an agent’s tool call for that long risks a client-side timeout, and it means one slow job holds up whatever else that agent might do next. This lesson covers the actual fix: Action Scheduler, a real, widely used background job library, not raw WP-Cron.

What you'll learn in this lesson
Why WP-Cron alone isn't the right tool here
It has no persistence, no retry, and no real concurrency control out of the box.
Action Scheduler as the real answer
A database-backed queue with logging and retries, already running on any site with WooCommerce or many other plugins installed.
Enqueuing an agent job with as_enqueue_async_action()
Handing off slow work from inside an ability's execute_callback.
Reporting job status back to the agent
So a client can poll or be told to check back, instead of the connection just hanging.
Prerequisites

The order-ops/reship-order ability and the handoff pattern from Lesson 3. This lesson assumes Action Scheduler is available on your site, it ships bundled with WooCommerce and several other popular plugins, or can be required directly as a Composer dependency (woocommerce/action-scheduler).

Step 1: Why not just run it inline, or just use wp_schedule_event()

Running slow work inline inside execute_callback means the MCP request stays open the whole time, at the mercy of whatever timeout the client, your web server, or a reverse proxy in between enforces. WP-Cron’s wp_schedule_event() avoids blocking the request but brings its own problems for this use case: it only fires on incoming site traffic, has no built-in retry if a job fails partway, and gives you no persisted record of what ran, when, or with what result. Action Scheduler solves exactly this gap: it persists every scheduled action as a row in the database, retries failed jobs, logs outcomes, and lets you control concurrency, which is why WooCommerce and a large share of the WordPress plugin ecosystem already depend on it for background processing.

Step 2: Enqueuing the job from an ability

The ability itself stays fast. It validates the request, hands the actual work off to Action Scheduler, and returns immediately with something the agent can relay to whoever is waiting.

// File: order-ops/class-reship-order.php
wp_register_ability( 'order-ops/reship-order', array(
	'label'       => 'Reship an order',
	'description' => 'Queues a reshipment for a confirmed lost-or-damaged order. This runs '
		. 'in the background, since it involves a carrier API call; it does not complete '
		. 'before this call returns. Use order-ops/get-reship-status to check on progress.',
	'input_schema' => array(
		'type'       => 'object',
		'properties' => array( 'order_id' => array( 'type' => 'integer' ) ),
		'required'   => array( 'order_id' ),
	),
	'execute_callback'    => 'order_ops_reship_order',
	'permission_callback' => 'order_ops_can_resolve',
	'meta' => array(
		'mcp'         => array( 'public' => true ),
		'annotations' => array( 'destructive' => true, 'idempotent' => false ),
	),
) );

function order_ops_reship_order( $input ) {
	$order = get_post( $input['order_id'] );
	if ( ! $order ) {
		return new WP_Error( 'not_found', 'No order with that ID exists.' );
	}

	update_post_meta( $input['order_id'], '_order_ops_reship_status', 'queued' );

	$action_id = as_enqueue_async_action(
		'order_ops_process_reshipment',   // hook
		array( 'order_id' => $order->ID ), // args
		'order-ops-fulfillment',           // group
		true                                // unique: don't double-queue the same order
	);

	return array(
		'order_id'   => $order->ID,
		'status'     => 'queued',
		'action_id'  => $action_id,
	);
}

add_action( 'order_ops_process_reshipment', 'order_ops_process_reshipment', 10, 1 );

function order_ops_process_reshipment( $order_id ) {
	update_post_meta( $order_id, '_order_ops_reship_status', 'processing' );

	$carrier_response = order_ops_call_carrier_api( $order_id ); // the genuinely slow part

	if ( is_wp_error( $carrier_response ) ) {
		update_post_meta( $order_id, '_order_ops_reship_status', 'failed' );
		throw new Exception( $carrier_response->get_error_message() ); // lets Action Scheduler log and retry
	}

	update_post_meta( $order_id, '_order_ops_reship_status', 'completed' );
	update_post_meta( $order_id, '_order_ops_tracking_number', $carrier_response['tracking_number'] );
}

as_enqueue_async_action() schedules the hook to run as soon as possible, in the background, outside the current request. Passing true for unique stops the same order from being queued twice if the agent, or a person, calls reship-order again before the first job has finished. Throwing an exception from the hook callback on failure is deliberate, Action Scheduler catches it, marks the action failed, and logs it, giving you the retry and audit trail WP-Cron never provided.

Step 3: Letting the agent (and a human) check on progress

Since the ability itself returns before the job finishes, register a second, read-only ability the agent can call to check status, using the post meta the background job writes as it progresses:

// File: order-ops/class-reship-status.php
wp_register_ability( 'order-ops/get-reship-status', array(
	'label'       => 'Check the status of a queued reshipment',
	'description' => 'Returns the current status (queued, processing, completed, failed) of '
		. 'a reshipment started by order-ops/reship-order, and the tracking number once complete.',
	'input_schema' => array(
		'type'       => 'object',
		'properties' => array( 'order_id' => array( 'type' => 'integer' ) ),
		'required'   => array( 'order_id' ),
	),
	'execute_callback'    => 'order_ops_get_reship_status',
	'permission_callback' => 'order_ops_can_resolve',
	'meta' => array( 'mcp' => array( 'public' => true ), 'annotations' => array( 'readonly' => true ) ),
) );

function order_ops_get_reship_status( $input ) {
	return array(
		'order_id'        => $input['order_id'],
		'status'          => get_post_meta( $input['order_id'], '_order_ops_reship_status', true ) ?: 'unknown',
		'tracking_number' => get_post_meta( $input['order_id'], '_order_ops_tracking_number', true ) ?: null,
	);
}

A client’s model, told in reship-order’s own description that the job runs in the background, will typically call get-reship-status a moment later, or tell the person it’s talking to that the reshipment is queued and to check back, exactly the behavior you want instead of the model waiting on a call that was never going to finish inline.

Test it

# File: terminal
wp-env run cli wp eval '
$result = wp_get_ability( "order-ops/reship-order" )->execute( array( "order_id" => 4821 ) );
var_dump( $result ); // status should be "queued", with an action_id
'

# Run the Action Scheduler queue (normally handled by its own async runner or WP-Cron)
wp-env run cli wp action-scheduler run

wp-env run cli wp eval '
var_dump( wp_get_ability( "order-ops/get-reship-status" )->execute( array( "order_id" => 4821 ) ) );
'

The second var_dump() should show completed, with a tracking number, or failed if you deliberately break order_ops_call_carrier_api() to test the failure path, confirming Action Scheduler actually logged the failure rather than losing it silently.

Don't reinvent a queue with wp_schedule_single_event()

It’s tempting to reach for wp_schedule_single_event() here since it’s built into core with no extra dependency. It runs, at best, once, whenever the next page load happens to trigger WP-Cron, with no persisted log of success or failure and no retry if the callback throws. Action Scheduler exists specifically because that’s not good enough for background jobs real plugins depend on, and it’s already a dependency of WooCommerce and much of the plugin ecosystem, reach for it directly rather than rebuilding a weaker version of it yourself.

Recap

Any ability whose real work takes long enough to risk a timeout, a carrier API call, a bulk operation, anything with an external round trip, should enqueue that work with as_enqueue_async_action() and return immediately with a status the agent can relay. The background hook callback does the actual slow work and updates a status field a second, read-only ability can report on. Action Scheduler’s database-backed persistence, retry, and logging are exactly the guarantees raw WP-Cron doesn’t provide, which is why it’s the right tool here rather than an invented queueing mechanism.

Resources & further reading

← Agent Handoffs Between Specialized Roles Orchestrating Chains of Abilities →