From 4ca66bcdc121d9153fc821dfccf0cc13805d7ba0 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Mon, 22 Jun 2026 16:32:13 +0000 Subject: [PATCH] v0.22.1: sync CLI, reaper TZ fix, maintenance-cron self-heal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wp prautoblogger generate --sync: synchronous VPS orchestrator mode. Acquires lock in-process, calls on_orchestrate_tick() then loops on_generate_tick() up to 20 ticks. Emits JSON summary; exits 0/1. - Generation_Checkpoint_Runner::set_sync_mode(): static flag suppresses wp-cron reschedule inside on_generate_tick() when the VPS external loop is the driver. No effect on existing async (cron/AJAX) paths. - Run_Reaper TZ fix: sweep_stuck_stages() + sweep_stuck_runs() now use wp_date() for $cutoff so it matches updated_at's local-TZ storage. gmdate() was 8h behind on Hostinger (UTC+8) — latent for old stale runs but would suppress reaping of same-day-stalled runs. - DB_Migrations v0221 self-heal: restores missing maintenance crons (reap_orphan_research_rows, collect_metrics, sync_runware_models). prautoblogger_daily_generation deliberately NOT re-registered — VPS systemd timer owns generation scheduling. Note: wp-cron reliability on this Hostinger host is suspect; maintenance crons are low-stakes. Agent-Session: prab-reliability-20260622 --- CHANGELOG.md | 32 ++++ includes/admin/class-wp-cli-commands.php | 168 +++++++++++++++--- includes/class-db-migrations.php | 27 +++ .../class-generation-checkpoint-runner.php | 30 +++- includes/core/class-run-reaper.php | 7 +- prautoblogger.php | 4 +- 6 files changed, 236 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3416c14..b838b46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,38 @@ 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. + ## [0.22.0] - 2026-06-16 ### Changed diff --git a/includes/admin/class-wp-cli-commands.php b/includes/admin/class-wp-cli-commands.php index 7b36470..23aeea6 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,132 @@ 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 +218,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..f625e19 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. @@ -245,10 +265,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 );