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(), themap_meta_capfilter, 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, andfuture_to_active(). The largest and most externally-coupled piece:LH_Tasks_mcp_abilities_classcalls these methods by class name throughout, so its 7 lifecycle call sites were switched over toLH_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:
- 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 satisfiedclass_exists( 'WP_Statuses' )— but a real gap that needed fixing regardless. - The actual root cause:
LH_Tasks_pluginis the one class in this plugin with noextends/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 runtimeifbranch wrapping it. The file’s own top-levelif ( 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 onlyinclude_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 extendingLH_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 viareassign_task()/edit-task. If set, only that account (oredit-task, staff-only) may claim, complete, fail, or reassign the task.NULLmeans open to anyone polling the queue.assignee— whoever currently holds the claim (set atomically byclaim_next_task()). Distinct fromassigned_to: claiming an open task doesn’t retroactively assign it, and a stale-claim sweep clearsassigneewithout touchingassigned_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.