Atomic claiming

The core problem this plugin solves that a plain CPT does not: when more than one worker (a human, an AI agent, or multiple agent connectors/cron ticks) might try to pick up the same task at the same moment, a naive read-then-write claim can let two workers claim — and therefore execute — the same task. claim_next_task() uses a single UPDATE ... WHERE ID = (SELECT ... LIMIT 1) SQL statement so the database’s own row locking guarantees only one caller wins, with a unique claim token stamped in to make the win unambiguous to look up.

File and class structure

The original LH_Tasks_task_post_type_class had grown to 42 methods covering registration, front-end visibility, migrations, task lifecycle, and admin UI all in one file. It was split into five focused classes, one self-contained piece at a time:

  • 1.7.1 — LH_Tasks_migrations_class (includes/lh-tasks-migrations-class.php): the two one-time data migrations. No external callers — a natural candidate for wholesale removal once every site has run past the version that introduced them.
  • 1.7.2 — LH_Tasks_visibility_class (includes/lh-tasks-visibility-class.php): front-end read-visibility gating (current_user_can_view_task(), the map_meta_cap filter, and the per-request view cache).
  • 1.7.3 — LH_Tasks_admin_ui_class (includes/lh-tasks-admin-ui-class.php): the dashboard widget, list-table Status/Assigned columns, and the workflow-status filter dropdown.
  • 1.7.4 — LH_Tasks_task_actions_class (includes/lh-tasks-task-actions-class.php): the task lifecycle itself — create, claim, complete, fail, reassign, renew, edit, the stale-claim sweep, and future_to_active(). The largest and most externally-coupled piece: LH_Tasks_mcp_abilities_class calls these methods by class name throughout, so its 7 lifecycle call sites were switched over to LH_Tasks_task_actions_class:: in the same session this class was wired in.

Each split followed the same pattern: the new class was created and deployed first (inert — nothing yet calling or hooking it), then wired into lh-tasks.php‘s require_once list, then the old methods and their hook registrations were removed from the god class in a follow-up apply — avoiding a fatal window where a partially-moved method is referenced before it exists.

LH_Tasks_task_post_type_class now covers only post type/taxonomy registration and identity/status helpers — 18 methods, down from 42. Registration/identity helpers (return_post_type(), meta_key(), return_queue_taxonomy(), return_active_status(), return_statuses(), etc.) remain there and are called from the other four classes via that class name.

Incident: CPT and abilities silently not registering (Sept 2026)

Shortly after the 1.7.1–1.7.4 class-split refactor, the plugin showed as active but registered no CPT, no hooks, and no MCP abilities at all — with no fatal error and nothing informative in the debug log. Two things had gone wrong:

  1. The vendored vendor/imath/wp-statuses/ library had gone missing from the plugin directory (now restored). Not the actual blocker on its own — another already-active plugin’s own copy of the same library satisfied class_exists( 'WP_Statuses' ) — but a real gap that needed fixing regardless.
  2. The actual root cause: LH_Tasks_plugin is the one class in this plugin with no extends/implements. PHP + OPcache on this server perform compile-time early binding for such standalone classes — the class gets linked into the class table the moment the file is compiled, independent of the runtime if branch wrapping it. The file’s own top-level if ( class_exists( 'LH_Tasks_plugin' ) ) { return; } guard (a duplicate-declaration guard, standard across LH plugins but never actually necessary for a plugin’s own top-level class — WordPress only include_onces an active plugin file once per request) therefore evaluated true on every single request, before the class’s own declaration should have “logically” been reached, and returned early — skipping __construct(), every conditional include, and every hook registration, silently, every time. Every child class extending LH_Tasks_plugin (the status-table, task-post-type, migrations, visibility, admin-ui, task-actions, markdown, and mcp-abilities classes) carried the identical guard pattern and inherited the same failure once the parent was early-bound.

Fix: removed the if ( class_exists( X ) ) { return; } guard from all 9 files (the main plugin file plus all 8 includes/ files). See Plugin Structure in lh-knowledge-base (“Do not guard the class declaration itself”) for the general rule this incident produced — that guard pattern is now documented as reserved strictly for genuinely shared third-party library code, never a plugin’s own classes.

Diagnostic technique that actually nailed it down: debug_backtrace() logged at file-top, plus ( new ReflectionClass( 'LH_Tasks_plugin' ) )->getFileName()/getStartLine() logged whenever the guard fired true — that combination gave a definitive answer fast. Several earlier theories (duplicate mu-plugin, opcache preload, WordPress’s fatal-error auto-pause, a duplicate active_plugins array entry) were checked and ruled out first; none of them explained why the guard fired on a completely ordinary, single, non-duplicated wp-settings.php plugin-load pass.

Front-end visibility (added 1.6.0)

