From 98eeb1227483fd701d58c8ee0e34b4d6ec31758f Mon Sep 17 00:00:00 2001 From: peptiderepo <273788462+peptiderepo@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:53:23 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20VPS-orchestrated=20generation=20reliabil?= =?UTF-8?q?ity=20=E2=80=94=20generate=20--sync,=20reaper=20TZ=20fix,=20mai?= =?UTF-8?q?ntenance-cron=20self-heal=20(v0.22.1)=20(#171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synchronous --sync CLI tick loop for VPS-driven generation over a held-open SSH session (survives Hostinger's ~10-min background-kill); $sync_mode guard on both tick handlers prevents double-drive; Run_Reaper UTC-vs-site-tz fix; one-shot self-heal migration restoring the maintenance crons that vanished after the 06-15/16 deploy (daily-generation excluded — VPS owns it). QA: APPROVE @ 0689989 (qa-reviews/prautoblogger/2026-06-23-0689989.md). CEO greenlight 2026-06-22. Agent-Session: prab-reliability-merge-20260622 --- ARCHITECTURE.md | 3 +- CHANGELOG.md | 45 +++- CONTEXT.md | 77 ++++++ includes/admin/class-wp-cli-commands.php | 184 +++++++++++-- includes/class-db-migrations.php | 27 ++ .../class-generation-checkpoint-runner.php | 42 ++- includes/core/class-run-reaper.php | 7 +- prautoblogger.php | 4 +- ...GenerationCheckpointRunnerSyncModeTest.php | 249 ++++++++++++++++++ .../Core/GenerationCheckpointRunnerTest.php | 7 +- tests/unit/Core/RunReaperTest.php | 7 + 11 files changed, 611 insertions(+), 41 deletions(-) create mode 100644 CONTEXT.md create mode 100644 tests/unit/Core/GenerationCheckpointRunnerSyncModeTest.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d6208a1..e026951 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1117,7 +1117,8 @@ If the host kills any tick: | Caller | Path | |--------|------| | Board "New Article" button | AJAX `prautoblogger_generate_now` → `Generation_Status_Poller::on_ajax_generate_now()` → schedules `prautoblogger_manual_generation` → `Executor::on_manual_generation()` → `kick_off()` | -| WP-CLI | `wp prautoblogger generate` → `WP_CLI_Commands::generate_command()` → `kick_off()` | +| WP-CLI (async) | `wp prautoblogger generate` → `WP_CLI_Commands::generate_command()` → `kick_off()` | +| WP-CLI (sync / VPS) | `wp prautoblogger generate --sync` → `WP_CLI_Commands::run_sync()` → `set_sync_mode(true)` → `on_orchestrate_tick()` → tick loop `on_generate_tick()` (×N) → `set_sync_mode(false)` | | Old cron hook (retained) | `prautoblogger_manual_generation` → `Executor::on_manual_generation()` → `kick_off()` | ### SSH Workaround Retirement diff --git a/CHANGELOG.md b/CHANGELOG.md index 3416c14..f00d1c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,49 @@ and this project uses [Semantic Versioning](https://semver.org/). - ADR log scaffolded: `docs/adr/` created as the app-local decision log for this plugin; `docs/adr/README.md` is the index; ADR-0001 documents the v0.16.0 editorial illustration image-style pivot. `ARCHITECTURE.md` updated to point to the folder. No runtime changes; docs-only. - CI constants guard (`scripts/check-constants.php`): new required CI step (`Check PRAUTOBLOGGER_* constants`) fails the build when any `PRAUTOBLOGGER_*` constant referenced in shipped plugin PHP is neither defined in `prautoblogger.php` nor `defined()`-guarded at the reference site. Prevents a recurrence of the v0.19.0 admin-500 regression class. +## [0.22.1] - 2026-06-22 + +### Added +- **`wp prautoblogger generate --sync`** (VPS orchestrator mode): runs the full + checkpoint pipeline synchronously in the calling process — acquires lock, calls + `on_orchestrate_tick()` in-process, then loops `on_generate_tick()` until the + queue is empty or a terminal state is reached (max 20 ticks). Exits 0 on + success with a JSON summary line; exits 1 on error. Designed to be called from + the Coolify VPS over a held-open SSH session, where Hostinger's ~10-min + background-process kill does not apply. + +### Fixed +- **`$sync_mode` guard in `Generation_Checkpoint_Runner::on_generate_tick()`**: + when running under `--sync`, the internal `wp_schedule_single_event()` + + `fire_cron_now()` reschedule is suppressed so the VPS's external loop cannot + race with a competing background wp-cron tick. Existing async (cron/AJAX) paths + are unaffected. +- **Reaper timezone fix** in `Run_Reaper::sweep_stuck_stages()` and + `sweep_stuck_runs()`: `$cutoff` was computed with `gmdate()` (UTC) while + `updated_at` is stored via `current_time('mysql')` (server local time, UTC+8). + Changed to `wp_date()` so the cutoff respects the site timezone. On this host + the 8-hour delta could suppress reaping of a same-day-stalled run; the 44h-old + run `ee6e7d91` was already far outside the threshold and unaffected. +- **Scoped self-heal migration** in `DB_Migrations`: restores the low-stakes + maintenance cron events (`prautoblogger_reap_orphan_research_rows`, + `prautoblogger_collect_metrics`, `prautoblogger_sync_runware_models`) that + vanished after the 06-15 deploy cycle. **`prautoblogger_daily_generation` is + deliberately NOT re-registered** — the VPS systemd timer owns generation + scheduling as of v0.22.1. Note: wp-cron reliability on this Hostinger host is + suspect (background PHP killed ~10 min); maintenance crons are low-stakes and + acceptable on this transport; generation is not. +- **Sync-mode guard tests** (QA NF-1 remediation 2026-06-23): moved sync-mode + tests to `GenerationCheckpointRunnerSyncModeTest.php`; fixed + `test_orchestrate_tick_in_sync_mode_does_not_schedule_background_event` to + return >= 1 idea from `orchestrate_only()` so execution reaches the sync guard + at line ~181 (was vacuous -- previous stub returned `[]` causing early return); + added `test_generate_tick_sync_mode_suppresses_reschedule` for the + `on_generate_tick()` guard; async regression test preserved in same file. +- **Root `CONTEXT.md`**: created domain glossary (run/run_stages/checkpoint/ + idea-queue/Generation_Lock) and execution mode comparison (async wp-cron vs. + `--sync` VPS). +- **`ARCHITECTURE.md` SS25**: added `WP-CLI (sync / VPS)` row to Entry Points table. + ## [0.22.0] - 2026-06-16 ### Changed @@ -478,7 +521,6 @@ and this project uses [Semantic Versioning](https://semver.org/). the catalog + OpenRouter static list. Breaking change: callers that expect a specific static list should note the new dynamic Runware side. - ### Fixed - Runware image generation now correctly uses the admin-selected model instead of silently falling back to FLUX.1 schnell (runware:100@1) for all non-schnell @@ -578,7 +620,6 @@ and this project uses [Semantic Versioning](https://semver.org/). ### Fixed - Image prompt builder now properly delegates to static parser methods - ### Fixed - **Opik autoloader gap** — `class-autoloader.php` listed `services/` as a scan directory but not `services/opik/`, where all four Opik classes live (`Opik_Client`, `Opik_Trace_Context`, `Opik_Span_Queue`, `Opik_Dispatcher`). Composer's classmap (used in PHPUnit) covers `includes/` recursively so tests passed, but the WordPress runtime autoloader would fatal on first Opik class reference in production. Added `services/opik/` to the scan list. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..3298e2a --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,77 @@ +# PRAutoBlogger — Domain Glossary + +Quick reference for the generation pipeline domain model. For full design rationale +see `ARCHITECTURE.md` (§21–§25) and `CONVENTIONS.md`. + +--- + +## Core Domain Terms + +**run** +A single end-to-end article generation cycle: orchestrate → generate idea(s) → +finalize. Represented as a row in `wp_prautoblogger_runs` (status, cost ceiling, +timestamps). One run may produce multiple articles if the daily idea-count > 1. + +**run\_stages** (`wp_prautoblogger_run_stages`) +Sub-steps within a run (research, analysis, generation, publish). Each stage has +its own status and can be replayed independently via `Rerun_Executor`. Generation +checkpoint runs populate stages as Article_Worker progresses. + +**checkpoint** +The persistence boundary between pipeline ticks. After each tick the idea queue +and run state are written to `wp_options` so a process kill cannot lose work. The +next tick reads the queue option and continues. See §25 of ARCHITECTURE.md. + +**idea queue** (`prautoblogger_article_queue` option) +Serialized array of `Article_Idea` objects written by `on_orchestrate_tick()` and +consumed one-at-a-time by successive `on_generate_tick()` calls. Deleted on +finalize or cleanup. + +**Generation\_Lock** (`wp_prautoblogger_generation_lock` or `wp_options`) +DB mutex that prevents two concurrent runs. Acquired at the start of +`on_orchestrate_tick()`, released by `Helpers::finalize()` or on error/halt. + +--- + +## Execution Modes + +### Async (wp-cron driven) + +Default path for the Board "New Article" button and the plain +`wp prautoblogger generate` WP-CLI command. + +1. `kick_off()` schedules `prautoblogger_gen_orchestrate` and calls `fire_cron_now()`. +2. `on_orchestrate_tick()` runs in a background PHP process, schedules + `prautoblogger_gen_tick`, and calls `fire_cron_now()` to spawn the next process. +3. Each `on_generate_tick()` spawns the next until the queue drains. + +Limitation: Hostinger kills background PHP processes at ~10 min. Orchestrate tick +(LLM-free) and individual generate ticks (one LLM call each) are both well within +this limit; the checkpoint design is the mitigation. + +### Sync (VPS-driven / `--sync`) + +Used by the Coolify KVM8 VPS systemd timer via `infra/vps/orchestrate-generation.sh` +in `peptide-e2e`. Introduced v0.22.1. + +1. `run_sync()` sets `Generation_Checkpoint_Runner::$sync_mode = true`. +2. Calls `on_orchestrate_tick()` **in-process** (no background spawn). +3. Loops calling `on_generate_tick()` directly until queue empty or terminal state. +4. `$sync_mode = true` suppresses all `wp_schedule_single_event` + + `fire_cron_now` calls inside both tick methods, preventing a competing + background cron chain from forming in parallel. + +The VPS SSH session is held open; the process is not subject to Hostinger's +background-kill constraint. + +--- + +## Table Shorthand + +Throughout the codebase `wp_prautoblogger_*` tables are referenced by their +`$wpdb->` property names (e.g. `$wpdb->prab_runs`, `$wpdb->prab_run_stages`). +Full table names are defined in `class-activator.php`. + +--- + +*See also: `ARCHITECTURE.md` §25 (checkpoint design), `CONVENTIONS.md` (coding rules).* diff --git a/includes/admin/class-wp-cli-commands.php b/includes/admin/class-wp-cli-commands.php index 7b36470..f1a4457 100644 --- a/includes/admin/class-wp-cli-commands.php +++ b/includes/admin/class-wp-cli-commands.php @@ -8,6 +8,14 @@ declare(strict_types=1); */ class PRAutoBlogger_WP_CLI_Commands { + /** + * Max generate-tick iterations for the --sync loop (covers daily_article_target = 1 + * with ample headroom; prevents infinite loops if the queue never drains). + * + * @var int + */ + private const SYNC_MAX_TICKS = 20; + /** * Register WP-CLI commands. * @@ -18,12 +26,19 @@ class PRAutoBlogger_WP_CLI_Commands { return; } - // Command: wp prautoblogger opik:eval \WP_CLI::add_command( 'prautoblogger generate', array( self::class, 'generate_command' ), array( - 'shortdesc' => 'Queue a new article generation run on chained-cron checkpoints', + 'shortdesc' => 'Queue (or synchronously run) a new article generation run', + 'synopsis' => array( + array( + 'type' => 'flag', + 'name' => 'sync', + 'description' => 'Run the full pipeline synchronously in this process (VPS orchestrator mode). Blocks until complete.', + 'optional' => true, + ), + ), ) ); @@ -51,6 +66,148 @@ class PRAutoBlogger_WP_CLI_Commands { ); } + /** + * Queue or synchronously run a new article generation run. + * + * Without --sync: routes through kick_off() (same as the board "New Article" + * button). Returns immediately; all pipeline work happens in cron. + * + * With --sync: runs the pipeline synchronously in this SSH/CLI process. + * 1. Acquires the generation lock (skips if a live run is in progress). + * 2. Calls on_orchestrate_tick() to collect/score ideas in-process. + * 3. Loops calling on_generate_tick() until the queue is empty or the run + * reaches a terminal state, up to SYNC_MAX_TICKS iterations. + * 4. Emits a JSON summary line on stdout and exits 0 (success) or 1 (error). + * + * The --sync path suppresses wp-cron reschedule inside on_generate_tick() so + * the external loop cannot race with a background cron tick. + * + * ## OPTIONS + * + * [--sync] + * : Run synchronously (VPS orchestrator mode). + * + * ## EXAMPLES + * + * wp prautoblogger generate + * wp prautoblogger generate --sync + * + * @param array $args Positional args (unused). + * @param array $assoc_args Associative args: sync. + * @return void + */ + public static function generate_command( array $args, array $assoc_args ): void { + if ( isset( $assoc_args['sync'] ) ) { + self::run_sync(); + return; + } + + PRAutoBlogger_Generation_Checkpoint_Runner::kick_off(); + \WP_CLI::success( 'Generation queued on chained-cron checkpoints. Check the board or Activity Log for progress.' ); + } + + /** + * Run the full pipeline synchronously in this process (--sync mode). + * + * Called only from generate_command(); never from wp-cron or AJAX paths. + * + * @return void + */ + private static function run_sync(): void { + // Honour ignore_user_abort so a dropped SSH session does not kill us. + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + @ignore_user_abort( true ); + + // Check for a live (non-expired) lock. Generation_Lock::acquire() already + // cleans expired locks on its DELETE pass, so if acquire() returns false + // there is a genuinely live concurrent run — skip cleanly. + if ( ! PRAutoBlogger_Generation_Lock::is_locked() ) { + // Lock is not held; proceed. acquire() is called inside on_orchestrate_tick(). + } else { + $acquired_at = PRAutoBlogger_Generation_Lock::get_acquired_at(); + $age = null !== $acquired_at ? ( time() - $acquired_at ) : HOUR_IN_SECONDS; + if ( $age < HOUR_IN_SECONDS ) { + \WP_CLI::log( + json_encode( + array( + 'status' => 'skipped', + 'reason' => 'run_in_progress', + 'lock_age_s' => $age, + ) + ) + ); + exit( 0 ); + } + // Expired lock — on_orchestrate_tick()'s acquire() will clean it. + } + + // Enable sync-mode: on_generate_tick() will skip the wp-cron reschedule. + PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( true ); + + \WP_CLI::log( 'PRAB-SYNC: starting orchestrate tick' ); + PRAutoBlogger_Generation_Checkpoint_Runner::on_orchestrate_tick(); + + // If the orchestrate tick failed to acquire the lock or found no ideas, + // the RUN_ID option will be absent. Detect and exit cleanly. + $run_id = (string) get_option( 'prautoblogger_checkpoint_run_id', '' ); + if ( '' === $run_id ) { + $queue = get_option( 'prautoblogger_article_queue' ); + if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) { + \WP_CLI::log( + json_encode( + array( + 'status' => 'done', + 'generated' => 0, + 'reason' => 'no_ideas_or_lock_failed', + ) + ) + ); + exit( 0 ); + } + } + + // Tick loop: drive generate ticks synchronously. + $ticks = 0; + while ( $ticks < self::SYNC_MAX_TICKS ) { + $queue = get_option( 'prautoblogger_article_queue' ); + if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) { + break; // Queue drained or finalized. + } + ++$ticks; + \WP_CLI::log( sprintf( 'PRAB-SYNC: generate tick %d (ideas remaining: %d)', $ticks, count( $queue['ideas'] ) ) ); + PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick(); + } + + PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( false ); + + if ( $ticks >= self::SYNC_MAX_TICKS ) { + \WP_CLI::warning( 'PRAB-SYNC: hit max-tick guard (' . self::SYNC_MAX_TICKS . '). Queue may not be fully drained.' ); + } + + // Read final status transient for summary. + $status = get_transient( 'prautoblogger_generation_status' ); + $result = is_array( $status ) ? $status : array(); + + $summary = array( + 'status' => 'done', + 'generated' => (int) ( $result['generated'] ?? 0 ), + 'published' => (int) ( $result['published'] ?? 0 ), + 'rejected' => (int) ( $result['rejected'] ?? 0 ), + 'cost_usd' => round( (float) ( $result['cost'] ?? 0.0 ), 4 ), + 'ticks' => $ticks, + ); + + \WP_CLI::log( json_encode( $summary ) ); + + if ( 'error' === ( $result['status'] ?? '' ) ) { + \WP_CLI::error( 'PRAB-SYNC: generation completed with error: ' . ( $result['message'] ?? 'unknown' ), false ); + exit( 1 ); + } + + \WP_CLI::success( 'PRAB-SYNC: generation complete.' ); + exit( 0 ); + } + /** * Run eval on the frozen dataset. * @@ -77,27 +234,4 @@ class PRAutoBlogger_WP_CLI_Commands { exit( $result['items_run'] > 0 ? 0 : 1 ); } - - /** - * Queue a new article generation run on chained-cron checkpoints. - * - * Routes through PRAutoBlogger_Generation_Checkpoint_Runner::kick_off() - * (same path as the board "New Article" button). Does NOT execute - * synchronously -- check the board or Activity Log for progress. - * This retires the `do_action("prautoblogger_manual_generation")` workaround. - * - * ## OPTIONS - * - * ## EXAMPLES - * - * wp prautoblogger generate - * - * @param array $args Positional args (unused). - * @param array $assoc_args Associative args (unused). - * @return void - */ - public static function generate_command( array $args, array $assoc_args ): void { - PRAutoBlogger_Generation_Checkpoint_Runner::kick_off(); - \WP_CLI::success( 'Generation queued on chained-cron checkpoints. Check the board or Activity Log for progress.' ); - } } diff --git a/includes/class-db-migrations.php b/includes/class-db-migrations.php index 823186b..a8681c9 100644 --- a/includes/class-db-migrations.php +++ b/includes/class-db-migrations.php @@ -125,5 +125,32 @@ class PRAutoBlogger_DB_Migrations { } update_option( 'prautoblogger_migrated_enc_prefix', '1' ); } + + // v0.22.1 self-heal: restore low-stakes maintenance cron events that were + // lost when the WP-Cron schedule vanished after the 06-15 deploy cycle. + // NOTE: wp-cron reliability on this Hostinger host is suspect (background + // PHP processes are killed at ~10 min). These low-stakes events (metrics, + // reaper, Runware sync) are acceptable on wp-cron; daily GENERATION is NOT + // re-registered here — the VPS systemd timer owns generation scheduling. + // The prautoblogger_migrated_schedule_tz_v082 guard is intentionally NOT + // re-used here; this migration targets the missing events themselves. + if ( ! get_option( 'prautoblogger_migrated_maintenance_crons_v0221' ) ) { + // Re-register maintenance crons only — daily_generation deliberately excluded. + // Activator::schedule_cron() uses wp_next_scheduled() guards so it is + // idempotent and will not overwrite events that are already present. + if ( ! wp_next_scheduled( 'prautoblogger_reap_orphan_research_rows' ) ) { + $tomorrow_reap = strtotime( 'tomorrow 03:15' ); + if ( false !== $tomorrow_reap ) { + wp_schedule_event( (int) $tomorrow_reap, 'daily', 'prautoblogger_reap_orphan_research_rows' ); + } + } + if ( ! wp_next_scheduled( 'prautoblogger_collect_metrics' ) ) { + wp_schedule_event( time() + HOUR_IN_SECONDS, 'prautoblogger_six_hours', 'prautoblogger_collect_metrics' ); + } + if ( ! wp_next_scheduled( 'prautoblogger_sync_runware_models' ) ) { + wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'prautoblogger_sync_runware_models' ); + } + update_option( 'prautoblogger_migrated_maintenance_crons_v0221', '1' ); + } } } diff --git a/includes/core/class-generation-checkpoint-runner.php b/includes/core/class-generation-checkpoint-runner.php index 9dd9feb..1d8f14f 100644 --- a/includes/core/class-generation-checkpoint-runner.php +++ b/includes/core/class-generation-checkpoint-runner.php @@ -52,6 +52,26 @@ class PRAutoBlogger_Generation_Checkpoint_Runner { /** WP-Cron action for each article-generation tick. */ public const GENERATE_ACTION = 'prautoblogger_gen_tick'; + /** + * When true, on_generate_tick() skips wp-cron rescheduling so the VPS + * orchestrator's external loop is the sole driver. Set via set_sync_mode(). + * Never true on the wp-cron path; reset to false after the sync run. + * + * @var bool + */ + private static bool $sync_mode = false; + + /** + * Enable or disable synchronous-driver mode. + * Called by WP_CLI_Commands::run_sync() before and after the tick loop. + * + * @param bool $enabled True to suppress internal cron reschedule. + * @return void + */ + public static function set_sync_mode( bool $enabled ): void { + self::$sync_mode = $enabled; + } + /** * Schedule the orchestration tick and write the initial status transient. * Called by on_ajax_generate_now() (via Executor) and by the WP-CLI command. @@ -155,11 +175,15 @@ class PRAutoBlogger_Generation_Checkpoint_Runner { ) ); - if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) { - wp_schedule_single_event( time(), self::GENERATE_ACTION ); + // In sync mode the VPS tick loop is the external driver; skip + // wp-cron reschedule to prevent a competing background tick. + // on_generate_tick() has the same guard for subsequent reschedules. + if ( ! self::$sync_mode ) { + if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) { + wp_schedule_single_event( time(), self::GENERATE_ACTION ); + } + PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now(); } - PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now(); - } catch ( \Throwable $e ) { PRAutoBlogger_Logger::instance()->error( sprintf( 'Orchestrate tick %s: %s', get_class( $e ), $e->getMessage() ), @@ -245,10 +269,14 @@ class PRAutoBlogger_Generation_Checkpoint_Runner { if ( ! empty( $queue['ideas'] ) ) { update_option( self::QUEUE_KEY, $queue, false ); PRAutoBlogger_Pipeline_Status::update_queue_progress( $queue ); - if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) { - wp_schedule_single_event( time(), self::GENERATE_ACTION ); + // In sync mode the VPS tick loop is the external driver; skip + // wp-cron reschedule to prevent a competing background tick. + if ( ! self::$sync_mode ) { + if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) { + wp_schedule_single_event( time(), self::GENERATE_ACTION ); + } + PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now(); } - PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now(); } else { PRAutoBlogger_Post_Assembler::amortize_research_costs( $run_id ); PRAutoBlogger_Generation_Checkpoint_Helpers::finalize( $queue['results'], $run_id ); diff --git a/includes/core/class-run-reaper.php b/includes/core/class-run-reaper.php index da85734..1fee3ee 100644 --- a/includes/core/class-run-reaper.php +++ b/includes/core/class-run-reaper.php @@ -133,7 +133,9 @@ class PRAutoBlogger_Run_Reaper { * @param int $seconds Expected seconds (default 300). */ $expected = (int) apply_filters( 'prautoblogger_filter_stage_expected_seconds', self::EXPECTED_STAGE_SECONDS ); - $cutoff = gmdate( 'Y-m-d H:i:s', time() - ( 2 * $expected ) ); + // Use wp_date() so the cutoff is expressed in the site's local timezone, + // matching the updated_at values written by current_time('mysql') (v0.22.1 TZ fix). + $cutoff = wp_date( 'Y-m-d H:i:s', time() - ( 2 * $expected ) ); $now = current_time( 'mysql' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery @@ -169,7 +171,8 @@ class PRAutoBlogger_Run_Reaper { * @param int $seconds Expected seconds (default 1800). */ $expected = (int) apply_filters( 'prautoblogger_filter_expected_run_seconds', self::EXPECTED_RUN_SECONDS ); - $cutoff = gmdate( 'Y-m-d H:i:s', time() - ( 2 * $expected ) ); + // Use wp_date() so the cutoff matches updated_at's local-timezone storage (v0.22.1 TZ fix). + $cutoff = wp_date( 'Y-m-d H:i:s', time() - ( 2 * $expected ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $stuck = $wpdb->get_col( diff --git a/prautoblogger.php b/prautoblogger.php index 335d318..0190ff0 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.22.0 + * Version: 0.22.1 * 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.22.0' ); +define( 'PRAUTOBLOGGER_VERSION', '0.22.1' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' ); define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 ); define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 ); diff --git a/tests/unit/Core/GenerationCheckpointRunnerSyncModeTest.php b/tests/unit/Core/GenerationCheckpointRunnerSyncModeTest.php new file mode 100644 index 0000000..668b398 --- /dev/null +++ b/tests/unit/Core/GenerationCheckpointRunnerSyncModeTest.php @@ -0,0 +1,249 @@ + on_orchestrate_tick() must NOT schedule GENERATE_ACTION + * or call spawn_cron/fire_cron_now (VPS loop drives ticks directly). + * NON-VACUOUS: orchestrate_only() returns 1 idea so execution reaches the guard + * at line ~181, not the empty-ideas early return at ~148. + * set_sync_mode(true) -> on_generate_tick() with ideas remaining must NOT schedule + * GENERATE_ACTION or call spawn_cron/fire_cron_now. + * set_sync_mode(false) -> on_generate_tick() with ideas remaining DOES schedule + * GENERATE_ACTION (regression guard -- async path unaffected by sync guard). + * + * @package PRAutoBlogger\Tests\Core + */ + +namespace PRAutoBlogger\Tests\Core; + +use PRAutoBlogger\Tests\BaseTestCase; +use Brain\Monkey\Functions; + +class GenerationCheckpointRunnerSyncModeTest extends BaseTestCase { + + /** @var string[] Cron hooks captured from wp_schedule_single_event(). */ + private array $scheduled = array(); + + /** @var array Option writes captured from update_option(). */ + private array $options = array(); + + /** @var bool Tracks whether spawn_cron was called. */ + private bool $spawn_called = false; + + protected function setUp(): void { + parent::setUp(); + $this->scheduled = array(); + $this->options = array(); + $this->spawn_called = false; + + Functions\when( 'wp_schedule_single_event' )->alias( + function ( $ts, $hook, $args = array() ) { + $this->scheduled[] = $hook; + return true; + } + ); + Functions\when( 'wp_next_scheduled' )->justReturn( false ); + Functions\when( 'spawn_cron' )->alias( function () { + $this->spawn_called = true; + return true; + } ); + Functions\when( 'wp_remote_post' )->justReturn( array() ); + Functions\when( 'site_url' )->justReturn( 'https://example.com' ); + + Functions\when( 'set_transient' )->justReturn( true ); + Functions\when( 'update_option' )->alias( + function ( $name, $value, $autoload = null ) { + $this->options[ $name ] = $value; + return true; + } + ); + Functions\when( 'delete_option' )->alias( + function ( $name ) { + unset( $this->options[ $name ] ); + return true; + } + ); + Functions\when( 'get_option' )->alias( + function ( $name, $default = false ) { + return $this->options[ $name ] ?? $default; + } + ); + Functions\when( 'wp_generate_uuid4' )->justReturn( 'sync-test-uuid-001' ); + $this->options['prautoblogger_monthly_budget_usd'] = '0'; + Functions\when( 'wp_cache_get' )->justReturn( false ); + Functions\when( 'wp_cache_set' )->justReturn( true ); + Functions\when( 'wp_cache_delete' )->justReturn( true ); + \PRAutoBlogger_Run_State::flush_cache(); + } + + protected function tearDown(): void { + \PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( false ); + \PRAutoBlogger_Run_State::flush_cache(); + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** Stub the generation lock to always succeed. */ + private function wire_wpdb_for_lock(): void { + $wpdb = $this->create_mock_wpdb(); + $wpdb->options = 'wp_options'; + $GLOBALS['wpdb'] = $wpdb; + $wpdb->method( 'query' )->willReturn( 1 ); + $wpdb->method( 'get_var' )->willReturn( null ); + } + + /** Build a minimal Article_Idea-compatible array. */ + private function make_idea( string $topic = 'Sync Topic' ): array { + return array( + 'topic' => $topic, + 'article_type' => 'guide', + 'suggested_title' => 'Guide to ' . $topic, + 'summary' => '', + 'score' => 0.9, + 'analysis_id' => 1, + 'source_ids' => array(), + 'key_points' => array(), + 'target_keywords' => array(), + ); + } + + // __ sync-mode guard: on_orchestrate_tick() ______________________________________________ + + /** + * sync_mode=true + >=1 idea returned: on_orchestrate_tick() must not schedule + * GENERATE_ACTION or call spawn_cron (VPS loop drives generate ticks directly). + * + * NON-VACUOUS: orchestrate_only() returns 1 idea so execution reaches the sync + * guard at line ~181, not the empty-ideas early return at ~148. + */ + public function test_orchestrate_tick_in_sync_mode_does_not_schedule_background_event(): void { + $this->wire_wpdb_for_lock(); + + // Provide Pipeline_Runner stub that returns 1 idea -- forces execution past + // the empty-ideas early return so the sync guard at line ~181 is exercised. + if ( ! class_exists( 'PRAutoBlogger_Pipeline_Runner', false ) ) { + // phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired + eval( 'class PRAutoBlogger_Pipeline_Runner { + public function set_skip_dedup($v){ return $this; } + public function orchestrate_only($ct){ + $idea = new PRAutoBlogger_Article_Idea([ + "topic"=>"Sync Topic","article_type"=>"guide", + "suggested_title"=>"Guide","summary"=>"","score"=>0.9, + "analysis_id"=>1,"source_ids"=>[],"key_points"=>[], + "target_keywords"=>[] + ]); + return [$idea]; + } + }' ); + } + + \PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( true ); + \PRAutoBlogger_Generation_Checkpoint_Runner::on_orchestrate_tick(); + + $this->assertNotContains( + \PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION, + $this->scheduled, + 'sync mode: on_orchestrate_tick must not schedule GENERATE_ACTION' + ); + $this->assertFalse( + $this->spawn_called, + 'sync mode: on_orchestrate_tick must not call spawn_cron' + ); + } + + // __ sync-mode guard: on_generate_tick() _________________________________________________ + + /** + * sync_mode=true + 2 ideas in queue: on_generate_tick() must not reschedule + * GENERATE_ACTION or call spawn_cron (VPS loop is the sole driver of next tick). + * + * Uses 2 ideas so the code stays in the "more ideas remain" branch -- this + * path calls Article_Worker->generate() and then the sync guard, never + * Post_Assembler::amortize_research_costs() (that is in the last-idea branch). + * Article_Worker is stubbed; Post_Assembler is not needed and must NOT be + * redefined here to avoid clobbering the real class for downstream tests. + */ + public function test_generate_tick_sync_mode_suppresses_reschedule(): void { + $run_id = 'sync-gen-tick-uuid'; + $this->options['prautoblogger_article_queue'] = array( + 'run_id' => $run_id, + 'ideas' => array( $this->make_idea( 'A' ), $this->make_idea( 'B' ) ), + 'results' => array( 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ), + ); + + $wpdb = $this->create_mock_wpdb(); + $wpdb->options = 'wp_options'; + $GLOBALS['wpdb'] = $wpdb; + $wpdb->method( 'get_row' )->willReturn( array( 'status' => 'running', 'ceiling_usd' => '0' ) ); + $wpdb->method( 'query' )->willReturn( 1 ); + $wpdb->method( 'get_var' )->willReturn( null ); + + // Stub Article_Worker only -- Post_Assembler is NOT stubbed here because + // amortize_research_costs() is not called on the "more ideas remain" path, + // and redefining it with an incomplete stub breaks Publisher tests that run + // after this file (alphabetical: SyncModeTest < Test). + if ( ! class_exists( 'PRAutoBlogger_Article_Worker', false ) ) { + // phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired + eval( 'class PRAutoBlogger_Article_Worker { + public function __construct($ct){} + public function generate($idea){ return ["generated"=>1,"published"=>1,"rejected"=>0,"cost"=>0.01]; } + }' ); + } + + \PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( true ); + \PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick(); + + $this->assertNotContains( + \PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION, + $this->scheduled, + 'sync mode: on_generate_tick must not schedule GENERATE_ACTION' + ); + $this->assertFalse( + $this->spawn_called, + 'sync mode: on_generate_tick must not call spawn_cron' + ); + } + + // __ async regression guard ______________________________________________________________ + + /** + * sync_mode=false: on_generate_tick() with ideas remaining DOES schedule + * GENERATE_ACTION (regression guard -- async path must be unaffected by sync guard). + */ + public function test_generate_tick_async_mode_schedules_generate_action(): void { + $run_id = 'async-regression-uuid'; + $this->options['prautoblogger_article_queue'] = array( + 'run_id' => $run_id, + 'ideas' => array( $this->make_idea( 'A' ), $this->make_idea( 'B' ) ), + 'results' => array( 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ), + ); + + $wpdb = $this->create_mock_wpdb(); + $wpdb->options = 'wp_options'; + $GLOBALS['wpdb'] = $wpdb; + $wpdb->method( 'get_row' )->willReturn( array( 'status' => 'running', 'ceiling_usd' => '0' ) ); + $wpdb->method( 'query' )->willReturn( 1 ); + $wpdb->method( 'get_var' )->willReturn( null ); + + // Article_Worker stub only -- see note in test_generate_tick_sync_mode_suppresses_reschedule. + if ( ! class_exists( 'PRAutoBlogger_Article_Worker', false ) ) { + // phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired + eval( 'class PRAutoBlogger_Article_Worker { + public function __construct($ct){} + public function generate($idea){ return ["generated"=>1,"published"=>1,"rejected"=>0,"cost"=>0.01]; } + }' ); + } + + \PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( false ); + \PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick(); + + $this->assertContains( + \PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION, + $this->scheduled, + 'async mode: on_generate_tick must schedule GENERATE_ACTION when ideas remain' + ); + } +} diff --git a/tests/unit/Core/GenerationCheckpointRunnerTest.php b/tests/unit/Core/GenerationCheckpointRunnerTest.php index 2a8e104..f9a7229 100644 --- a/tests/unit/Core/GenerationCheckpointRunnerTest.php +++ b/tests/unit/Core/GenerationCheckpointRunnerTest.php @@ -6,6 +6,9 @@ * kick_off() -- schedules cron + writes initial transient, no pipeline call. * on_generate_tick() -- pops ONE idea, reschedules or finalizes; halted run aborts early. * + * Sync-mode guard tests live in GenerationCheckpointRunnerSyncModeTest.php + * (split to comply with the 300-line rule). + * * @package PRAutoBlogger\Tests\Core */ @@ -104,7 +107,7 @@ class GenerationCheckpointRunnerTest extends BaseTestCase { ); } - // ── kick_off() ──────────────────────────────────────────────────────── + // __ kick_off() __________________________________________________________________________ /** * kick_off() schedules exactly the ORCHESTRATE cron action and writes @@ -129,7 +132,7 @@ class GenerationCheckpointRunnerTest extends BaseTestCase { $this->assertTrue( $found_running_transient, 'kick_off() must write a running status transient' ); } - // ── on_generate_tick() ──────────────────────────────────────────────── + // __ on_generate_tick() __________________________________________________________________ /** * An empty queue causes generate tick to finalize without scheduling diff --git a/tests/unit/Core/RunReaperTest.php b/tests/unit/Core/RunReaperTest.php index 23877f2..812c8c4 100644 --- a/tests/unit/Core/RunReaperTest.php +++ b/tests/unit/Core/RunReaperTest.php @@ -46,6 +46,13 @@ class RunReaperTest extends BaseTestCase { } ); Functions\when( 'apply_filters' )->returnArg( 2 ); + // wp_date() is called by the reaper's stuck-sweep cutoff (v0.22.1 TZ fix). + // Alias to gmdate() so tests remain timezone-agnostic. + Functions\when( 'wp_date' )->alias( + static function ( string $format, ?int $timestamp = null ): string { + return gmdate( $format, null !== $timestamp ? $timestamp : time() ); + } + ); \PRAutoBlogger_Run_State::flush_cache(); \PRAutoBlogger_Run_Stage_State::flush_cache(); }