Building It

Handling Partial Failures in a Multi-Step Chain

⏱ 17 min

Lesson 5’s coordinator returned early and cleanly whenever a step failed, is_wp_error(), return the error, done. That’s the easy failure to handle: nothing happened yet, so nothing needs undoing. The hard case is the one that’s actually common in a four- or five-step chain: step 3 fails after steps 1 and 2 already changed something real. The refund already went out. The restock call is what just failed. Now what does the coordinator do, and what does it tell whoever’s waiting on the result?

What you'll learn in this lesson
Why "just return the error" isn't enough past step 1
Earlier steps left real, committed changes behind; the response has to account for that.
Compensating actions for the steps that already ran
Not a database rollback, WordPress writes across separate abilities aren't transactional.
Reusing the idempotent annotation from Course 1/2
Deciding which failed steps are safe to simply retry versus which need a distinct compensating step.
Returning enough state for a human or the model to actually recover
A generic failure message strands a real, partially completed order.
Prerequisites

The four-step order-ops/coordinate-return chain from Lesson 5, and the idempotent annotation from Course 1 and Course 2’s ability schema and MCP-hint lessons.

Step 1: Why WordPress ability chains have no rollback

A database transaction can roll back cleanly because every write happens inside one connection, one commit boundary. A chain of abilities is nothing like that: refund-order might call an external payment gateway, restock-returned-item writes to your own inventory table, and neither knows the other exists. There is no ROLLBACK that undoes a completed external refund. Once step 2 has genuinely refunded money, the only way to “undo” a step 3 failure is a deliberate, explicit compensating action, more code you write, not a mechanism WordPress or PHP gives you for free.

Step 2: Classify every step by what failure there actually means

What a failure at each step of the return chain actually implies
StepIf it failsWhat’s already true
1. validate-return-eligibilityNothing happened, safe to just report the error.No side effects yet.
2. refund-orderPayment gateway didn’t confirm, safe to retry the whole chain from here.Depends on the gateway; check its own idempotency guarantee before retrying blindly.
3. restock-returned-itemThe refund already succeeded. Retrying step 2 would double-refund.Refund is committed and irreversible from here.
4. notify-customer-refundedThe refund and restock already succeeded, only the notification is missing.Nothing more needs undoing, only the notification needs a retry.

This table is the actual design work. Once you know, per step, whether a failure there means “nothing happened” or “something irreversible already happened,” you know whether the recovery path is retry the step, retry the whole chain, or run a distinct compensating action.

Step 3: The coordinator, extended to track what already succeeded

// File: order-ops/class-coordinate-return.php (extended)
function order_ops_coordinate_return( $input ) {
	$order_id = $input['order_id'];
	$completed_steps = array();

	$eligibility = wp_get_ability( 'order-ops/validate-return-eligibility' )->execute( array( 'order_id' => $order_id ) );
	if ( is_wp_error( $eligibility ) || empty( $eligibility['eligible'] ) ) {
		return new WP_Error( 'not_eligible', 'Order is not eligible for return.', array( 'completed_steps' => $completed_steps ) );
	}

	$refund = wp_get_ability( 'order-ops/refund-order' )->execute( array(
		'order_id' => $order_id,
		'amount'   => $eligibility['refund_amount'],
	) );
	if ( is_wp_error( $refund ) ) {
		// Nothing committed yet. Safe to retry the whole chain later.
		return new WP_Error( 'refund_failed', $refund->get_error_message(), array( 'completed_steps' => $completed_steps ) );
	}
	$completed_steps[] = 'refund';

	$restock = wp_get_ability( 'order-ops/restock-returned-item' )->execute( array( 'order_id' => $order_id ) );
	if ( is_wp_error( $restock ) ) {
		// The refund already happened. Do not retry refund-order, record the gap instead.
		update_post_meta( $order_id, '_order_ops_chain_state', array(
			'completed_steps' => $completed_steps,
			'failed_step'     => 'restock',
			'refund_amount'   => $eligibility['refund_amount'],
		) );
		return new WP_Error(
			'restock_failed',
			'Refund succeeded but restock failed. Retry order-ops/retry-restock-only for this order.',
			array( 'completed_steps' => $completed_steps, 'order_id' => $order_id )
		);
	}
	$completed_steps[] = 'restock';

	$notify = wp_get_ability( 'order-ops/notify-customer-refunded' )->execute( array(
		'order_id' => $order_id,
		'amount'   => $eligibility['refund_amount'],
	) );
	$completed_steps[] = is_wp_error( $notify ) ? null : 'notify';

	delete_post_meta( $order_id, '_order_ops_chain_state' ); // fully resolved, clear any prior failure record

	return array( 'order_id' => $order_id, 'completed_steps' => array_filter( $completed_steps ) );
}

