@prefix sioc: <http://rdfs.org/sioc/ns#> .
@prefix dc: <http://purl.org/dc/elements/1.1/> .
@prefix dcterms: <http://purl.org/dc/terms/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix content: <http://purl.org/rss/1.0/modules/content/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .

<https://lhero.org/?post_type=lh-portfolio&#038;p=147132>
  a sioc:Post ;
  dc:title "Architecture notes" ;
  dcterms:identifier 147132 ;
  dc:modified "2026-09-11T16:42:20Z"^^xsd:dateTime ;
  dc:created "2026-09-07T19:13:59Z"^^xsd:dateTime ;
  sioc:link <https://lhero.org/portfolio/lh-tasks/architecture-notes/> ;
  sioc:has_creator <https://lhero.org/author/1/#account> ;
  sioc:has_container <https://lhero.org/#posts> ;
  content:encoded """<ul class="lh_portfolio-meta"><li><strong>Type:</strong> Doc-section</li><li><strong>Part of:</strong> <a href="https://lhero.org/portfolio/lh-tasks/">LH Tasks</a></li></ul><h2>Atomic claiming</h2>
<p>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. <code>claim_next_task()</code> uses a single <code>UPDATE ... WHERE ID = (SELECT ... LIMIT 1)</code> SQL statement so the database&#8217;s own row locking guarantees only one caller wins, with a unique claim token stamped in to make the win unambiguous to look up.</p>
<h2>File and class structure</h2>
<p>The original <code>LH_Tasks_task_post_type_class</code> 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:</p>
<ul>
<li><strong>1.7.1</strong> — <code>LH_Tasks_migrations_class</code> (<code>includes/lh-tasks-migrations-class.php</code>): 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.</li>
<li><strong>1.7.2</strong> — <code>LH_Tasks_visibility_class</code> (<code>includes/lh-tasks-visibility-class.php</code>): front-end read-visibility gating (<code>current_user_can_view_task()</code>, the <code>map_meta_cap</code> filter, and the per-request view cache).</li>
<li><strong>1.7.3</strong> — <code>LH_Tasks_admin_ui_class</code> (<code>includes/lh-tasks-admin-ui-class.php</code>): the dashboard widget, list-table Status/Assigned columns, and the workflow-status filter dropdown.</li>
<li><strong>1.7.4</strong> — <code>LH_Tasks_task_actions_class</code> (<code>includes/lh-tasks-task-actions-class.php</code>): the task lifecycle itself — create, claim, complete, fail, reassign, renew, edit, the stale-claim sweep, and <code>future_to_active()</code>. The largest and most externally-coupled piece: <code>LH_Tasks_mcp_abilities_class</code> calls these methods by class name throughout, so its 7 lifecycle call sites were switched over to <code>LH_Tasks_task_actions_class::</code> in the same session this class was wired in.</li>
</ul>
<p>Each split followed the same pattern: the new class was created and deployed first (inert — nothing yet calling or hooking it), then wired into <code>lh-tasks.php</code>&#8216;s <code>require_once</code> 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.</p>
<p><code>LH_Tasks_task_post_type_class</code> now covers only post type/taxonomy registration and identity/status helpers — 18 methods, down from 42. Registration/identity helpers (<code>return_post_type()</code>, <code>meta_key()</code>, <code>return_queue_taxonomy()</code>, <code>return_active_status()</code>, <code>return_statuses()</code>, etc.) remain there and are called from the other four classes via that class name.</p>
<h2>Incident: CPT and abilities silently not registering (Sept 2026)</h2>
<p>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:</p>
<ol>
<li>The vendored <code>vendor/imath/wp-statuses/</code> library had gone missing from the plugin directory (now restored). Not the actual blocker on its own — another already-active plugin&#8217;s own copy of the same library satisfied <code>class_exists( 'WP_Statuses' )</code> — but a real gap that needed fixing regardless.</li>
<li><strong>The actual root cause:</strong> <code>LH_Tasks_plugin</code> is the one class in this plugin with no <code>extends</code>/<code>implements</code>. 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 <code>if</code> branch wrapping it. The file&#8217;s own top-level <code>if ( class_exists( 'LH_Tasks_plugin' ) ) { return; }</code> guard (a duplicate-declaration guard, standard across LH plugins but never actually necessary for a plugin&#8217;s own top-level class — WordPress only <code>include_once</code>s an active plugin file once per request) therefore evaluated true on every single request, before the class&#8217;s own declaration should have &#8220;logically&#8221; been reached, and returned early — skipping <code>__construct()</code>, every conditional include, and every hook registration, silently, every time. Every child class extending <code>LH_Tasks_plugin</code> (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.</li>
</ol>
<p><strong>Fix:</strong> removed the <code>if ( class_exists( X ) ) { return; }</code> guard from all 9 files (the main plugin file plus all 8 <code>includes/</code> files). See <a href="https://lhero.org/knowledge/conventions/plugin-structure.md">Plugin Structure</a> in lh-knowledge-base (&#8220;Do not guard the class declaration itself&#8221;) 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&#8217;s own classes.</p>
<p>Diagnostic technique that actually nailed it down: <code>debug_backtrace()</code> logged at file-top, plus <code>( new ReflectionClass( 'LH_Tasks_plugin' ) )-&gt;getFileName()</code>/<code>getStartLine()</code> logged whenever the guard fired true — that combination gave a definitive answer fast. Several earlier theories (duplicate mu-plugin, opcache preload, WordPress&#8217;s fatal-error auto-pause, a duplicate <code>active_plugins</code> array entry) were checked and ruled out first; none of them explained why the guard fired on a completely ordinary, single, non-duplicated <code>wp-settings.php</code> plugin-load pass.</p>
<h2>Front-end visibility (added 1.6.0)</h2>
<p>A task is readable on the front end by its <code>post_author</code>, its hard-assigned account (<code>assigned_to</code>), its current claim holder (<code>assignee</code>), or anyone holding <code>edit_others_posts</code> — enforced via a <code>map_meta_cap</code> filter on <code>read_post</code> (in <code>LH_Tasks_visibility_class</code>, see above), triggered by WordPress core&#8217;s own <code>WP::handle_404()</code> because <code>lh_tasks-active</code> is registered <code>public =&gt; false</code>. Everyone else gets a 404 on a direct task URL. There is no &#8220;my tasks&#8221; front-end archive listing, and no front-end write actions (reassigning, completing, etc. from a browser) — those remain MCP-only, gated to <code>edit_others_posts</code> regardless of assignment.</p>
<h2>Human vs agent assignment</h2>
<p>Accounts (human or AI agent, e.g. the shared agent user) are the identity directly — there is no separate <code>assignee_type</code>/<code>assigned_to_hint_type</code> distinction as of 1.6.0. Two WP user IDs matter per task, both in the status table:</p>
<ul>
<li><strong><code>assigned_to</code></strong> — a hard requirement, set at creation or via <code>reassign_task()</code>/<code>edit-task</code>. If set, only that account (or <code>edit-task</code>, staff-only) may claim, complete, fail, or reassign the task. <code>NULL</code> means open to anyone polling the queue.</li>
<li><strong><code>assignee</code></strong> — whoever currently holds the claim (set atomically by <code>claim_next_task()</code>). Distinct from <code>assigned_to</code>: claiming an open task doesn&#8217;t retroactively assign it, and a stale-claim sweep clears <code>assignee</code> without touching <code>assigned_to</code>.</li>
</ul>
<p>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 <code>assigned_to</code>/<code>assignee</code> distinguish which account, while <code>claim_token</code> (not account identity) is what disambiguates concurrent sessions under that same account.</p>
<h2>Queues / lanes</h2>
<p>An optional non-hierarchical taxonomy, <code>lh_tasks_queue</code>, lets tasks be routed into lanes (e.g. <code>email-triage</code>, <code>content-review</code>, <code>link-repair</code>) so a poller only claims from the lane it&#8217;s built to handle. Omitting a queue when creating a task means it can be claimed by any poller not filtering by queue.</p>
<h2>Dependencies</h2>
<p>A task can declare <code>blocked_by</code> (an array of other task IDs) as post meta. <code>claim_next_task()</code> 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.</p>
<h2>Retries</h2>
<p><code>max_retries</code> and <code>retry_count</code> are tracked per task. <code>fail_task()</code> requeues to New (clearing the previous claim, leaving <code>assigned_to</code> untouched) while retries remain, and falls back to Blocked status for human triage once retries are exhausted.</p>
<h2>Reassignment and handoffs</h2>
<p>A task claim can be released and handed off via <code>reassign_task()</code> — distinct from failure: nothing went wrong, but the current assignee isn&#8217;t the right one to finish it. Reassignment is self-service, available to whoever currently holds the claim (requires a matching <code>claim_token</code>) — it always releases the current claim and appends an entry to <code>progress_log</code> describing what&#8217;s done, what&#8217;s left, and why. As of 1.6.0, <code>new_assigned_to</code> 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 <code>edit-task</code>) that can claim/act on it next.</p>
<p><code>fail_task()</code>&#8216;s <code>reason_type</code> parameter ties into this: <code>retryable</code> (default) is the original behaviour above. <code>needs_human</code> skips <code>retry_count</code> entirely and internally calls <code>reassign_task()</code> instead, defaulting to Pending status.</p>
<h2>Admin queue management (<code>edit-task</code>, added 1.6.0)</h2>
<p><code>reassign_task()</code> is deliberately identity-gated to the current claim holder — that&#8217;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. <code>edit_task()</code> / the <code>lh-tasks/edit-task</code> MCP ability provides that: gated only to <code>edit_others_posts</code>, no assignee/claim check, can set/clear <code>assigned_to</code>, release a stuck claim, and/or change workflow status or queue. Logged to the same <code>progress_log</code> as <code>reassign_task()</code>, tagged with <code>event: admin_override</code>.</p>
<h2>Scheduled (one-off future) tasks</h2>
<p>Passing <code>scheduled_for</code> (a MySQL datetime) to <code>create_task()</code> / <code>lh-tasks/create-task</code> creates the task with native <code>post_status = 'future'</code> and that date as its <code>post_date</code>. A <code>future_to_publish</code> intercept (in <code>LH_Tasks_task_actions_class</code>) lands the task on <code>lh_tasks-active</code> (not native <code>publish</code>, which is unregistered for this CPT) when WordPress&#8217;s own future-post cron fires — the task stays invisible to <code>claim_next_task()</code> until then. This covers a one-off future run; it does not cover recurring/repeating tasks, which are not yet implemented.</p>
<h2>Description format: HTML canonical, markdown at the agent boundary</h2>
<p><code>post_content</code> is always canonical HTML — the same thing WP&#8217;s block editor writes for a human editing a task normally. Agent/MCP callers write and read markdown instead: <code>create-task</code>&#8216;s <code>description</code> defaults to <code>description_format: markdown</code> and is converted to HTML before storage; <code>claim-next-task</code> and <code>get-task</code> return a <code>description_markdown</code> field alongside the raw HTML <code>description</code>. Conversion is delegated entirely to <code>lh-markdown-converter</code> (a hard plugin dependency). <code>markdown_to_html()</code> always returns Gutenberg block markup, matching what the block editor itself writes.</p>
<h2>Note on migration from lh-crm</h2>
<p>This plugin does not migrate existing <code>lh-crm-task_post</code> records from lh-crm automatically. That dataset is migrated manually by the site owner; lh-crm&#8217;s own task CPT and related code are expected to be removed from lh-crm in a future release once migration is complete.</p>
"""^^rdf:XMLLiteral ;
  sioc:content """Type: Doc-sectionPart of: LH TasksAtomic 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&#8217;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&#8216;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&#8217;s own copy of the same library satisfied class_exists( 'WP_Statuses' ) — but a real gap that needed fixing regardless.
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&#8217;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&#8217;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&#8217;s own declaration should have &#8220;logically&#8221; 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 (&#8220;Do not guard the class declaration itself&#8221;) 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&#8217;s own classes.
Diagnostic technique that actually nailed it down: debug_backtrace() logged at file-top, plus ( new ReflectionClass( 'LH_Tasks_plugin' ) )-&gt;getFileName()/getStartLine() logged whenever the guard fired true — that combination gave a definitive answer fast. Several earlier theories (duplicate mu-plugin, opcache preload, WordPress&#8217;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&#8217;s own WP::handle_404() because lh_tasks-active is registered public =&gt; false. Everyone else gets a 404 on a direct task URL. There is no &#8220;my tasks&#8221; 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&#8217;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&#8217;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&#8217;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&#8217;s done, what&#8217;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()&#8216;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&#8217;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&#8217;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&#8217;s block editor writes for a human editing a task normally. Agent/MCP callers write and read markdown instead: create-task&#8216;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&#8217;s own task CPT and related code are expected to be removed from lh-crm in a future release once migration is complete.
""" ;
  sioc:topic <https://lhero.org/lh_portfolio-type/doc-section/>, <https://lhero.org/?taxonomy=author&term=cap-1> .

<https://lhero.org/author/1/#account> rdfs:seeAlso <https://lhero.org/author/1/?feed=lhrdf&format=turtle> .
<https://lhero.org/lh_portfolio-type/doc-section/> rdfs:seeAlso <https://lhero.org/lh_portfolio-type/doc-section/?feed=lhrdf&format=turtle> .
<https://lhero.org/?taxonomy=author&term=cap-1> rdfs:seeAlso <https://lhero.org/?taxonomy=author&term=cap-1&feed=lhrdf&format=turtle> .