A task is readable on the front end by its post_author, its hard-assigned account (assigned_to), its current claim holder (assignee), or anyone holding edit_others_posts — enforced via a map_meta_cap filter on read_post (in LH_Tasks_visibility_class, see above), triggered by WordPress core’s own WP::handle_404() because lh_tasks-active is registered public => false. Everyone else gets a 404 on a direct task URL. There is no “my tasks” front-end archive listing, and no front-end write actions (reassigning, completing, etc. from a browser) — those remain MCP-only, gated to edit_others_posts regardless of assignment.

Human vs agent assignment

Accounts (human or AI agent, e.g. the shared agent user) are the identity directly — there is no separate assignee_type/assigned_to_hint_type distinction as of 1.6.0. Two WP user IDs matter per task, both in the status table:

  • assigned_to — a hard requirement, set at creation or via reassign_task()/edit-task. If set, only that account (or edit-task, staff-only) may claim, complete, fail, or reassign the task. NULL means open to anyone polling the queue.
  • assignee — whoever currently holds the claim (set atomically by claim_next_task()). Distinct from assigned_to: claiming an open task doesn’t retroactively assign it, and a stale-claim sweep clears assignee without touching assigned_to.

Both share the same queue, the same statuses, and the same claim mechanism — there is no separate human queue and agent queue. Identity for claiming/acting is always derived server-side from the authenticated account making the call, never accepted as caller input — this matters in particular for a shared agent account, where assigned_to/assignee distinguish which account, while claim_token (not account identity) is what disambiguates concurrent sessions under that same account.

Queues / lanes

An optional non-hierarchical taxonomy, lh_tasks_queue, lets tasks be routed into lanes (e.g. email-triage, content-review, link-repair) so a poller only claims from the lane it’s built to handle. Omitting a queue when creating a task means it can be claimed by any poller not filtering by queue.

Dependencies

A task can declare blocked_by (an array of other task IDs) as post meta. claim_next_task() will not hand out a task while any of its blockers has not yet reached a terminal status (Resolved, Closed, Cancelled) — it puts such a task back to New rather than leaving it stuck as Claimed with no assignee.

Retries

max_retries and retry_count are tracked per task. fail_task() requeues to New (clearing the previous claim, leaving assigned_to untouched) while retries remain, and falls back to Blocked status for human triage once retries are exhausted.

Reassignment and handoffs

A task claim can be released and handed off via reassign_task() — distinct from failure: nothing went wrong, but the current assignee isn’t the right one to finish it. Reassignment is self-service, available to whoever currently holds the claim (requires a matching claim_token) — it always releases the current claim and appends an entry to progress_log describing what’s done, what’s left, and why. As of 1.6.0, new_assigned_to is a hard requirement going forward (not a routing hint) — the intended recipient (or an unassigned/open task, if omitted) is the only account (besides staff via edit-task) that can claim/act on it next.

fail_task()‘s reason_type parameter ties into this: retryable (default) is the original behaviour above. needs_human skips retry_count entirely and internally calls reassign_task() instead, defaulting to Pending status.

Admin queue management (edit-task, added 1.6.0)

reassign_task() is deliberately identity-gated to the current claim holder — that’s what makes assignment meaningful as a routing mechanism. But staff sometimes need an unconditional override (a stuck claim, a wrong assignment, general queue cleanup) without going through whoever currently holds it. edit_task() / the lh-tasks/edit-task MCP ability provides that: gated only to edit_others_posts, no assignee/claim check, can set/clear assigned_to, release a stuck claim, and/or change workflow status or queue. Logged to the same progress_log as reassign_task(), tagged with event: admin_override.

Scheduled (one-off future) tasks

Passing scheduled_for (a MySQL datetime) to create_task() / lh-tasks/create-task creates the task with native post_status = 'future' and that date as its post_date. A future_to_publish intercept (in LH_Tasks_task_actions_class) lands the task on lh_tasks-active (not native publish, which is unregistered for this CPT) when WordPress’s own future-post cron fires — the task stays invisible to claim_next_task() until then. This covers a one-off future run; it does not cover recurring/repeating tasks, which are not yet implemented.

Description format: HTML canonical, markdown at the agent boundary

post_content is always canonical HTML — the same thing WP’s block editor writes for a human editing a task normally. Agent/MCP callers write and read markdown instead: create-task‘s description defaults to description_format: markdown and is converted to HTML before storage; claim-next-task and get-task return a description_markdown field alongside the raw HTML description. Conversion is delegated entirely to lh-markdown-converter (a hard plugin dependency). markdown_to_html() always returns Gutenberg block markup, matching what the block editor itself writes.

Note on migration from lh-crm

This plugin does not migrate existing lh-crm-task_post records from lh-crm automatically. That dataset is migrated manually by the site owner; lh-crm’s own task CPT and related code are expected to be removed from lh-crm in a future release once migration is complete.