From 1afac09d0adaf7a9008ba87ae8dd47c77d3d7786 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 12 Jun 2026 03:15:14 +0000 Subject: [PATCH] fix(v0183): GA4 SQL fix + manual-run resilience + honest status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 37 +++ includes/class-executor.php | 211 +++----------- includes/class-generation-lock.php | 23 ++ includes/class-generation-status-poller.php | 260 ++++++++++++++++++ includes/core/class-analysis-prompts.php | 4 +- includes/core/class-post-assembler.php | 4 +- prautoblogger.php | 4 +- tests/unit/Core/AnalysisPromptsTest.php | 124 +++++++++ .../unit/Core/GenerationStatusPollerTest.php | 224 +++++++++++++++ 9 files changed, 717 insertions(+), 174 deletions(-) create mode 100644 includes/class-generation-status-poller.php create mode 100644 tests/unit/Core/AnalysisPromptsTest.php create mode 100644 tests/unit/Core/GenerationStatusPollerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bcc547..23a8e1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ All notable changes to PRAutoBlogger will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project uses [Semantic Versioning](https://semver.org/). +## [0.18.3] - 2026-06-12 + +GA4-context SQL fix, manual-run resilience, and honest status on transient expiry +(CTO thread `2026-06-pipeline-v2-phase1` seq 38). + +### Fixed +- **Analysis-prompts SQL / GA4 self-improvement loop (R1 — P2)**: `phpcs:ignore` annotation + was embedded inside the SQL string literal in `class-analysis-prompts.php:81` (same pattern + as the v0.18.2 cost-reporter fix). MariaDB rejected the query on every pipeline run, + causing `get_performance_context()` to always return `''` — the LLM research/analysis + stage has had no historical performance context since v0.16.0. Annotation moved to the + `// phpcs:ignore` pragma above `get_results()`. Sibling occurrence also fixed in + `class-post-assembler.php:112` (same pattern). Regression + behavioral tests added to + `AnalysisPromptsTest`. +- **Manual-run resilience — transient renewal (R2a)**: `update_generation_stage()` in the + new `PRAutoBlogger_Generation_Status_Poller` now renews the STATUS_TTL on every stage + update, ensuring the status transient outlasts any single LLM call even when `Pipeline_ + Status::broadcast()` is delayed by a long model call. +- **Manual-run resilience — stuck-run detection (R2b)**: `on_ajax_generation_status()` + now detects when the status transient is absent but the generation lock has been held + longer than STATUS_TTL seconds — indicative of a Hostinger background-process kill. + Marks the stuck run `failed` in the audit ledger, releases the lock, and returns + `status: error` with an "infrastructure timeout" message instead of silently resetting + to idle. +- **Honest status when transient gone but lock held (R3)**: when the status transient is + absent but the lock is held within TTL, `on_ajax_generation_status()` now returns + `status: running` with `started_at` from the lock timestamp (rather than `status: idle`) + so the Generate Now button reflects reality during long background runs. + +### Changed +- **Executor split (300-line rule)**: `class-executor.php` was at 351 lines. AJAX generation + handlers (`on_ajax_generate_now`, `on_ajax_generation_status`, `abort_orphaned_run`, + `update_generation_stage`) extracted to new `class-generation-status-poller.php` (260 lines). + Executor retains cron handlers and delegates AJAX calls to the poller. Public API unchanged. +- **Generation_Lock**: new `get_acquired_at()` method returns the Unix timestamp when the + lock was set, used by R2b/R3 to determine lock age without touching the pipeline run table. + ## [0.18.2] - 2026-06-12 Four confirmed-live maintenance fixes (CTO thread `2026-06-pipeline-v2-phase1` seq 25). diff --git a/includes/class-executor.php b/includes/class-executor.php index f3f0fa8..fb2d1e0 100644 --- a/includes/class-executor.php +++ b/includes/class-executor.php @@ -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' ); } } diff --git a/includes/class-generation-lock.php b/includes/class-generation-lock.php index 39357a6..7287a0c 100644 --- a/includes/class-generation-lock.php +++ b/includes/class-generation-lock.php @@ -76,6 +76,29 @@ class PRAutoBlogger_Generation_Lock { return null !== $row; } + /** + * Get the Unix timestamp when the lock was acquired, or null if not held. + * + * Returns null when the lock is not set. The timestamp is the value written + * by acquire() — used by the status poller to detect infrastructure-timeout + * runs (lock held longer than STATUS_TTL → background PHP process died). + * + * @return int|null Unix timestamp, or null when the lock is absent. + */ + public static function get_acquired_at(): ?int { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $row = $wpdb->get_var( + $wpdb->prepare( + "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", + self::LOCK_NAME + ) + ); + + return null !== $row ? (int) $row : null; + } + /** * Release the generation mutex. * diff --git a/includes/class-generation-status-poller.php b/includes/class-generation-status-poller.php new file mode 100644 index 0000000..fe8367c --- /dev/null +++ b/includes/class-generation-status-poller.php @@ -0,0 +1,260 @@ + STATUS_TTL seconds (Hostinger background- + * process death); marks the stuck run failed + surfaces a human-readable + * "infrastructure timeout" message for the Activity Log. + * - R3: on_ajax_generation_status() returns status:running (with + * started_at) when the transient is absent but the lock IS held and + * within TTL — so the button reflects reality during long runs rather + * than silently resetting to idle. + * + * Who calls it: PRAutoBlogger hook registration (wp_ajax_prautoblogger_*). + * Dependencies: PRAutoBlogger_Generation_Lock, PRAutoBlogger_Run_State, + * PRAutoBlogger_Run_Context, PRAutoBlogger_Pipeline_Runner, + * PRAutoBlogger_Logger. + * + * @see class-executor.php — Cron handlers (daily, manual, queued). + * @see class-generation-lock.php — DB mutex (acquire/release/timestamps). + * @see core/class-pipeline-status.php — Transient broadcast helpers. + * @see ARCHITECTURE.md #21 — Run lifecycle and reaper design. + */ +class PRAutoBlogger_Generation_Status_Poller { + + /** Transient key for background generation status (shared with Pipeline_Status). */ + public const STATUS_TRANSIENT = 'prautoblogger_generation_status'; + + /** + * How long (seconds) to keep the status transient readable. + * Renewed on every stage transition so a long run doesn't expire it early. + */ + public const STATUS_TTL = 600; + + /** + * AJAX handler: kick off manual generation as a background cron job. + * + * Returns immediately so the browser never hits Hostinger's connection + * timeout. The frontend polls on_ajax_generation_status() every few + * seconds for progress and final results. + * + * 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 + // not subject to the 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' ), + ) + ); + } + + /** AJAX: return generation status for frontend polling. Recovers orphaned runs. */ + 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 ); + + // R2(b) + R3: transient is gone — check the lock before returning idle. + if ( ! is_array( $status ) ) { + $this->handle_missing_transient(); + return; + } + + if ( 'running' === $status['status'] ) { + $elapsed = time() - ( $status['started'] ?? 0 ); + + // Absolute timeout — give up after STATUS_TTL. + 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 ); + } + + /** + * Handle the case where the status transient is absent. + * + * R3: if lock is HELD and within TTL → return status:running so the + * button reflects reality during long background runs. + * R2(b): if lock is held but has been held > STATUS_TTL → the background + * PHP process died without releasing it; mark the run failed and + * surface "infrastructure timeout" to the UI. + * + * @return void Sends JSON and exits. + */ + private function handle_missing_transient(): void { + $acquired_at = PRAutoBlogger_Generation_Lock::get_acquired_at(); + + if ( null === $acquired_at ) { + // Lock not held — genuinely idle. + wp_send_json_success( array( 'status' => 'idle' ) ); + return; + } + + $lock_age = time() - $acquired_at; + + if ( $lock_age > self::STATUS_TTL ) { + // Lock held longer than STATUS_TTL: background process died. + // R2(b): mark the stuck run failed + surface a clear error. + PRAutoBlogger_Logger::instance()->warning( + sprintf( + 'Status transient absent and lock held for %ds (> %ds TTL) — background process died; marking run failed.', + $lock_age, + self::STATUS_TTL + ), + 'pipeline' + ); + $this->abort_orphaned_run( + __( 'Generation failed — infrastructure timeout. Check Activity Log for details.', 'prautoblogger' ) + ); + return; + } + + // R3: lock is fresh (within TTL) but transient is gone — long run in + // progress (pipeline broadcast hasn't fired yet, or transient was + // evicted by object-cache pressure). Return running so the button + // does not silently reset to idle. + wp_send_json_success( + array( + 'status' => 'running', + 'stage' => __( 'Generation in progress (background). Check Activity Log for details.', 'prautoblogger' ), + 'started' => $acquired_at, + 'last_updated' => $acquired_at, + ) + ); + } + + /** Clean up an orphaned generation run and report error. */ + private function abort_orphaned_run( string $message ): void { + // Mark the stuck run as failed in the audit ledger. + $queue = get_option( 'prautoblogger_article_queue' ); + if ( is_array( $queue ) && ! empty( $queue['run_id'] ) ) { + PRAutoBlogger_Run_State::mark_status( (string) $queue['run_id'], 'failed' ); + } + // Also attempt to close the current active run from Run_Context. + $run_id = PRAutoBlogger_Run_Context::current_run_id(); + if ( null !== $run_id ) { + PRAutoBlogger_Run_State::mark_status( $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, + ) + ); + } + + /** + * Update the generation stage text for frontend polling. + * Renews the transient TTL at every call so a long run doesn't expire it. + * + * @param string $stage Human-readable stage description. + */ + public function update_generation_stage( string $stage ): void { + $current = get_transient( self::STATUS_TRANSIENT ); + if ( is_array( $current ) ) { + $current['stage'] = $stage; + $current['last_updated'] = time(); + // R2(a): renew the TTL on every stage update so the transient + // outlives any single LLM call (STATUS_TTL from last activity). + set_transient( self::STATUS_TRANSIENT, $current, self::STATUS_TTL ); + } + } +} diff --git a/includes/core/class-analysis-prompts.php b/includes/core/class-analysis-prompts.php index c506415..237587d 100644 --- a/includes/core/class-analysis-prompts.php +++ b/includes/core/class-analysis-prompts.php @@ -76,9 +76,9 @@ class PRAutoBlogger_Analysis_Prompts { global $wpdb; $scores_table = $wpdb->prefix . 'prautoblogger_content_scores'; - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $top_posts = $wpdb->get_results( - "SELECT cs.post_id, cs.composite_score, p.post_title // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT cs.post_id, cs.composite_score, p.post_title FROM {$scores_table} cs JOIN {$wpdb->posts} p ON p.ID = cs.post_id WHERE cs.composite_score > 0 diff --git a/includes/core/class-post-assembler.php b/includes/core/class-post-assembler.php index d2c232e..80f9dc6 100644 --- a/includes/core/class-post-assembler.php +++ b/includes/core/class-post-assembler.php @@ -108,8 +108,8 @@ class PRAutoBlogger_Post_Assembler { // Find the unlinked research cost row for this run. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $research_row = $wpdb->get_row( - $wpdb->prepare( - "SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens FROM {$table} WHERE run_id = %s AND stage = 'llm_research' AND post_id IS NULL LIMIT 1", diff --git a/prautoblogger.php b/prautoblogger.php index dfec06e..5c1a78e 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -9,7 +9,7 @@ * Plugin Name: PRAutoBlogger * Plugin URI: https://peptiderepo.com/prautoblogger * Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics. - * Version: 0.18.2 + * Version: 0.18.3 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: PeptideRepo @@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) { | Defined here so every file in the plugin can reference paths, versions, | and limits without magic strings. */ -define( 'PRAUTOBLOGGER_VERSION', '0.18.2' ); +define( 'PRAUTOBLOGGER_VERSION', '0.18.3' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.2.0' ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); diff --git a/tests/unit/Core/AnalysisPromptsTest.php b/tests/unit/Core/AnalysisPromptsTest.php new file mode 100644 index 0000000..75c9f04 --- /dev/null +++ b/tests/unit/Core/AnalysisPromptsTest.php @@ -0,0 +1,124 @@ +get_results() must not embed phpcs:ignore comments + * (MariaDB rejects the query if it does). + * - get_performance_context() returns a non-empty, correctly-shaped string + * when seeded content_scores rows are present. + * + * @package PRAutoBlogger\Tests\Core + */ + +namespace PRAutoBlogger\Tests\Core; + +use PRAutoBlogger\Tests\BaseTestCase; +use Brain\Monkey\Functions; + +class AnalysisPromptsTest extends BaseTestCase { + + /** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */ + private $wpdb; + + protected function setUp(): void { + parent::setUp(); + $this->wpdb = $this->create_mock_wpdb(); + $GLOBALS['wpdb'] = $this->wpdb; + } + + protected function tearDown(): void { + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** + * Regression: get_performance_context() SQL must not embed phpcs:ignore + * inside the string literal — MariaDB rejects the query if it does, + * causing get_performance_context() to silently return '' on every run. + * + * @see includes/core/class-analysis-prompts.php — fixed in v0.18.3 + */ + public function test_get_performance_context_sql_has_no_inline_phpcs_ignore(): void { + $captured_sql = null; + $this->wpdb->method( 'prepare' )->willReturnCallback( + function ( $sql ) use ( &$captured_sql ) { + // get_results is called WITHOUT prepare (direct string interpolation + // of a table prefix); capture the first arg to get_results instead. + return $sql; + } + ); + $this->wpdb->method( 'get_results' )->willReturnCallback( + function ( $sql ) use ( &$captured_sql ) { + $captured_sql = $sql; + return array(); + } + ); + + \PRAutoBlogger_Analysis_Prompts::get_performance_context(); + + $this->assertNotNull( $captured_sql, 'get_results() was not called' ); + $this->assertStringNotContainsString( + '// phpcs:ignore', + (string) $captured_sql, + 'SQL passed to get_results() must not contain a phpcs:ignore comment — MariaDB rejects it as a syntax error' + ); + } + + /** + * Behavioral: get_performance_context() returns a non-empty, correctly-shaped + * string when the scores table contains rows with composite_score > 0. + * + * The returned string must: + * - start with the "Top performing" header line + * - contain at least one bullet entry of the form '- "…" (score: N.N)' + * - not be empty + */ + public function test_get_performance_context_returns_shaped_context_with_seeded_rows(): void { + // Simulate two content_scores rows returned from the DB query. + $rows = array( + array( + 'post_id' => 100, + 'composite_score' => '9.2', + 'post_title' => 'BPC-157 and Gut Healing — What the Research Shows', + ), + array( + 'post_id' => 101, + 'composite_score' => '7.8', + 'post_title' => 'Semaglutide Dosing Guide for Weight Loss', + ), + ); + + $this->wpdb->method( 'get_results' )->willReturn( $rows ); + + $context = \PRAutoBlogger_Analysis_Prompts::get_performance_context(); + + $this->assertNotEmpty( $context, 'get_performance_context() returned empty string with seeded rows' ); + $this->assertStringContainsString( + 'Top performing past articles', + $context, + 'Context block must start with the header line' + ); + $this->assertStringContainsString( + 'BPC-157 and Gut Healing', + $context, + 'Context block must contain the first seeded article title' + ); + $this->assertStringContainsString( + 'score: 9.2', + $context, + 'Context block must include formatted composite_score' + ); + } + + /** + * Edge case: get_performance_context() returns '' when no rows exist. + */ + public function test_get_performance_context_returns_empty_string_when_no_rows(): void { + $this->wpdb->method( 'get_results' )->willReturn( array() ); + + $context = \PRAutoBlogger_Analysis_Prompts::get_performance_context(); + + $this->assertSame( '', $context, 'get_performance_context() must return empty string when scores table is empty' ); + } +} diff --git a/tests/unit/Core/GenerationStatusPollerTest.php b/tests/unit/Core/GenerationStatusPollerTest.php new file mode 100644 index 0000000..4fe74de --- /dev/null +++ b/tests/unit/Core/GenerationStatusPollerTest.php @@ -0,0 +1,224 @@ + STATUS_TTL and returns status:error with an infrastructure + * timeout message. + * - R3: on_ajax_generation_status() returns status:running (with started_at) + * when the transient is absent but the lock is held within TTL. + * + * @package PRAutoBlogger\Tests\Core + */ + +namespace PRAutoBlogger\Tests\Core; + +use PRAutoBlogger\Tests\BaseTestCase; +use Brain\Monkey\Functions; + +class GenerationStatusPollerTest extends BaseTestCase { + + /** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */ + private $wpdb; + + /** @var array Captured wp_send_json_success payloads. */ + private array $json_success = array(); + + /** @var array Captured wp_send_json_error payloads. */ + private array $json_error = array(); + + /** @var array Captured transient writes: [key, value, ttl]. */ + private array $transient_writes = array(); + + /** @var mixed Current transient value. */ + private $transient_value = false; + + /** @var int|null Mocked lock acquired_at timestamp (null = not held). */ + private ?int $lock_acquired_at = null; + + protected function setUp(): void { + parent::setUp(); + + $this->json_success = array(); + $this->json_error = array(); + $this->transient_writes = array(); + $this->transient_value = false; + $this->lock_acquired_at = null; + + $this->wpdb = $this->create_mock_wpdb(); + $GLOBALS['wpdb'] = $this->wpdb; + + // Stub nonce check and capability check (always pass in unit tests). + Functions\when( 'check_ajax_referer' )->justReturn( true ); + Functions\when( 'current_user_can' )->justReturn( true ); + + // Transient stubs. + Functions\when( 'get_transient' )->alias( function( $key ) { + return $this->transient_value; + } ); + Functions\when( 'set_transient' )->alias( function( $key, $value, $ttl ) { + $this->transient_writes[] = array( $key, $value, $ttl ); + return true; + } ); + + // wp_send_json stubs — capture payload, mark test as "sent". + Functions\when( 'wp_send_json_success' )->alias( function( $data ) { + $this->json_success[] = $data; + } ); + Functions\when( 'wp_send_json_error' )->alias( function( $data ) { + $this->json_error[] = $data; + } ); + + // Generation_Lock::get_acquired_at() — return our test value. + // We stub the static method via $wpdb since it does a DB query. + $this->wpdb->method( 'prepare' )->willReturnCallback( + function ( $sql, ...$args ) { + return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */'; + } + ); + $this->wpdb->method( 'get_var' )->willReturnCallback( + function ( $sql ) { + // Return the lock timestamp when querying the lock option. + if ( false !== strpos( (string) $sql, 'prautoblogger_generation_lock' ) ) { + return null !== $this->lock_acquired_at ? (string) $this->lock_acquired_at : null; + } + return null; + } + ); + + // Run_State and Run_Context stubs — no DB tables in unit tests. + \PRAutoBlogger_Run_State::flush_cache(); + Functions\when( 'get_option' )->justReturn( false ); + Functions\when( 'delete_option' )->justReturn( true ); + Functions\when( 'delete_transient' )->justReturn( true ); + + // Logger stub. + Functions\when( 'wp_date' )->returnArg(); + } + + protected function tearDown(): void { + \PRAutoBlogger_Run_State::flush_cache(); + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + // ------------------------------------------------------------------------- + // R2(a): update_generation_stage() renews transient TTL + // ------------------------------------------------------------------------- + + /** + * update_generation_stage() must renew the transient with STATUS_TTL on + * every call so the transient outlasts a long LLM call. + */ + public function test_update_generation_stage_renews_transient_ttl(): void { + // Seed an existing running transient. + $this->transient_value = array( + 'status' => 'running', + 'stage' => 'old stage', + 'started' => time() - 30, + 'last_updated' => time() - 30, + ); + + $poller = new \PRAutoBlogger_Generation_Status_Poller(); + $poller->update_generation_stage( 'Analyzing sources...' ); + + $this->assertCount( 1, $this->transient_writes, 'set_transient must be called once' ); + [ $key, $value, $ttl ] = $this->transient_writes[0]; + $this->assertSame( \PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT, $key ); + $this->assertSame( \PRAutoBlogger_Generation_Status_Poller::STATUS_TTL, $ttl, + 'TTL must be renewed to STATUS_TTL on every stage update' ); + $this->assertSame( 'Analyzing sources...', $value['stage'] ); + } + + /** + * update_generation_stage() is a no-op when the transient is absent + * (prevents spurious "running" writes after a run has completed). + */ + public function test_update_generation_stage_no_op_when_transient_absent(): void { + $this->transient_value = false; // absent. + + $poller = new \PRAutoBlogger_Generation_Status_Poller(); + $poller->update_generation_stage( 'Stage text' ); + + $this->assertEmpty( $this->transient_writes, 'set_transient must NOT be called when transient is absent' ); + } + + // ------------------------------------------------------------------------- + // R3: transient absent, lock held within TTL → return status:running + // ------------------------------------------------------------------------- + + /** + * R3: When the status transient has expired but the lock is held within + * STATUS_TTL, the poller must return status:running so the Generate Now + * button does not silently reset to idle during long background runs. + */ + public function test_missing_transient_but_fresh_lock_returns_running(): void { + $this->transient_value = false; // transient gone. + $this->lock_acquired_at = time() - 60; // lock held 60s — well within TTL. + + // Stub _POST for the nonce. + $_POST = array( 'nonce' => 'fake' ); + $poller = new \PRAutoBlogger_Generation_Status_Poller(); + $poller->on_ajax_generation_status(); + + $this->assertCount( 1, $this->json_success, 'wp_send_json_success must be called once' ); + $response = $this->json_success[0]; + $this->assertSame( 'running', $response['status'], + 'R3: status must be running when transient absent but lock held within TTL' ); + $this->assertArrayHasKey( 'started', $response, + 'R3: response must include started timestamp from lock' ); + $this->assertSame( $this->lock_acquired_at, $response['started'], + 'R3: started must equal the lock acquired_at timestamp' ); + } + + // ------------------------------------------------------------------------- + // R2(b): transient absent, lock held > STATUS_TTL → abort + status:error + // ------------------------------------------------------------------------- + + /** + * R2(b): When the status transient is absent and the lock has been held + * longer than STATUS_TTL, the poller must mark the run failed and return + * status:error with an "infrastructure timeout" message. + */ + public function test_missing_transient_with_expired_lock_marks_failed_and_returns_error(): void { + $this->transient_value = false; + $ttl = \PRAutoBlogger_Generation_Status_Poller::STATUS_TTL; + $this->lock_acquired_at = time() - ( $ttl + 60 ); // Lock held TTL+60s. + + $_POST = array( 'nonce' => 'fake' ); + $poller = new \PRAutoBlogger_Generation_Status_Poller(); + $poller->on_ajax_generation_status(); + + $this->assertCount( 1, $this->json_success, 'wp_send_json_success must be called' ); + $response = $this->json_success[0]; + $this->assertSame( 'error', $response['status'], + 'R2(b): status must be error when lock is expired-stale' ); + $this->assertStringContainsString( + 'infrastructure timeout', + strtolower( $response['message'] ?? '' ), + 'R2(b): error message must mention infrastructure timeout' + ); + } + + // ------------------------------------------------------------------------- + // Idle path: no transient, no lock + // ------------------------------------------------------------------------- + + /** + * When both the transient and the lock are absent, the poller must + * return status:idle (normal rest state between runs). + */ + public function test_no_transient_no_lock_returns_idle(): void { + $this->transient_value = false; + $this->lock_acquired_at = null; // no lock. + + $_POST = array( 'nonce' => 'fake' ); + $poller = new \PRAutoBlogger_Generation_Status_Poller(); + $poller->on_ajax_generation_status(); + + $this->assertCount( 1, $this->json_success ); + $this->assertSame( 'idle', $this->json_success[0]['status'] ); + } +}