Skip to content

Commit 0e7bb83

Browse files
fix: resolve default agent owner for system-triggered media/SEO task enqueues (#2842)
Media, SEO, and internal-linking abilities enqueue agent-owned queued tasks (alt_text_generation, image_optimization, meta_description_generation, internal_linking) whose handlers require a real agent owner. They resolved identity solely from get_current_user_id(), which returns 0 outside an authenticated web request (WP-Cron, WP-CLI without --user, or a system pipeline step). That zeroed the enqueue context, so every batched item hit TaskScheduler's agent-context gate and was rejected with 'TaskScheduler: queued task requires agent context' — one ERROR log line per item (87 alt_text_generation rejections in a single tick on production). Add datamachine_resolve_system_agent_context(), which falls back to the install's default agent owner (DirectoryManager::get_default_agent_user_id()) when no user is in the request, and route all six enqueue call sites through it. The fix lives in the enqueue path that owns the defect, not as a workaround in TaskScheduler — the agent-context gate is correct to reject a genuinely ownerless queued task. Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent 4b1f052 commit 0e7bb83

7 files changed

Lines changed: 209 additions & 14 deletions

data-machine.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,49 @@ function datamachine_resolve_or_create_agent_id( int $user_id ): int {
847847
);
848848
}
849849

850+
/**
851+
* Resolve the acting agent identity for a system/ability-triggered operation.
852+
*
853+
* Media, SEO, and linking abilities enqueue agent-owned queued tasks (alt text,
854+
* meta descriptions, image optimization, internal linking). Those tasks require
855+
* a real agent owner in TaskScheduler — a queued task with agent_id/user_id 0 is
856+
* rejected by the agent-context gate before it ever runs.
857+
*
858+
* The historical pattern resolved identity solely from get_current_user_id(),
859+
* which returns 0 whenever the ability runs outside an authenticated web request
860+
* — WP-Cron, WP-CLI without --user, or a system pipeline step. That produced a
861+
* zeroed context and a flood of "queued task requires agent context" rejections,
862+
* one per batched item. This helper closes that gap by falling back to the
863+
* install's default agent user (DirectoryManager::get_default_agent_user_id())
864+
* when no user is present in the request, mirroring how the rest of the
865+
* single-agent surface resolves an owner.
866+
*
867+
* @since 0.72.0
868+
*
869+
* @return array{user_id:int,agent_id:int} Acting user id and its agent id. Both
870+
* may be 0 only when the install has no
871+
* resolvable owner at all.
872+
*/
873+
function datamachine_resolve_system_agent_context(): array {
874+
$user_id = function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0;
875+
876+
// No authenticated user (cron / CLI / system pipeline): fall back to the
877+
// install's default agent owner so agent-owned queued tasks carry a real
878+
// identity into TaskScheduler instead of a zeroed, gate-rejected context.
879+
if ( $user_id <= 0 && class_exists( \DataMachine\Core\FilesRepository\DirectoryManager::class ) ) {
880+
$user_id = (int) \DataMachine\Core\FilesRepository\DirectoryManager::get_default_agent_user_id();
881+
}
882+
883+
$agent_id = ( $user_id > 0 && function_exists( 'datamachine_resolve_or_create_agent_id' ) )
884+
? datamachine_resolve_or_create_agent_id( $user_id )
885+
: 0;
886+
887+
return array(
888+
'user_id' => $user_id,
889+
'agent_id' => $agent_id,
890+
);
891+
}
892+
850893
/**
851894
* Run a callback for every site on the network.
852895
*

inc/Abilities/InternalLinkingAbilities.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -510,8 +510,9 @@ public static function queueInternalLinking( array $input ): array {
510510
$dry_run = ! empty( $input['dry_run'] );
511511
$force = ! empty( $input['force'] );
512512

513-
$user_id = get_current_user_id();
514-
$agent_id = function_exists( 'datamachine_resolve_or_create_agent_id' ) && $user_id > 0 ? datamachine_resolve_or_create_agent_id( $user_id ) : 0;
513+
$acting = datamachine_resolve_system_agent_context();
514+
$user_id = $acting['user_id'];
515+
$agent_id = $acting['agent_id'];
515516
$system_defaults = PluginSettings::resolveModelForAgentMode( $agent_id, 'system' );
516517
$provider = $system_defaults['provider'];
517518
$model = $system_defaults['model'];

inc/Abilities/Media/AltTextAbilities.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,9 @@ public static function generateAltText( array $input ): array {
137137
$post_id = absint( $input['post_id'] ?? 0 );
138138
$force = ! empty( $input['force'] );
139139

140-
$user_id = get_current_user_id();
141-
$agent_id = function_exists( 'datamachine_resolve_or_create_agent_id' ) && $user_id > 0 ? datamachine_resolve_or_create_agent_id( $user_id ) : 0;
140+
$acting = datamachine_resolve_system_agent_context();
141+
$user_id = $acting['user_id'];
142+
$agent_id = $acting['agent_id'];
142143
$system_defaults = PluginSettings::resolveModelForAgentMode( $agent_id, 'system' );
143144
$provider = $system_defaults['provider'];
144145
$model = $system_defaults['model'];
@@ -353,8 +354,9 @@ public function queueAttachmentAltText( int $attachment_id ): void {
353354
return;
354355
}
355356

356-
$user_id = get_current_user_id();
357-
$agent_id = function_exists( 'datamachine_resolve_or_create_agent_id' ) && $user_id > 0 ? datamachine_resolve_or_create_agent_id( $user_id ) : 0;
357+
$acting = datamachine_resolve_system_agent_context();
358+
$user_id = $acting['user_id'];
359+
$agent_id = $acting['agent_id'];
358360
$system_defaults = PluginSettings::resolveModelForAgentMode( $agent_id, 'system' );
359361
$provider = $system_defaults['provider'];
360362
$model = $system_defaults['model'];

inc/Abilities/Media/ImageGenerationAbilities.php

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -325,8 +325,9 @@ public static function generateImage( array $input ): array {
325325
* @return string|null Refined prompt on success, null on failure.
326326
*/
327327
public static function refine_prompt( string $raw_prompt, string $post_context = '', array $config = array() ): ?string {
328-
$user_id = get_current_user_id();
329-
$agent_id = function_exists( 'datamachine_resolve_or_create_agent_id' ) && $user_id > 0 ? datamachine_resolve_or_create_agent_id( $user_id ) : 0;
328+
$acting = datamachine_resolve_system_agent_context();
329+
$user_id = $acting['user_id'];
330+
$agent_id = $acting['agent_id'];
330331
$system_defaults = PluginSettings::resolveModelForAgentMode( $agent_id, 'system' );
331332
$provider = $system_defaults['provider'];
332333
$model = $system_defaults['model'];
@@ -423,8 +424,8 @@ public static function is_refinement_enabled( array $config = array() ): bool {
423424
}
424425

425426
// Must have a DM AI provider configured.
426-
$user_id = get_current_user_id();
427-
$agent_id = function_exists( 'datamachine_resolve_or_create_agent_id' ) && $user_id > 0 ? datamachine_resolve_or_create_agent_id( $user_id ) : 0;
427+
$acting = datamachine_resolve_system_agent_context();
428+
$agent_id = $acting['agent_id'];
428429
$system_defaults = PluginSettings::resolveModelForAgentMode( $agent_id, 'system' );
429430

430431
return ! empty( $system_defaults['provider'] ) && ! empty( $system_defaults['model'] );

inc/Abilities/Media/ImageOptimizationAbilities.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -340,8 +340,9 @@ public static function optimizeImages( array $input = array() ): array {
340340
);
341341
}
342342

343-
$user_id = get_current_user_id();
344-
$agent_id = function_exists( 'datamachine_resolve_or_create_agent_id' ) && $user_id > 0 ? datamachine_resolve_or_create_agent_id( $user_id ) : 0;
343+
$acting = datamachine_resolve_system_agent_context();
344+
$user_id = $acting['user_id'];
345+
$agent_id = $acting['agent_id'];
345346

346347
$batch = TaskScheduler::scheduleBatch(
347348
'image_optimization',

inc/Abilities/SEO/MetaDescriptionAbilities.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,9 @@ public static function generateMetaDescriptions( array $input ): array {
134134
$limit = absint( $input['limit'] ?? 50 );
135135
$force = ! empty( $input['force'] );
136136

137-
$user_id = get_current_user_id();
138-
$agent_id = function_exists( 'datamachine_resolve_or_create_agent_id' ) && $user_id > 0 ? datamachine_resolve_or_create_agent_id( $user_id ) : 0;
137+
$acting = datamachine_resolve_system_agent_context();
138+
$user_id = $acting['user_id'];
139+
$agent_id = $acting['agent_id'];
139140
$system_defaults = PluginSettings::resolveModelForAgentMode( $agent_id, 'system' );
140141
$provider = $system_defaults['provider'];
141142
$model = $system_defaults['model'];
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
<?php
2+
/**
3+
* Pure-PHP smoke test for datamachine_resolve_system_agent_context().
4+
*
5+
* Run with: php tests/system-agent-context-resolution-smoke.php
6+
*
7+
* Regression coverage for the "queued task requires agent context" flood
8+
* produced when media/SEO/linking abilities enqueued agent-owned batch tasks
9+
* (alt_text_generation, image_optimization, meta_description_generation,
10+
* internal_linking) from a context with no authenticated user — WP-Cron,
11+
* WP-CLI without --user, or a system pipeline step.
12+
*
13+
* The old resolution resolved identity solely from get_current_user_id(),
14+
* which returns 0 outside a web request. That zeroed the enqueue context, so
15+
* every batched item hit TaskScheduler's agent-context gate and was rejected
16+
* (one ERROR log line per item). The fix teaches the enqueue path to fall back
17+
* to the install's default agent owner via
18+
* DirectoryManager::get_default_agent_user_id().
19+
*
20+
* This test verifies the resolver's decision table without a WP bootstrap by
21+
* mirroring its production logic byte-for-byte in a harness whose two inputs
22+
* (current-user id, default-agent user id → agent id) are injectable.
23+
*
24+
* @package DataMachine\Tests
25+
*/
26+
27+
if ( ! defined( 'ABSPATH' ) ) {
28+
define( 'ABSPATH', __DIR__ . '/' );
29+
}
30+
31+
// ─── Harness mirroring datamachine_resolve_system_agent_context() ────
32+
//
33+
// Mirrors data-machine.php:datamachine_resolve_system_agent_context().
34+
// The two collaborators the real function calls — get_current_user_id() and
35+
// DirectoryManager::get_default_agent_user_id() — are supplied as inputs, and
36+
// the user_id → agent_id resolution (datamachine_resolve_or_create_agent_id)
37+
// is supplied as a closure so the branch logic is exercised in isolation.
38+
//
39+
// @param int $current_user_id What get_current_user_id() would return.
40+
// @param int $default_user_id What DirectoryManager::get_default_agent_user_id() would return.
41+
// @param callable $resolve_agent_id user_id => agent_id (0 for unresolvable users).
42+
function resolve_system_agent_context_for_test( int $current_user_id, int $default_user_id, callable $resolve_agent_id ): array {
43+
$user_id = $current_user_id;
44+
45+
// No authenticated user: fall back to the install's default agent owner.
46+
if ( $user_id <= 0 ) {
47+
$user_id = $default_user_id;
48+
}
49+
50+
$agent_id = $user_id > 0 ? (int) $resolve_agent_id( $user_id ) : 0;
51+
52+
return array(
53+
'user_id' => $user_id,
54+
'agent_id' => $agent_id,
55+
);
56+
}
57+
58+
/**
59+
* Mirror of TaskScheduler's agent-context gate for a task that
60+
* requiresAgentContext() === true: reject when neither agent_id nor
61+
* agent_slug is present in the enqueue context.
62+
*/
63+
function task_scheduler_gate_for_test( array $context ): bool {
64+
return ! empty( $context['agent_slug'] ) || ! empty( $context['agent_id'] );
65+
}
66+
67+
// ─── Tiny assertion helpers ─────────────────────────────────────────
68+
69+
$failures = 0;
70+
$total = 0;
71+
72+
$assert = function ( string $label, bool $cond ) use ( &$failures, &$total ): void {
73+
$total++;
74+
if ( $cond ) {
75+
echo " [PASS] {$label}\n";
76+
} else {
77+
$failures++;
78+
echo " [FAIL] {$label}\n";
79+
}
80+
};
81+
82+
// Single-agent install: default owner is user 1 → agent 1.
83+
$resolve = static function ( int $user_id ): int {
84+
$map = array( 1 => 1, 7 => 25 ); // owner_id => agent_id
85+
return $map[ $user_id ] ?? 0;
86+
};
87+
88+
// ─── Test cases ─────────────────────────────────────────────────────
89+
90+
echo "\n[1] Authenticated web request resolves the current user's agent\n";
91+
$ctx = resolve_system_agent_context_for_test( 7, 1, $resolve );
92+
$assert( 'user_id is the current user', 7 === $ctx['user_id'] );
93+
$assert( 'agent_id resolved for current user', 25 === $ctx['agent_id'] );
94+
$assert( 'enqueue context passes the agent-context gate', task_scheduler_gate_for_test( $ctx ) );
95+
96+
echo "\n[2] Cron/CLI/system context (no current user) falls back to default agent owner\n";
97+
$ctx = resolve_system_agent_context_for_test( 0, 1, $resolve );
98+
$assert( 'user_id falls back to default agent user', 1 === $ctx['user_id'] );
99+
$assert( 'agent_id resolved from default owner', 1 === $ctx['agent_id'] );
100+
$assert(
101+
'REGRESSION: fallback context is NOT gate-rejected (was agent_id 0 before fix)',
102+
task_scheduler_gate_for_test( $ctx )
103+
);
104+
105+
echo "\n[3] Old behaviour would have produced a zeroed, gate-rejected context\n";
106+
// Before the fix, a no-user context resolved to user_id 0 / agent_id 0.
107+
$old_ctx = array( 'user_id' => 0, 'agent_id' => 0 );
108+
$assert(
109+
'old zeroed context is exactly what the gate rejected',
110+
false === task_scheduler_gate_for_test( $old_ctx )
111+
);
112+
113+
echo "\n[4] Install with no resolvable owner at all still returns zeros (no fatal)\n";
114+
$ctx = resolve_system_agent_context_for_test( 0, 0, $resolve );
115+
$assert( 'user_id is 0 when no default owner exists', 0 === $ctx['user_id'] );
116+
$assert( 'agent_id is 0 when no default owner exists', 0 === $ctx['agent_id'] );
117+
118+
echo "\n[5] Production sources use the shared resolver, not raw get_current_user_id()\n";
119+
$root = dirname( __DIR__ );
120+
$sources = array(
121+
'AltTextAbilities' => $root . '/inc/Abilities/Media/AltTextAbilities.php',
122+
'ImageOptimizationAbilities' => $root . '/inc/Abilities/Media/ImageOptimizationAbilities.php',
123+
'ImageGenerationAbilities' => $root . '/inc/Abilities/Media/ImageGenerationAbilities.php',
124+
'MetaDescriptionAbilities' => $root . '/inc/Abilities/SEO/MetaDescriptionAbilities.php',
125+
'InternalLinkingAbilities' => $root . '/inc/Abilities/InternalLinkingAbilities.php',
126+
);
127+
foreach ( $sources as $name => $path ) {
128+
$src = (string) file_get_contents( $path );
129+
$assert( "{$name} calls datamachine_resolve_system_agent_context()", str_contains( $src, 'datamachine_resolve_system_agent_context()' ) );
130+
$assert(
131+
"{$name} no longer resolves system-task agent id from a user-gated ternary",
132+
! str_contains( $src, 'datamachine_resolve_or_create_agent_id( $user_id ) : 0' )
133+
);
134+
}
135+
136+
echo "\n[6] The resolver is defined and documents the cron/CLI fallback\n";
137+
$plugin_src = (string) file_get_contents( $root . '/data-machine.php' );
138+
$assert( 'datamachine_resolve_system_agent_context() defined', str_contains( $plugin_src, 'function datamachine_resolve_system_agent_context(): array' ) );
139+
$assert( 'resolver falls back to the default agent user', str_contains( $plugin_src, 'get_default_agent_user_id()' ) );
140+
141+
echo "\n";
142+
if ( $failures > 0 ) {
143+
echo "=== system-agent-context-resolution-smoke: {$failures}/{$total} FAILED ===\n";
144+
exit( 1 );
145+
}
146+
echo "=== system-agent-context-resolution-smoke: ALL PASS ({$total} assertions) ===\n";

0 commit comments

Comments
 (0)