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. - 1.8.0 β
LH_Tasks_dependencies_class(includes/lh-tasks-dependencies-class.php):blocked_byedges, the rule for when a prerequisite still holds a task back, validation, and flagging of dependents when a prerequisite is dropped. A new concern rather than a split; see Dependencies below.
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, a list of other task IDs it waits on. As of 1.8.0 these edges live in their own per-site table, {$wpdb->prefix}lh_tasks_dependencies (owned by LH_Tasks_dependencies_class), instead of post meta. The existing meta was moved across by a schema_version()-keyed migration and deleted, so the table is the only store.
Why a table
Before 1.8.0 claim_next_task() claimed the oldest New task and only then checked its blockers in PHP, releasing it again if one was unfinished. An old blocked task therefore sat at the head of the queue and every claim came back empty until its prerequisite finished (#148478). Serialized meta can’t be joined, so the fix needed the edges in a table: the claim query now excludes blocked tasks with a NOT EXISTS inside the same atomic UPDATE. Blocked is computed live from the blockers’ current status; nothing is stored or mirrored, so there is nothing to recompute.
When a prerequisite counts as finished
One definition, LH_Tasks_dependencies_class::return_unsatisfied_blockers_sql(), is used by both the claim query and get_waiting_on(), so they can’t disagree. Decided by Peter in October 2026 (#148486):
- Resolved, Closed and Cancelled release their dependents, and so do trashed and permanently deleted prerequisites.
- Deferred and Failed (retries exhausted; called Blocked before 1.18.0) keep holding their dependents, as does any open status.
- When a prerequisite is cancelled, trashed or deleted, each dependent still waiting in New moves to Pending, and every unfinished dependent gets a
prerequisite_droppedprogress-log note naming it. A person then decides: setting the task back to New goes ahead without that prerequisite. - A cancelled child releasing its parent is left for the goals work (#148492);
return_edges_from()is the place to addpost_parentedges.
Validation
create-task checks blocked_by before writing anything (validate_blocker_ids(), #148481): every ID must be an existing, untrashed task, duplicates are dropped, and a bad ID fails with an error naming it. For an existing task (used by update-task) it also refuses self-reference and loops, with a bounded breadth-first walk.
Claim order and failure reasons
Candidates are ordered by priority (urgent, high, normal, low; no priority counts as normal), then oldest, and claim-next-task accepts a task_type filter (#148479). Since 1.15.0 the priority rank comes from the task’s priority term (see Priority below). When nothing is claimed the ability returns a reason (#148480): empty or race_lost for a queue scan, and not_claimable, assigned_elsewhere, blocked (with waiting_on) or race_lost for a task_id claim.
Priority (1.15.0)
Since 1.15.0 (#148849) priority is the lh_tasks_priority taxonomy, owned by LH_Tasks_priority_class (includes/lh-tasks-priority-class.php), instead of post meta written once at creation. Its terms are a locked set (low, normal, high, urgent) that nobody can add to, rename or delete, and each task carries one. A priority’s rank is its term’s order meta (low 10, normal 20, high 30, urgent 40), the network’s usual term-ordering convention, and return_rank_sql() is the one rank query shared by the claim query and list-tasks. A task with no priority ranks as normal.
The upgrade is keyed on the class’s own schema version (option lh_tasks_priority_schema_version), never the plugin version. Schema 1 moved each task’s _lh_tasks-priority meta onto the matching term (a missing or unknown value became normal) and deleted the meta. Schema 2 (1.15.1) repairs term order: LH Term Order Fixer sets a new term’s order to its term ID, which overwrote the seeded ranks on sites where the terms were created one at a time, so low could outrank high. A missing priority now creates the whole missing set, low first, and writes the seeded order after the insert so it wins; the upgrade resets the order on any site where the four ranks do not rise from low to urgent, and recounts the terms. Term counts include tasks in every status, since tasks are never published.
The Tasks list screen has a Priority column and filter, and the classic edit screen a single-choice Priority box. A block-editor panel is planned in #148852.
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. Once they are exhausted it moves the task to Failed (lh_tasks-fail_task; called Blocked, lh_tasks-block_task, before 1.18.0) for human triage and, from 1.18.0, releases the claim too, so the agent that failed it no longer holds a live claim token. To requeue a Failed task with its full retries, staff use edit-task with status New and reset_retries, which sets retry_count back to 0 and logs the previous count as retries_reset_from.
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.
People work through assignment, agents through claims (1.16.0)
Agents work through claims: claim-next-task, a claim token, and the ten-minute stale-claim sweep. People work through assigned_to: they are given a task, see it on their Tasks tab, add notes and mark it done there, and never claim it. So nothing reminded anyone about a task they had been given. Since 1.16.0 (#148487) LH_Tasks_follow_up_class (includes/lh-tasks-follow-up-class.php) follows those tasks up.
A daily cron on each site (lh_tasks_follow_up) looks at open, live tasks that are hard-assigned, not claimed, and not self-assigned (the author is not the assignee). When such a task is past its due date and has been quiet for a day, or has no due date and has been quiet for 7 days, the assignee gets a reminder. If it is still quiet 3 days later, the author is told; when the author is the assignee, no longer exists or has no email address (agent accounts), that is noted on the task instead. Quiet means no edit, status change or progress-log entry other than the follow-ups themselves (the follow_up and follow_up_escalated events), and any activity starts the cycle again. Where a task is in the cycle is kept in _lh_tasks-follow_up meta. One run handles at most 50 tasks.
Each step is logged on the task and sent as an email and, when the BuddyPress notifications component is active, as a notification linking to the person’s Tasks tab. BuddyPress notifications are shared by every site on the network while task IDs are per site, so each notification records the task’s site and the link worked out there as notification meta (lh_tasks_blog_id, lh_tasks_link), and its text is built on that site whichever site displays it. A task’s notifications are removed when it is completed or cancelled. The day counts, the batch size and which tasks take part are filterable; see Hooks and cron. Deactivating the plugin removes the daily run.
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, change workflow status or queue, and (1.18.0) reset retry_count with reset_retries. 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-content-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.