The _order_ops_chain_state meta written on a step-3 failure is the compensating record, it tells a later recovery attempt exactly what’s already true, so it never re-runs the refund by mistake.

Step 4: A narrow compensating ability for the specific gap

Rather than re-running the whole coordinator (which would call refund-order a second time), register a narrow ability that only does the missing part:

// File: order-ops/class-retry-restock.php
wp_register_ability( 'order-ops/retry-restock-only', array(
	'label'       => 'Retry only the restock step for an already-refunded order',
	'description' => 'For an order where order-ops/coordinate-return already refunded '
		. 'successfully but restocking failed, retries only the restock step. Does not '
		. 'issue a second refund.',
	'input_schema' => array(
		'type' => 'object', 'properties' => array( 'order_id' => array( 'type' => 'integer' ) ),
		'required' => array( 'order_id' ),
	),
	'execute_callback'    => 'order_ops_retry_restock_only',
	'permission_callback' => 'order_ops_can_resolve',
	'meta' => array( 'mcp' => array( 'public' => true ), 'annotations' => array( 'idempotent' => true ) ),
) );

function order_ops_retry_restock_only( $input ) {
	$state = get_post_meta( $input['order_id'], '_order_ops_chain_state', true );
	if ( empty( $state ) || 'restock' !== ( $state['failed_step'] ?? null ) ) {
		return new WP_Error( 'no_pending_restock', 'No pending restock failure recorded for this order.' );
	}

	$restock = wp_get_ability( 'order-ops/restock-returned-item' )->execute( array( 'order_id' => $input['order_id'] ) );
	if ( is_wp_error( $restock ) ) {
		return $restock;
	}

	delete_post_meta( $input['order_id'], '_order_ops_chain_state' );
	return array( 'order_id' => $input['order_id'], 'restocked' => true );
}

Marking this ability idempotent: true is deliberate and matches the annotation concept from Course 1 and Course 2: calling it twice for the same order after the gap is already resolved correctly returns the no_pending_restock error rather than doing anything twice, restocking the same item’s inventory count more than once would be a real, silent bug.

The recovery decision, summarized
1
Failure before any irreversible step
Return the error, safe to retry the whole coordinator from scratch.
2
Failure after an irreversible step, before the chain finishes
Record what already succeeded, then expose a narrow, idempotent ability that retries only the specific missing step.
3
Failure only in a non-critical trailing step
Like the notification in step 4, log it but don't treat the whole chain as failed, the order's actual state is already correct.

Test it

# File: terminal
# Force restock to fail deliberately (e.g. temporarily break order_ops_restock_returned_item),
# then run the chain and confirm the failure response and the meta record:
wp-env run cli wp eval '
$result = wp_get_ability( "order-ops/coordinate-return" )->execute( array( "order_id" => 4821 ) );
var_dump( $result ); // should be a WP_Error mentioning restock_failed
var_dump( get_post_meta( 4821, "_order_ops_chain_state", true ) ); // should show completed_steps: [refund]
'

# Fix the restock bug, then run the compensating ability:
wp-env run cli wp eval '
var_dump( wp_get_ability( "order-ops/retry-restock-only" )->execute( array( "order_id" => 4821 ) ) );
'

Confirm the payment gateway shows exactly one refund for the order, never two, even after the retry, that’s the detail that actually proves the compensating-action design worked.

A generic catch-all error message strands the order

Wrapping the whole coordinator in one broad try/catch and returning "something went wrong" on any failure discards exactly the information Step 3 needs: which steps already committed. A model, or a human, reading that generic message has no way to know a refund already went out, and the natural next action, “just try again,” is precisely how you end up double- refunding an order. Always return which steps completed, not just that something failed.

Recap

WordPress ability chains have no transactional rollback, so a failure past the first irreversible step needs a deliberate compensating design: know which steps are safe to retry outright, record what already succeeded when a step fails after something irreversible, and expose a narrow, idempotent-annotated ability that repairs only the specific gap rather than re-running the whole chain. The annotation isn’t decorative here, it’s what stops a recovery attempt from causing the exact double-write it’s supposed to fix.

Resources & further reading

← Orchestrating Chains of Abilities Monitoring a Multi-Agent System →