Repair WooCommerce core: scheduling, cron, and email
Action Scheduler stuck in-progress
One action never finished, and now it is sitting on in-progress forever. Renewals stop firing, retry emails stop going out, and the queue behind it gets longer every hour. This is what makes an action freeze mid run, why it can block the queue, and a small script that finds every stuck action, checks Stripe for the truth, and tells you exactly which ones are safe to reset.
An Action Scheduler action moves to in-progress the instant a worker claims it, and only moves to complete once that worker returns. If the worker crashes, times out, or is killed partway through, no one ever marks the action finished, so it stays on in-progress permanently and can hold up its group. Run a small Python or Node.js auditor that reads the stuck actions, checks Stripe for whatever payment they were attempting, and tells you which are safe to reset and which still have money in flight. Full code, tests, and a dry run guard are below.
The problem in plain words
Action Scheduler is the job queue that runs inside WooCommerce. Subscription renewals, retry emails, webhook processing, and dozens of other background tasks all move through it as rows in a database table. Each row has a status. Most of the time a row goes from pending, to in-progress, to complete in well under a second, and nobody ever looks at the table directly.
The in-progress status exists so two workers do not grab the same job at once. A worker claims a batch of actions, marks them in-progress, runs them, then marks each one complete or failed when it is done. That last step is the part that can be skipped. If the PHP process dies while the action is running, from a page timeout, a memory limit, a fatal error in a plugin, or the server being restarted, the action is left exactly where it was: in-progress, with nobody left to finish it.
Why it happens
Action Scheduler's own documentation describes in-progress as a claim lock meant to last seconds, not hours. A few common reasons a worker never gets to release that lock:
- The action ran during a normal page load or WP-Cron request that hit the host's execution time limit, so PHP was killed mid function with no chance to clean up.
- The action hit a fatal error, an uncaught exception in a payment gateway or a third party plugin hooked onto the same action, which stops PHP before the completion callback runs.
- The server or container was restarted, redeployed, or ran out of memory while a batch of actions was mid claim.
- A long running action, like a large CSV export or a bulk email send, was stopped manually or by a process manager before it finished.
This is reported often enough that it has its own section in the Action Scheduler troubleshooting docs, and support threads describe subscription renewals silently stopping because the hook that runs them is stuck behind a single frozen row. See the citations at the end for the exact references.
An action stuck on in-progress does not tell you what happened to the payment it was trying to process. It only tells you the worker never came back. Stripe is still the source of truth for money. The safe move is to ask Stripe what actually happened for that attempt, not to guess from the WooCommerce side alone.
The fix, as a flow
We do not touch wp_actionscheduler_actions directly, and we do not blindly reset every stuck row. We export the actions that have been sitting on in-progress past a safe threshold, load the order each one was working on, and ask Stripe about the PaymentIntent tied to that order. Only once we know Stripe's answer do we decide what to do: finish the order if Stripe already succeeded, flag the action safe to reset if nothing was charged, or leave it alone if the payment is still genuinely in flight.
Build it step by step
Export the stuck actions and get access to both systems
List the actions currently sitting on in-progress with WP-CLI, since Action Scheduler does not expose this over the WooCommerce REST API. Add the linked order ID and how many minutes each one has been stuck to that export. You also need a Stripe secret key and a WooCommerce REST API key pair with read and write access to orders.
pip install stripe requests
wp action-scheduler action list --status=in-progress --format=json > stuck.json
# add order_id and age_minutes to each row before running the auditor
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export STUCK_AFTER_MINUTES="30"
export DRY_RUN="true" # start safe, change to false to write
npm install stripe
wp action-scheduler action list --status=in-progress --format=json > stuck.json
// add orderId and ageMinutes to each row before running the auditor
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export STUCK_AFTER_MINUTES="30"
export DRY_RUN="true" // start safe, change to false to write
Load the order behind the stuck action
Use the WooCommerce REST API to read the order by ID. Going through the REST API means the code works the same whether the store keeps orders in posts or has High Performance Order Storage turned on, since WooCommerce handles the storage either way. Then read the saved Stripe PaymentIntent ID off the order, from meta _stripe_intent_id or, as a fallback, transaction_id when it looks like an intent.
import os, requests
from requests.auth import HTTPBasicAuth
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
def get_order(order_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def intent_id_of(order):
for meta in order.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = order.get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
async function woo(path, options = {}) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
...options,
headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
export function intentIdOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = order.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
Decide, with one pure function
Keep the decision in its own function that takes the stuck action, the order, and the Stripe intent, and returns a verdict. A pure function like this is easy to read and easy to test, which we do later. It skips actions that are not actually stuck, waits on ones that have not aged past the threshold, holds off when Stripe shows the payment still in flight, and only then decides whether to finish the order or flag the action as safe to reset.
PAID_STATUSES = {"processing", "completed"}
IN_FLIGHT_INTENT_STATUSES = {"requires_action", "requires_confirmation", "processing"}
FAILED_INTENT_STATUSES = {"requires_payment_method", "canceled"}
def decide(action, order, intent):
if action.get("status") != "in-progress":
return ("skip", "action is not in-progress")
if action.get("age_minutes", 0) < STUCK_AFTER_MINUTES:
return ("wait", "action has not been stuck long enough yet")
if order is None:
return ("investigate", "action points to an order that cannot be found")
if intent is not None and intent.get("status") in IN_FLIGHT_INTENT_STATUSES:
return ("investigate", "Stripe shows the payment still in flight")
if order.get("status") in PAID_STATUSES:
return ("reset_action", "order is already paid, the action is just stale")
if intent is not None and intent.get("status") == "succeeded":
return ("complete_order", "Stripe succeeded but the order was never updated")
if intent is None or intent.get("status") in FAILED_INTENT_STATUSES:
return ("reset_action", "no successful charge behind this attempt, safe to retry")
return ("investigate", "unclear Stripe state, needs a human look")
const PAID_STATUSES = new Set(["processing", "completed"]);
const IN_FLIGHT_INTENT_STATUSES = new Set(["requires_action", "requires_confirmation", "processing"]);
const FAILED_INTENT_STATUSES = new Set(["requires_payment_method", "canceled"]);
export function decide(action, order, intent) {
if (action.status !== "in-progress") return ["skip", "action is not in-progress"];
if ((action.ageMinutes || 0) < STUCK_AFTER_MINUTES) return ["wait", "action has not been stuck long enough yet"];
if (!order) return ["investigate", "action points to an order that cannot be found"];
if (intent && IN_FLIGHT_INTENT_STATUSES.has(intent.status)) {
return ["investigate", "Stripe shows the payment still in flight"];
}
if (PAID_STATUSES.has(order.status)) return ["reset_action", "order is already paid, the action is just stale"];
if (intent && intent.status === "succeeded") return ["complete_order", "Stripe succeeded but the order was never updated"];
if (!intent || FAILED_INTENT_STATUSES.has(intent.status)) {
return ["reset_action", "no successful charge behind this attempt, safe to retry"];
}
return ["investigate", "unclear Stripe state, needs a human look"];
}
Act on the verdict, never on a guess
When the verdict is complete_order, set the order to Processing, save the charge ID, and add a note explaining the recovery. When it is reset_action or investigate, add a note to the order so a shop manager can see it and reset the actual Action Scheduler row from wp-admin or with wp action-scheduler action update --id=<id> --status=pending. The script never edits the actions table itself, since that is WooCommerce's job to own.
def complete_order(order_id, intent):
charge_id = intent.get("latest_charge") or intent["id"]
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
json={"status": "processing", "transaction_id": charge_id},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": f"Recovered from a stuck Action Scheduler action. Stripe PaymentIntent "
f"{intent['id']} had already succeeded. Marked processing by the auditor."},
auth=AUTH, timeout=30,
).raise_for_status()
def note_stuck_action(order_id, reason):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": f"Action Scheduler action for this order was stuck on in-progress: "
f"{reason}. Flagged for a reset by the auditor."},
auth=AUTH, timeout=30,
).raise_for_status()
async function completeOrder(orderId, intent) {
const chargeId = intent.latest_charge || intent.id;
await woo(`/orders/${orderId}`, {
method: "PUT",
body: JSON.stringify({ status: "processing", transaction_id: chargeId }),
});
await woo(`/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Recovered from a stuck Action Scheduler action. Stripe PaymentIntent ` +
`${intent.id} had already succeeded. Marked processing by the auditor.`,
}),
});
}
async function noteStuckAction(orderId, reason) {
await woo(`/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Action Scheduler action for this order was stuck on in-progress: ` +
`${reason}. Flagged for a reset by the auditor.`,
}),
});
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports its plan. Read the output, trust it, then switch it off to let it write. Run it on a schedule with cron every fifteen to thirty minutes, and refresh the stuck actions export each time.
Always start with DRY_RUN=true. This script writes order notes and can change order status, so you want to see its plan first. Never reset an action for a payment you have not checked against Stripe.
The full code
Here is the complete auditor in one file for each language. It reads settings from the environment, respects the dry run flag, and never touches an order while Stripe still shows the payment in flight, so it is safe to run again and again.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Find Action Scheduler actions stuck on in-progress and decide how to clear them.
An action normally moves from pending, to in-progress, to complete within seconds.
When the PHP worker that claimed an action dies mid run (a timeout, an out of memory
kill, a fatal error), the action is left on in-progress forever. Action Scheduler's
own claim lock then treats that slot as busy, so the next run of that group or hook
can stall behind it, and the queue backs up.
This script does not touch wp_actionscheduler_actions directly. It uses the
WooCommerce REST API to read the order that a stuck subscription renewal or payment
action points to (order id is taken from the action's hook args, passed in on the
command line or from a small JSON export), asks Stripe for the truth about the
PaymentIntent on that order, and decides one of four outcomes:
- "complete_order": Stripe says the payment succeeded. Mark the order processing
and add a note. The stuck action can be marked complete in wp-admin or with
`wp action-scheduler action update --id=<id> --status=complete`.
- "reset_action": Stripe never took a real payment for this attempt (no intent,
or the intent failed or is still requiring action). Safe to reset the action
back to pending so it can be retried, since nothing was charged.
- "wait": the action has not been stuck long enough yet to act on. Actions can
briefly sit on in-progress during a normal, slow run.
- "investigate": Stripe shows a PaymentIntent that requires_action or is
processing. Money is in flight. Do not touch the order or the action yet.
Read only by default (DRY_RUN=true). Run on a schedule, for example every 15
minutes, well past STUCK_AFTER_MINUTES.
"""
import os
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("audit_stuck_actions")
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
STUCK_AFTER_MINUTES = int(os.environ.get("STUCK_AFTER_MINUTES", "30"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
PAID_STATUSES = {"processing", "completed"}
IN_FLIGHT_INTENT_STATUSES = {"requires_action", "requires_confirmation", "processing"}
FAILED_INTENT_STATUSES = {"requires_payment_method", "canceled"}
def intent_id_of(order):
"""The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
for meta in order.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = order.get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def decide(action, order, intent):
"""Pure decision function. No I/O. action is a dict with at least
status and age_minutes. order and intent may be None.
Returns (verdict, reason).
"""
if action.get("status") != "in-progress":
return ("skip", "action is not in-progress")
if action.get("age_minutes", 0) < STUCK_AFTER_MINUTES:
return ("wait", "action has not been stuck long enough yet")
if order is None:
return ("investigate", "action points to an order that cannot be found")
if intent is not None and intent.get("status") in IN_FLIGHT_INTENT_STATUSES:
return ("investigate", "Stripe shows the payment still in flight")
if order.get("status") in PAID_STATUSES:
return ("reset_action", "order is already paid, the action is just stale")
if intent is not None and intent.get("status") == "succeeded":
return ("complete_order", "Stripe succeeded but the order was never updated")
if intent is None or intent.get("status") in FAILED_INTENT_STATUSES:
return ("reset_action", "no successful charge behind this attempt, safe to retry")
return ("investigate", "unclear Stripe state, needs a human look")
def get_order(order_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def get_intent(intent_id):
if not intent_id:
return None
try:
return stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
def complete_order(order_id, intent):
charge_id = intent.get("latest_charge") or intent["id"]
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
json={"status": "processing", "transaction_id": charge_id},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": f"Recovered from a stuck Action Scheduler action. Stripe PaymentIntent "
f"{intent['id']} had already succeeded. Marked processing by the auditor."},
auth=AUTH, timeout=30,
).raise_for_status()
def note_stuck_action(order_id, reason):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": f"Action Scheduler action for this order was stuck on in-progress: "
f"{reason}. Flagged for a reset by the auditor."},
auth=AUTH, timeout=30,
).raise_for_status()
def stuck_actions_from_export(path):
"""Read a small JSON export of stuck actions. Each row looks like:
{"action_id": 4821, "status": "in-progress", "age_minutes": 55, "order_id": 9321}
Produce this with:
wp action-scheduler action list --status=in-progress --format=json > stuck.json
then add age_minutes and order_id per hook args before feeding it in, or adapt
this loader to your own store's export shape.
"""
import json
with open(path) as f:
return json.load(f)
def run(export_path):
handled = 0
for action in stuck_actions_from_export(export_path):
order_id = action.get("order_id")
order = get_order(order_id) if order_id else None
intent = get_intent(intent_id_of(order)) if order else None
verdict, reason = decide(action, order, intent)
if verdict in ("skip", "wait"):
continue
log.info(
"Action %s (order %s): %s -> %s",
action.get("action_id"), order_id, reason,
"would act" if DRY_RUN else "acting",
)
if not DRY_RUN:
if verdict == "complete_order":
complete_order(order_id, intent)
elif verdict in ("reset_action", "investigate"):
note_stuck_action(order_id, reason)
handled += 1
log.info("Done. %d stuck action(s) %s.", handled, "to handle" if DRY_RUN else "handled")
if __name__ == "__main__":
import sys
run(sys.argv[1] if len(sys.argv) > 1 else "stuck.json")
/**
* Find Action Scheduler actions stuck on in-progress and decide how to clear them.
*
* An action normally moves from pending, to in-progress, to complete within seconds.
* When the PHP worker that claimed an action dies mid run (a timeout, an out of
* memory kill, a fatal error), the action is left on in-progress forever. Action
* Scheduler's own claim lock then treats that slot as busy, so the next run of that
* group or hook can stall behind it, and the queue backs up.
*
* This script does not touch wp_actionscheduler_actions directly. It uses the
* WooCommerce REST API to read the order that a stuck subscription renewal or
* payment action points to (order id comes from a small JSON export of stuck
* actions), asks Stripe for the truth about the PaymentIntent on that order, and
* decides one of four outcomes: complete_order, reset_action, wait, or investigate.
*
* Read only by default (DRY_RUN=true). Run on a schedule.
*/
import Stripe from "stripe";
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const STUCK_AFTER_MINUTES = Number(process.env.STUCK_AFTER_MINUTES || 30);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PAID_STATUSES = new Set(["processing", "completed"]);
const IN_FLIGHT_INTENT_STATUSES = new Set(["requires_action", "requires_confirmation", "processing"]);
const FAILED_INTENT_STATUSES = new Set(["requires_payment_method", "canceled"]);
export function intentIdOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = order.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
/**
* Pure decision function. No I/O. action is an object with at least status and
* ageMinutes. order and intent may be null. Returns [verdict, reason].
*/
export function decide(action, order, intent) {
if (action.status !== "in-progress") return ["skip", "action is not in-progress"];
if ((action.ageMinutes || 0) < STUCK_AFTER_MINUTES) return ["wait", "action has not been stuck long enough yet"];
if (!order) return ["investigate", "action points to an order that cannot be found"];
if (intent && IN_FLIGHT_INTENT_STATUSES.has(intent.status)) {
return ["investigate", "Stripe shows the payment still in flight"];
}
if (PAID_STATUSES.has(order.status)) return ["reset_action", "order is already paid, the action is just stale"];
if (intent && intent.status === "succeeded") return ["complete_order", "Stripe succeeded but the order was never updated"];
if (!intent || FAILED_INTENT_STATUSES.has(intent.status)) {
return ["reset_action", "no successful charge behind this attempt, safe to retry"];
}
return ["investigate", "unclear Stripe state, needs a human look"];
}
async function woo(path, options = {}) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
...options,
headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function getIntent(intentId) {
if (!intentId) return null;
try {
return await stripe.paymentIntents.retrieve(intentId);
} catch {
return null;
}
}
async function completeOrder(orderId, intent) {
const chargeId = intent.latest_charge || intent.id;
await woo(`/orders/${orderId}`, {
method: "PUT",
body: JSON.stringify({ status: "processing", transaction_id: chargeId }),
});
await woo(`/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Recovered from a stuck Action Scheduler action. Stripe PaymentIntent ` +
`${intent.id} had already succeeded. Marked processing by the auditor.`,
}),
});
}
async function noteStuckAction(orderId, reason) {
await woo(`/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Action Scheduler action for this order was stuck on in-progress: ` +
`${reason}. Flagged for a reset by the auditor.`,
}),
});
}
/**
* Read a small JSON export of stuck actions. Each row looks like:
* { "actionId": 4821, "status": "in-progress", "ageMinutes": 55, "orderId": 9321 }
* Produce this with:
* wp action-scheduler action list --status=in-progress --format=json
* then add ageMinutes and orderId per hook args, or adapt this loader to your
* own store's export shape.
*/
async function stuckActionsFromExport(path) {
const raw = await readFile(path, "utf8");
return JSON.parse(raw);
}
export async function run(exportPath = "stuck.json") {
let handled = 0;
for (const action of await stuckActionsFromExport(exportPath)) {
const orderId = action.orderId;
const order = orderId ? await woo(`/orders/${orderId}`) : null;
const intent = order ? await getIntent(intentIdOf(order)) : null;
const [verdict, reason] = decide(action, order, intent);
if (verdict === "skip" || verdict === "wait") continue;
console.log(
`Action ${action.actionId} (order ${orderId}): ${reason} -> ${DRY_RUN ? "would act" : "acting"}`
);
if (!DRY_RUN) {
if (verdict === "complete_order") await completeOrder(orderId, intent);
else if (verdict === "reset_action" || verdict === "investigate") await noteStuckAction(orderId, reason);
}
handled++;
}
console.log(`Done. ${handled} stuck action(s) ${DRY_RUN ? "to handle" : "handled"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run(process.argv[2]).catch((e) => { console.error(e); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether a real order gets rewritten or a real action gets reset. Because decide is pure, the test needs no network, no Stripe account, and no WordPress install. It just feeds in plain objects and checks the verdict.
from audit_stuck_actions import decide, intent_id_of
def action(**over):
base = {"status": "in-progress", "age_minutes": 55, "action_id": 1}
base.update(over)
return base
def order(**over):
base = {"status": "pending", "total": "50.00"}
base.update(over)
return base
def intent(**over):
base = {"status": "succeeded", "id": "pi_1"}
base.update(over)
return base
def test_skip_when_action_not_in_progress():
assert decide(action(status="complete"), order(), intent())[0] == "skip"
def test_wait_when_not_stuck_long_enough():
assert decide(action(age_minutes=5), order(), intent())[0] == "wait"
def test_investigate_when_order_missing():
assert decide(action(), None, None)[0] == "investigate"
def test_investigate_when_payment_in_flight():
assert decide(action(), order(), intent(status="requires_action"))[0] == "investigate"
def test_reset_when_order_already_paid():
assert decide(action(), order(status="processing"), intent())[0] == "reset_action"
def test_complete_order_when_stripe_succeeded_but_order_unpaid():
verdict, _ = decide(action(), order(status="pending"), intent(status="succeeded"))
assert verdict == "complete_order"
def test_reset_when_no_intent_at_all():
assert decide(action(), order(), None)[0] == "reset_action"
def test_reset_when_intent_failed():
assert decide(action(), order(), intent(status="requires_payment_method"))[0] == "reset_action"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf } from "./audit-stuck-actions.js";
const action = (over = {}) => ({ status: "in-progress", ageMinutes: 55, actionId: 1, ...over });
const order = (over = {}) => ({ status: "pending", total: "50.00", ...over });
const intent = (over = {}) => ({ status: "succeeded", id: "pi_1", ...over });
test("skip when action not in-progress", () => {
assert.equal(decide(action({ status: "complete" }), order(), intent())[0], "skip");
});
test("wait when not stuck long enough", () => {
assert.equal(decide(action({ ageMinutes: 5 }), order(), intent())[0], "wait");
});
test("investigate when order missing", () => {
assert.equal(decide(action(), null, null)[0], "investigate");
});
test("investigate when payment in flight", () => {
assert.equal(decide(action(), order(), intent({ status: "requires_action" }))[0], "investigate");
});
test("reset_action when order already paid", () => {
assert.equal(decide(action(), order({ status: "processing" }), intent())[0], "reset_action");
});
test("complete_order when Stripe succeeded but order unpaid", () => {
const [verdict] = decide(action(), order({ status: "pending" }), intent({ status: "succeeded" }));
assert.equal(verdict, "complete_order");
});
Case studies
The renewal batch that hit the PHP memory cap
A store on a shared host ran a batch of subscription renewals during its busiest hour. One renewal in the batch loaded a customer with an unusually long order history, blew past the PHP memory limit, and the worker was killed mid run. That single action sat on in-progress for two days, and everything else queued behind the same hook quietly stopped firing.
The auditor found the frozen action, checked Stripe, saw no charge had gone through for that attempt, and flagged it safe to reset. Once reset, the batch and everything behind it picked back up within the hour.
The deploy that landed mid webhook
A routine deploy restarted the app servers while a webhook processing action was mid execution. The action never got to mark itself complete, and the Stripe event it was handling, a successful renewal charge, was left unrecorded on the WooCommerce side.
The auditor asked Stripe directly, confirmed the PaymentIntent had in fact succeeded, and finished the order itself rather than waiting on a retry that would never come from the frozen action.
After this runs on a schedule, a crashed worker is no longer a silent queue jam. The worst case becomes a short delay before the auditor notices the frozen action, checks Stripe, and either finishes the order or flags the action for a clean reset. Keep it running even after you fix whatever caused the crash, because a worker dying mid run will always happen once in a while.
FAQ
Why do Action Scheduler actions get stuck on in-progress?
An action moves to in-progress the moment a worker claims it, and only moves to complete after the worker finishes. If the worker dies first, from a timeout, an out of memory kill, or a fatal error, nothing ever marks the action finished, so it sits on in-progress forever.
Is it safe to reset a stuck action myself?
It is safe once you know what the action was trying to do. For a payment or renewal action, check Stripe first. If Stripe shows no successful charge for that attempt, resetting the action to pending is safe, since nothing was charged. If Stripe already succeeded, finish the order directly instead of retrying the action.
How often should the auditor run?
Every fifteen to thirty minutes is enough for most stores. It only acts on actions that have been stuck longer than a set number of minutes, so running it often is safe and will not interrupt actions that are still genuinely working.
Related field notes
Citations
On the problem:
- Action Scheduler documentation: action statuses, including in-progress as a short lived claim lock. actionscheduler.org/faq
- WooCommerce developer docs: how Action Scheduler processes background tasks and batches. developer.woocommerce.com/docs/features/action-scheduler
- WordPress.org support: subscription renewals silently stop when a hook is stuck behind a frozen action. wordpress.org/support/plugin/woocommerce
On the solution:
- Stripe API: retrieve a PaymentIntent to check its current status before acting. docs.stripe.com/api/payment_intents/retrieve
- WP-CLI Action Scheduler command reference: listing and updating actions from the command line. actionscheduler.org/wp-cli
- WooCommerce REST API: update an order and add an order note. woocommerce.github.io/woocommerce-rest-api-docs
Stuck on a tricky one?
If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this clear your stuck queue?
If this saved you from a frozen renewal queue or a pile of missed payments, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi