fix(v0183): GA4 SQL fix + manual-run resilience + honest status
R1: move phpcs:ignore out of SQL string in class-analysis-prompts.php:81
(same pattern as v0.18.2 cost-reporter fix). GA4 self-improvement loop
has returned empty context on every pipeline run since v0.16.0 — this
restores get_performance_context() to the LLM. Sibling occurrence also
fixed in class-post-assembler.php:112.
R2(a): update_generation_stage() now renews STATUS_TTL on every stage call
so the transient outlasts long LLM calls during background runs.
R2(b): on_ajax_generation_status() detects lock-held-but-transient-gone
when lock age > STATUS_TTL (= Hostinger background-process kill).
Marks stuck run failed, releases lock, returns status:error with
'infrastructure timeout' message. Daily reaper remains the backstop.
R3: when transient absent but lock held within TTL, return status:running
(with started_at from lock) instead of status:idle.
Executor split: class-executor.php was 351 lines (>300). AJAX generation
handlers extracted to new class-generation-status-poller.php (260 lines);
executor now 226 lines. Public hook wiring unchanged via delegation.
Generation_Lock gains get_acquired_at() for R2b/R3 lock-age checks.
Tests: AnalysisPromptsTest (regression SQL + behavioral context shaping),
GenerationStatusPollerTest (R2a TTL renewal, R2b abort, R3 running,
idle path).
Closes: R1 (analysis-prompts SQL), R2 (manual-run resilience), R3 (honest status)
Version: 0.18.3
Agent-Session: prab-v0183-20260612
This commit is contained in:
@@ -4,25 +4,26 @@ declare(strict_types=1);
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Cron/AJAX handlers for content generation, metrics collection, model registry.
|
||||
* Cron handlers for content generation and metrics collection.
|
||||
*
|
||||
* What: Daily generation, manual generation, queued-article processing,
|
||||
* and metrics collection cron handlers. AJAX generation handlers
|
||||
* live in PRAutoBlogger_Generation_Status_Poller (v0.18.3 split).
|
||||
* Triggered by: PRAutoBlogger hook registration.
|
||||
* Dependencies: Pipeline_Runner, Metrics_Collector, Generation_Lock, Logger.
|
||||
*
|
||||
* @see class-prautoblogger.php — Hook wiring.
|
||||
* @see core/class-pipeline-runner.php — Actual generation pipeline.
|
||||
* @see class-generation-lock.php — DB mutex.
|
||||
* @see class-generation-status-poller.php — "Generate Now" AJAX + status poll.
|
||||
* @see class-prautoblogger.php — Hook wiring.
|
||||
* @see core/class-pipeline-runner.php — Actual generation pipeline.
|
||||
* @see class-generation-lock.php — DB mutex.
|
||||
*/
|
||||
class PRAutoBlogger_Executor {
|
||||
|
||||
/** @var PRAutoBlogger_OpenRouter_Model_Registry|null Lazy-loaded singleton. */
|
||||
private ?PRAutoBlogger_OpenRouter_Model_Registry $model_registry = null;
|
||||
|
||||
/** Transient key for background generation status. */
|
||||
private const STATUS_TRANSIENT = 'prautoblogger_generation_status';
|
||||
|
||||
/** How long to keep the result available for polling (seconds). */
|
||||
private const STATUS_TTL = 600;
|
||||
/** @var PRAutoBlogger_Generation_Status_Poller|null Lazy-loaded poller. */
|
||||
private ?PRAutoBlogger_Generation_Status_Poller $poller = null;
|
||||
|
||||
/**
|
||||
* Handle the daily generation cron event.
|
||||
@@ -58,20 +59,6 @@ class PRAutoBlogger_Executor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this process's run failed after a fatal pipeline error
|
||||
* (no-op outside a run; a governor-halted run stays halted — final
|
||||
* states are sticky).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function mark_current_run_failed(): void {
|
||||
$run_id = PRAutoBlogger_Run_Context::current_run_id();
|
||||
if ( null !== $run_id ) {
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the metrics collection cron event.
|
||||
* Side effects: API calls to GA4, database writes to ab_content_scores.
|
||||
@@ -89,79 +76,12 @@ class PRAutoBlogger_Executor {
|
||||
|
||||
/**
|
||||
* AJAX handler: kick off manual generation as a background cron job.
|
||||
*
|
||||
* Returns immediately so the browser never hits Hostinger's 120-second
|
||||
* connection timeout. The frontend polls on_ajax_generation_status()
|
||||
* every few seconds to get progress and final results.
|
||||
* Delegates to PRAutoBlogger_Generation_Status_Poller.
|
||||
*
|
||||
* Side effects: schedules a WP-Cron event, writes a status transient.
|
||||
*/
|
||||
public function on_ajax_generate_now(): void {
|
||||
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||
return;
|
||||
}
|
||||
|
||||
$force = isset( $_POST['force'] ) && '1' === $_POST['force'];
|
||||
if ( $force ) {
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
delete_transient( self::STATUS_TRANSIENT );
|
||||
}
|
||||
|
||||
// Check if a run is already in progress.
|
||||
$current = get_transient( self::STATUS_TRANSIENT );
|
||||
if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) {
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'background' => true,
|
||||
'message' => __( 'Generation already in progress.', 'prautoblogger' ),
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Write initial "running" status for the frontend to poll.
|
||||
set_transient(
|
||||
self::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'running',
|
||||
'stage' => __( 'Starting generation...', 'prautoblogger' ),
|
||||
'started' => time(),
|
||||
'last_updated' => time(),
|
||||
),
|
||||
self::STATUS_TTL
|
||||
);
|
||||
|
||||
// Schedule immediate one-shot cron event. WordPress will fire this
|
||||
// on the next page load (or via wp-cron.php) in a separate PHP process
|
||||
// that is not subject to the 120-second connection timeout.
|
||||
if ( ! wp_next_scheduled( 'prautoblogger_manual_generation' ) ) {
|
||||
wp_schedule_single_event( time(), 'prautoblogger_manual_generation' );
|
||||
}
|
||||
|
||||
// Spawn the cron immediately via a non-blocking loopback request
|
||||
// so we don't depend on the next visitor to trigger it.
|
||||
spawn_cron();
|
||||
|
||||
// Direct non-blocking loopback to wp-cron.php — ensures the event
|
||||
// fires even if DISABLE_WP_CRON is true or spawn_cron() no-ops.
|
||||
wp_remote_post(
|
||||
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
|
||||
array(
|
||||
'timeout' => 0.01,
|
||||
'blocking' => false,
|
||||
'sslverify' => false,
|
||||
)
|
||||
);
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'background' => true,
|
||||
'message' => __( 'Generation started in background. Polling for status...', 'prautoblogger' ),
|
||||
)
|
||||
);
|
||||
$this->poller()->on_ajax_generate_now();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,18 +99,18 @@ class PRAutoBlogger_Executor {
|
||||
|
||||
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
|
||||
set_transient(
|
||||
self::STATUS_TRANSIENT,
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => __( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' ),
|
||||
),
|
||||
self::STATUS_TTL
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) );
|
||||
$this->poller()->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) );
|
||||
|
||||
$runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true );
|
||||
$results = $runner->run();
|
||||
@@ -199,7 +119,7 @@ class PRAutoBlogger_Executor {
|
||||
// When articles ARE queued, the pipeline handles status + lock release.
|
||||
if ( ! get_option( 'prautoblogger_article_queue' ) ) {
|
||||
set_transient(
|
||||
self::STATUS_TRANSIENT,
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'complete',
|
||||
'generated' => $results['generated'],
|
||||
@@ -207,7 +127,7 @@ class PRAutoBlogger_Executor {
|
||||
'rejected' => $results['rejected'],
|
||||
'cost' => $results['cost'],
|
||||
),
|
||||
self::STATUS_TTL
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
);
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
}
|
||||
@@ -217,12 +137,12 @@ class PRAutoBlogger_Executor {
|
||||
'pipeline'
|
||||
);
|
||||
set_transient(
|
||||
self::STATUS_TRANSIENT,
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
),
|
||||
self::STATUS_TTL
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
);
|
||||
$this->mark_current_run_failed();
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
@@ -247,89 +167,44 @@ class PRAutoBlogger_Executor {
|
||||
delete_option( 'prautoblogger_article_queue' );
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
set_transient(
|
||||
self::STATUS_TRANSIENT,
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
),
|
||||
self::STATUS_TTL
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** AJAX: return generation status for frontend polling. Recovers orphaned runs. */
|
||||
/** AJAX: return generation status for frontend polling. Delegates to poller. */
|
||||
public function on_ajax_generation_status(): void {
|
||||
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||
return;
|
||||
}
|
||||
|
||||
$status = get_transient( self::STATUS_TRANSIENT );
|
||||
if ( ! is_array( $status ) ) {
|
||||
wp_send_json_success( array( 'status' => 'idle' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( 'running' === $status['status'] ) {
|
||||
$elapsed = time() - ( $status['started'] ?? 0 );
|
||||
|
||||
// Absolute timeout — give up after 10 minutes.
|
||||
if ( $elapsed > self::STATUS_TTL ) {
|
||||
$this->abort_orphaned_run( __( 'Generation timed out. Check the Activity Log.', 'prautoblogger' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
// After 90s, check if a queued job died and needs re-scheduling.
|
||||
if ( $elapsed > 90 && get_option( 'prautoblogger_article_queue' ) ) {
|
||||
if ( ! wp_next_scheduled( PRAutoBlogger_Pipeline_Runner::CRON_ACTION ) ) {
|
||||
wp_schedule_single_event( time(), PRAutoBlogger_Pipeline_Runner::CRON_ACTION );
|
||||
spawn_cron();
|
||||
}
|
||||
}
|
||||
|
||||
// Stall = 5 min since last stage update (not since start).
|
||||
$last_activity = $status['last_updated'] ?? $status['started'] ?? 0;
|
||||
$idle_seconds = time() - $last_activity;
|
||||
if ( $idle_seconds > 300 ) {
|
||||
$this->abort_orphaned_run( __( 'Generation stalled. Check Activity Log.', 'prautoblogger' ) );
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
wp_send_json_success( $status );
|
||||
}
|
||||
|
||||
/** Clean up an orphaned generation run and report error. */
|
||||
private function abort_orphaned_run( string $message ): void {
|
||||
// v0.18.0: the dead run renders as failed/"incomplete" in audit
|
||||
// queries instead of silently lingering as 'running'.
|
||||
$queue = get_option( 'prautoblogger_article_queue' );
|
||||
if ( is_array( $queue ) && ! empty( $queue['run_id'] ) ) {
|
||||
PRAutoBlogger_Run_State::mark_status( (string) $queue['run_id'], 'failed' );
|
||||
}
|
||||
delete_transient( self::STATUS_TRANSIENT );
|
||||
delete_option( 'prautoblogger_article_queue' );
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => $message,
|
||||
)
|
||||
);
|
||||
$this->poller()->on_ajax_generation_status();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: update the generation stage for frontend polling.
|
||||
* Lazy-load the generation status poller.
|
||||
*
|
||||
* @param string $stage Human-readable stage description.
|
||||
* @return PRAutoBlogger_Generation_Status_Poller
|
||||
*/
|
||||
private function update_generation_stage( string $stage ): void {
|
||||
$current = get_transient( self::STATUS_TRANSIENT );
|
||||
if ( is_array( $current ) ) {
|
||||
$current['stage'] = $stage;
|
||||
$current['last_updated'] = time();
|
||||
set_transient( self::STATUS_TRANSIENT, $current, self::STATUS_TTL );
|
||||
private function poller(): PRAutoBlogger_Generation_Status_Poller {
|
||||
if ( null === $this->poller ) {
|
||||
$this->poller = new PRAutoBlogger_Generation_Status_Poller();
|
||||
}
|
||||
return $this->poller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this process's run failed after a fatal pipeline error
|
||||
* (no-op outside a run; a governor-halted run stays halted — final
|
||||
* states are sticky).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function mark_current_run_failed(): void {
|
||||
$run_id = PRAutoBlogger_Run_Context::current_run_id();
|
||||
if ( null !== $run_id ) {
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user