|null Article_Idea-compatible array, or null.
- */
- private static function load_idea_data( int $idea_id ): ?array {
- global $wpdb;
- $table = $wpdb->prefix . 'prautoblogger_analysis_results';
-
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
- $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $idea_id ),
- ARRAY_A
- );
- if ( ! $row ) {
- return null;
- }
-
- $meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?? array();
- return array(
- 'topic' => $row['topic'],
- 'article_type' => $row['analysis_type'],
- 'suggested_title' => $meta['suggested_title'] ?? $row['topic'],
- 'summary' => $row['summary'] ?? '',
- 'score' => (float) $row['relevance_score'],
- 'analysis_id' => (int) $row['id'],
- 'source_ids' => json_decode( $row['source_ids_json'] ?? '[]', true ) ?? array(),
- 'key_points' => $meta['key_points'] ?? array(),
- 'target_keywords' => $meta['target_keywords'] ?? array(),
- );
- }
-
- /** Query analysis results with optional filtering and pagination. */
- private function query_ideas( int $paged, string $type ): array {
- global $wpdb;
- $table = $wpdb->prefix . 'prautoblogger_analysis_results';
- $where = array();
- $params = array();
-
- if ( '' !== $type ) {
- $where[] = 'analysis_type = %s';
- $params[] = $type;
- }
-
- $where_sql = empty( $where ) ? '' : 'WHERE ' . implode( ' AND ', $where );
- $offset = ( $paged - 1 ) * self::PER_PAGE;
-
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
- $total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
- ? "SELECT COUNT(*) FROM {$table} {$where_sql}" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
- : $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
- );
-
- $order_sql = 'ORDER BY analyzed_at DESC, relevance_score DESC';
- $limit_sql = sprintf( 'LIMIT %d OFFSET %d', self::PER_PAGE, $offset );
- $full_sql = "SELECT * FROM {$table} {$where_sql} {$order_sql} {$limit_sql}";
-
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
- $rows = empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
- ? $wpdb->get_results( $full_sql, ARRAY_A ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
- : $wpdb->get_results( $wpdb->prepare( $full_sql, ...$params ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
-
- return array(
- 'rows' => $rows ?? array(),
- 'total' => $total,
- );
- }
-
- /** Get counts per analysis_type for the filter badges. */
- private function get_type_counts(): array {
- global $wpdb;
- $table = $wpdb->prefix . 'prautoblogger_analysis_results';
-
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
- $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- "SELECT analysis_type, COUNT(*) AS cnt FROM {$table} GROUP BY analysis_type ORDER BY cnt DESC",
- ARRAY_A
- );
-
- $counts = array();
- foreach ( $rows ?? array() as $row ) {
- $counts[ $row['analysis_type'] ] = (int) $row['cnt'];
- }
- return $counts;
+ delete_transient( $prefix . 'data_' . $idea_id );
}
}
diff --git a/includes/admin/class-wp-cli-commands.php b/includes/admin/class-wp-cli-commands.php
index d9f664f..7b36470 100644
--- a/includes/admin/class-wp-cli-commands.php
+++ b/includes/admin/class-wp-cli-commands.php
@@ -19,6 +19,14 @@ class PRAutoBlogger_WP_CLI_Commands {
}
// 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',
+ )
+ );
+
\WP_CLI::add_command(
'prautoblogger opik:eval',
array( self::class, 'opik_eval_command' ),
@@ -69,4 +77,27 @@ 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-activator.php b/includes/class-activator.php
index 7892977..4f6d98c 100644
--- a/includes/class-activator.php
+++ b/includes/class-activator.php
@@ -80,7 +80,10 @@ class PRAutoBlogger_Activator {
'prautoblogger_table_borders' => '1',
'prautoblogger_schedule_time' => '03:00',
'prautoblogger_log_level' => 'info',
- 'prautoblogger_image_nsfw_retry' => '1',
+ 'prautoblogger_image_nsfw_retry' => '1',
+ // M4 (v0.21.0): board column card limit + ideas page size -- settings-backed.
+ 'prautoblogger_board_column_limit' => PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT,
+ 'prautoblogger_ideas_per_page' => PRAUTOBLOGGER_DEFAULT_IDEAS_PER_PAGE,
);
foreach ( $defaults as $key => $value ) {
diff --git a/includes/class-executor.php b/includes/class-executor.php
index fb2d1e0..97aba82 100644
--- a/includes/class-executor.php
+++ b/includes/class-executor.php
@@ -85,68 +85,21 @@ class PRAutoBlogger_Executor {
}
/**
- * Cron handler: runs pipeline orchestration + first article.
+ * Cron handler: kick off the chained-cron orchestration tick (v0.21.0, M4).
*
- * If the pipeline queues additional articles (2..N), they will fire as
- * chained cron events — each in its own PHP process. The lock is released
- * by the pipeline when the last article completes, not here.
+ * Delegates to Generation_Checkpoint_Runner::on_orchestrate_tick() which
+ * runs Tick 1 (collect -> analyze -> score), then schedules Tick 2..N for
+ * article generation. This replaces the former monolithic pipeline run and
+ * retires the SSH-only `do_action("prautoblogger_manual_generation")` workaround
+ * (see ARCHITECTURE.md ss25).
+ *
+ * Lock acquisition and status transient writes are fully owned by the
+ * checkpoint runner; this method is a thin routing wrapper.
+ *
+ * @return void
*/
public function on_manual_generation(): void {
- // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
- @ignore_user_abort( true );
- // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
- @set_time_limit( 300 );
-
- if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
- set_transient(
- PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
- array(
- 'status' => 'error',
- 'message' => __( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' ),
- ),
- PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
- );
- return;
- }
-
- try {
- $this->poller()->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) );
-
- $runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true );
- $results = $runner->run();
-
- // If no articles were queued (target=1 or 0 ideas), finalize here.
- // When articles ARE queued, the pipeline handles status + lock release.
- if ( ! get_option( 'prautoblogger_article_queue' ) ) {
- set_transient(
- PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
- array(
- 'status' => 'complete',
- 'generated' => $results['generated'],
- 'published' => $results['published'],
- 'rejected' => $results['rejected'],
- 'cost' => $results['cost'],
- ),
- PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
- );
- PRAutoBlogger_Generation_Lock::release();
- }
- } catch ( \Throwable $e ) {
- PRAutoBlogger_Logger::instance()->error(
- sprintf( 'Manual generation %s: %s', get_class( $e ), $e->getMessage() ),
- 'pipeline'
- );
- set_transient(
- PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
- array(
- 'status' => 'error',
- 'message' => $e->getMessage(),
- ),
- PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
- );
- $this->mark_current_run_failed();
- PRAutoBlogger_Generation_Lock::release();
- }
+ PRAutoBlogger_Generation_Checkpoint_Runner::on_orchestrate_tick();
}
/** Cron handler: process next queued article. Chained by Pipeline_Runner. */
diff --git a/includes/class-pipeline-schema-installer.php b/includes/class-pipeline-schema-installer.php
index 3ceee7c..e0fafe4 100644
--- a/includes/class-pipeline-schema-installer.php
+++ b/includes/class-pipeline-schema-installer.php
@@ -115,7 +115,8 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
updated_at DATETIME NOT NULL,
finished_at DATETIME DEFAULT NULL,
PRIMARY KEY (run_id),
- KEY status (status)
+ KEY status (status),
+ KEY started_at (started_at)
) {$charset_collate};";
// Per-run per-stage state rows. The idempotency key is
diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php
index 9ebf6a9..ca74d4f 100644
--- a/includes/class-prautoblogger.php
+++ b/includes/class-prautoblogger.php
@@ -146,6 +146,10 @@ class PRAutoBlogger {
add_action( PRAutoBlogger_Rerun_Executor::REPLAY_ACTION, array( $rerun_executor, 'on_replay_job' ), 10, 5 );
add_action( PRAutoBlogger_Rerun_Executor::REBUILD_ACTION, array( $rerun_executor, 'on_rebuild_job' ), 10, 4 );
+ // v0.21.0 (M4): chained-cron checkpoint ticks for new article generation.
+ add_action( PRAutoBlogger_Generation_Checkpoint_Runner::ORCHESTRATE_ACTION, array( 'PRAutoBlogger_Generation_Checkpoint_Runner', 'on_orchestrate_tick' ) );
+ add_action( PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION, array( 'PRAutoBlogger_Generation_Checkpoint_Runner', 'on_generate_tick' ) );
+
add_action( 'prautoblogger_generate_from_idea', array( 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ) );
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Research_Reaper', 'on_cron' ) );
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Run_Reaper', 'on_cron' ) );
diff --git a/includes/class-schema-installer.php b/includes/class-schema-installer.php
index ec61810..e808944 100644
--- a/includes/class-schema-installer.php
+++ b/includes/class-schema-installer.php
@@ -71,7 +71,8 @@ class PRAutoBlogger_Schema_Installer {
metadata_json LONGTEXT DEFAULT NULL,
PRIMARY KEY (id),
KEY type_relevance (analysis_type, relevance_score),
- KEY analyzed_at (analyzed_at)
+ KEY analyzed_at (analyzed_at),
+ KEY analyzed_type (analyzed_at, analysis_type)
) {$charset_collate};";
// Generation log — every API call with cost tracking. `run_id`
@@ -100,7 +101,9 @@ class PRAutoBlogger_Schema_Installer {
KEY post_id (post_id),
KEY run_id (run_id),
KEY created_at (created_at),
- KEY stage (stage)
+ KEY stage (stage),
+ KEY run_id_post_id (run_id, post_id),
+ KEY created_status (created_at, response_status)
) {$charset_collate};";
// Content scores — post performance metrics for self-improvement.
diff --git a/includes/core/class-generation-checkpoint-helpers.php b/includes/core/class-generation-checkpoint-helpers.php
new file mode 100644
index 0000000..00e9370
--- /dev/null
+++ b/includes/core/class-generation-checkpoint-helpers.php
@@ -0,0 +1,71 @@
+ 0.01,
+ 'blocking' => false,
+ 'sslverify' => false,
+ )
+ );
+ }
+}
diff --git a/includes/core/class-generation-checkpoint-runner.php b/includes/core/class-generation-checkpoint-runner.php
new file mode 100644
index 0000000..9dd9feb
--- /dev/null
+++ b/includes/core/class-generation-checkpoint-runner.php
@@ -0,0 +1,267 @@
+ analyze patterns -> score ideas ->
+ * persist idea queue to option -> schedule Tick 2.
+ * Tick 2..N (GENERATE): pop one idea from queue -> Article_Worker ->
+ * update transient -> schedule next tick or finalize.
+ *
+ * Why separate from Rerun_Executor: Rerun_Executor targets existing run_stages
+ * rows (replay/rebuild). This class creates a brand-new run from scratch.
+ *
+ * SSH retirement (v0.21.0): the former `wp eval 'do_action("prautoblogger_manual_generation")'`
+ * workaround fired on_manual_generation() synchronously in one PHP process --
+ * bypassing the nonce and running the full pipeline without checkpoints.
+ * That path is retired: Executor::on_manual_generation() now delegates here,
+ * and `wp prautoblogger generate` queues via kick_off() (see ARCHITECTURE.md ss25).
+ *
+ * Cost governor: Run_State::ceiling_setting() is applied per-run; a halted run
+ * aborts all remaining ticks (same guarantee as Pipeline_Runner's queued-article path).
+ *
+ * Who triggers it: PRAutoBlogger_Executor::on_manual_generation() (cron),
+ * PRAutoBlogger_WP_CLI_Commands (wp prautoblogger generate).
+ * Dependencies: Pipeline_Runner (orchestration), Run_State (run ledger),
+ * Pipeline_Status (transient), Generation_Lock, Cost_Tracker, Logger.
+ *
+ * @see includes/class-executor.php -- Cron/AJAX entry points.
+ * @see core/class-pipeline-runner.php -- Orchestration + article worker.
+ * @see core/class-run-state.php -- Per-run ledger (cost governor).
+ * @see core/class-pipeline-status.php -- Transient broadcast helpers.
+ * @see core/class-generation-checkpoint-helpers.php -- finalize/cleanup/fire helpers.
+ * @see ARCHITECTURE.md ss25 -- Checkpoint generation path design.
+ */
+class PRAutoBlogger_Generation_Checkpoint_Runner {
+
+ /** Option key for the checkpoint queue (shared with Pipeline_Runner article queue). */
+ private const QUEUE_KEY = 'prautoblogger_article_queue';
+
+ /** Option key for the in-progress run UUID (generation tick). */
+ private const RUN_ID_KEY = 'prautoblogger_checkpoint_run_id';
+
+ /** WP-Cron action for the orchestration tick. */
+ public const ORCHESTRATE_ACTION = 'prautoblogger_gen_orchestrate';
+
+ /** WP-Cron action for each article-generation tick. */
+ public const GENERATE_ACTION = 'prautoblogger_gen_tick';
+
+ /**
+ * Schedule the orchestration tick and write the initial status transient.
+ * Called by on_ajax_generate_now() (via Executor) and by the WP-CLI command.
+ * Returns immediately -- ALL pipeline work happens in cron.
+ *
+ * Side effects: cron scheduling, transient write.
+ *
+ * @return void
+ */
+ public static function kick_off(): void {
+ if ( wp_next_scheduled( self::ORCHESTRATE_ACTION ) ) {
+ return; // Idempotent: already queued.
+ }
+ wp_schedule_single_event( time(), self::ORCHESTRATE_ACTION );
+ PRAutoBlogger_Pipeline_Status::write_initial();
+ PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
+ }
+
+ /**
+ * Cron Tick 1: collect -> analyze -> score. Persists ideas, creates run row,
+ * schedules Tick 2 for the first article, then returns.
+ *
+ * Side effects: source/analysis DB writes, run row insert, option write,
+ * status transient update, cron scheduling.
+ *
+ * @return void
+ */
+ public static function on_orchestrate_tick(): void {
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+ @ignore_user_abort( true );
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+ @set_time_limit( 300 );
+
+ if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
+ PRAutoBlogger_Pipeline_Status::write_error(
+ __( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' )
+ );
+ return;
+ }
+
+ $run_id = wp_generate_uuid4();
+ update_option( self::RUN_ID_KEY, $run_id, false );
+ PRAutoBlogger_Run_State::ensure_run( $run_id );
+
+ $cost_tracker = new PRAutoBlogger_Cost_Tracker();
+ $cost_tracker->set_run_id( $run_id );
+
+ try {
+ if ( $cost_tracker->is_budget_exceeded() ) {
+ PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
+ PRAutoBlogger_Pipeline_Status::write_error(
+ __( 'Monthly API budget exceeded. Generation halted.', 'prautoblogger' )
+ );
+ PRAutoBlogger_Generation_Lock::release();
+ return;
+ }
+
+ PRAutoBlogger_Pipeline_Status::broadcast( __( 'Collecting sources…', 'prautoblogger' ) );
+ $runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true );
+ $ideas = $runner->orchestrate_only( $cost_tracker );
+
+ if ( empty( $ideas ) ) {
+ PRAutoBlogger_Run_State::mark_status( $run_id, 'done' );
+ PRAutoBlogger_Pipeline_Status::write_final(
+ array(
+ 'generated' => 0,
+ 'published' => 0,
+ 'rejected' => 0,
+ 'cost' => 0.0,
+ )
+ );
+ PRAutoBlogger_Generation_Lock::release();
+ delete_option( self::RUN_ID_KEY );
+ return;
+ }
+
+ $serialized = array_map(
+ static fn( PRAutoBlogger_Article_Idea $i ) => $i->to_array(),
+ $ideas
+ );
+ update_option(
+ self::QUEUE_KEY,
+ array(
+ 'run_id' => $run_id,
+ 'ideas' => $serialized,
+ 'results' => array(
+ 'generated' => 0,
+ 'published' => 0,
+ 'rejected' => 0,
+ 'cost' => 0.0,
+ ),
+ ),
+ false
+ );
+
+ PRAutoBlogger_Pipeline_Status::broadcast(
+ sprintf(
+ /* translators: %d: number of ideas found */
+ __( 'Found %d idea(s) — starting generation…', 'prautoblogger' ),
+ count( $ideas )
+ )
+ );
+
+ if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) {
+ wp_schedule_single_event( time(), self::GENERATE_ACTION );
+ }
+ PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
+
+ } catch ( \Throwable $e ) {
+ PRAutoBlogger_Logger::instance()->error(
+ sprintf( 'Orchestrate tick %s: %s', get_class( $e ), $e->getMessage() ),
+ 'pipeline'
+ );
+ PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
+ PRAutoBlogger_Pipeline_Status::write_error( $e->getMessage() );
+ PRAutoBlogger_Generation_Lock::release();
+ delete_option( self::RUN_ID_KEY );
+ }
+ }
+
+ /**
+ * Cron Tick 2..N: generate one article, persist totals, reschedule or finalize.
+ *
+ * Side effects: LLM API calls, DB writes, post creation, transient updates,
+ * cron scheduling.
+ *
+ * @return void
+ */
+ public static function on_generate_tick(): void {
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+ @ignore_user_abort( true );
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+ @set_time_limit( 300 );
+
+ $queue = get_option( self::QUEUE_KEY );
+ if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) {
+ PRAutoBlogger_Generation_Checkpoint_Helpers::finalize(
+ array(
+ 'generated' => 0,
+ 'published' => 0,
+ 'rejected' => 0,
+ 'cost' => 0.0,
+ )
+ );
+ return;
+ }
+
+ $run_id = (string) ( $queue['run_id'] ?? '' );
+ $run_status = PRAutoBlogger_Run_State::get_status( $run_id );
+ if ( in_array( $run_status, array( 'halted', 'failed' ), true ) ) {
+ PRAutoBlogger_Logger::instance()->warning(
+ sprintf( 'Generate tick: run %s is %s -- aborting.', $run_id, $run_status ),
+ 'pipeline'
+ );
+ PRAutoBlogger_Generation_Checkpoint_Helpers::cleanup_queue();
+ PRAutoBlogger_Generation_Lock::release();
+ return;
+ }
+
+ $idea_data = array_shift( $queue['ideas'] );
+ $idea = new PRAutoBlogger_Article_Idea( $idea_data );
+ $cost_tracker = new PRAutoBlogger_Cost_Tracker();
+ $cost_tracker->set_run_id( $run_id );
+
+ if ( $cost_tracker->is_budget_exceeded() ) {
+ PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
+ PRAutoBlogger_Generation_Checkpoint_Helpers::cleanup_queue();
+ PRAutoBlogger_Generation_Lock::release();
+ PRAutoBlogger_Pipeline_Status::write_error(
+ __( 'Monthly API budget exceeded. Generation halted.', 'prautoblogger' )
+ );
+ return;
+ }
+
+ // Persist consumed queue BEFORE generating (prevents orphan-recovery duplication).
+ if ( ! empty( $queue['ideas'] ) ) {
+ update_option( self::QUEUE_KEY, $queue, false );
+ } else {
+ delete_option( self::QUEUE_KEY );
+ }
+
+ try {
+ $worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
+ $result = $worker->generate( $idea );
+
+ $queue['results']['generated'] += $result['generated'];
+ $queue['results']['published'] += $result['published'];
+ $queue['results']['rejected'] += $result['rejected'];
+ $queue['results']['cost'] += $result['cost'];
+
+ 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 );
+ }
+ 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 );
+ }
+ } catch ( \Throwable $e ) {
+ PRAutoBlogger_Logger::instance()->error(
+ sprintf( 'Generate tick %s for run %s: %s', get_class( $e ), $run_id, $e->getMessage() ),
+ 'pipeline'
+ );
+ PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
+ PRAutoBlogger_Generation_Checkpoint_Helpers::cleanup_queue();
+ PRAutoBlogger_Generation_Lock::release();
+ PRAutoBlogger_Pipeline_Status::write_error( $e->getMessage() );
+ }
+ }
+}
diff --git a/includes/core/class-pipeline-runner.php b/includes/core/class-pipeline-runner.php
index 426c373..b19044e 100644
--- a/includes/core/class-pipeline-runner.php
+++ b/includes/core/class-pipeline-runner.php
@@ -168,6 +168,19 @@ class PRAutoBlogger_Pipeline_Runner {
}
}
+
+ /**
+ * Public wrapper for orchestrate(): collect -> analyze -> score.
+ * Used by Generation_Checkpoint_Runner (Tick 1) so orchestration and
+ * article generation happen in separate cron ticks.
+ *
+ * @param PRAutoBlogger_Cost_Tracker $cost_tracker
+ * @return PRAutoBlogger_Article_Idea[]
+ */
+ public function orchestrate_only( PRAutoBlogger_Cost_Tracker $cost_tracker ): array {
+ return $this->orchestrate( $cost_tracker );
+ }
+
// ── Private helpers ─────────────────────────────────────────────────
/**
diff --git a/includes/core/class-pipeline-status.php b/includes/core/class-pipeline-status.php
index 6e7d01a..c83709c 100644
--- a/includes/core/class-pipeline-status.php
+++ b/includes/core/class-pipeline-status.php
@@ -103,4 +103,41 @@ class PRAutoBlogger_Pipeline_Status {
'pipeline'
);
}
+
+ /**
+ * Write an initial "running" status before the first tick fires.
+ * Mirrors the shape on_ajax_generate_now() already writes so the poller
+ * sees a consistent "running" state whether triggered from board or settings page.
+ *
+ * @return void
+ */
+ public static function write_initial(): void {
+ set_transient(
+ self::KEY,
+ array(
+ 'status' => 'running',
+ 'stage' => __( 'Starting generation…', 'prautoblogger' ),
+ 'started' => time(),
+ 'last_updated' => time(),
+ ),
+ self::TTL
+ );
+ }
+
+ /**
+ * Write a terminal "error" status.
+ *
+ * @param string $message Operator-facing error description.
+ * @return void
+ */
+ public static function write_error( string $message ): void {
+ set_transient(
+ self::KEY,
+ array(
+ 'status' => 'error',
+ 'message' => $message,
+ ),
+ self::TTL
+ );
+ }
}
diff --git a/prautoblogger.php b/prautoblogger.php
index 7d297c1..97ea22f 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.20.1
+ * Version: 0.21.0
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: PeptideRepo
@@ -35,10 +35,14 @@ if ( ! defined( 'ABSPATH' ) ) {
| Defined here so every file in the plugin can reference paths, versions,
| and limits without magic strings.
*/
-define( 'PRAUTOBLOGGER_VERSION', '0.20.1' );
-define( 'PRAUTOBLOGGER_DB_VERSION', '1.3.0' );
+define( 'PRAUTOBLOGGER_VERSION', '0.21.0' );
+define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' );
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
+// Default max cards shown per board column and per ideas-browser page.
+// Settings-backed (prautoblogger_board_column_limit, prautoblogger_ideas_per_page).
+define( 'PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT', 20 );
+define( 'PRAUTOBLOGGER_DEFAULT_IDEAS_PER_PAGE', 30 );
// v0.18.1: ceiling on "thinking" tokens per reasoning-enabled LLM call. The
// request's completion budget (max_tokens) is RAISED by this amount so
// reasoning can never consume the visible-content budget and return an empty
diff --git a/scripts/check-constants.php b/scripts/check-constants.php
index 6b0b9b6..2d36670 100644
--- a/scripts/check-constants.php
+++ b/scripts/check-constants.php
@@ -9,9 +9,9 @@
* kept a fallback copy, masking the gap until prod boot.
*
* Usage (from repo root):
- * php scripts/check-constants.php [--verbose]
+ * php scripts/check-constants.php [--verbose] [--self-test]
*
- * Exit 0 = all clear. Exit 1 = unguarded undefined constants found.
+ * Exit 0 = all clear. Exit 1 = unguarded undefined constants found. Exit 2 = --self-test failed.
*
* Include/exclude summary (relative to repo root):
* Defined-set source prautoblogger.php only
@@ -29,7 +29,7 @@
declare(strict_types=1);
// How many lines above the bare use to look for a defined() guard.
-const CONSTANTS_GUARD_WINDOW = 4;
+const CONSTANTS_GUARD_WINDOW = 4; // 4 covers if/elseif blocks and multi-line && chains (wider risks false-positives).
// Source of truth for define()d constants -- test-bootstrap fallbacks excluded.
const CONSTANTS_BOOTSTRAP_FILE = 'prautoblogger.php';
@@ -45,7 +45,8 @@ const CONSTANTS_SCAN_ROOTS = [
'templates',
];
-$verbose = in_array( '--verbose', $argv ?? [], true );
+$verbose = in_array( '--verbose', $argv ?? [], true );
+$self_test = in_array( '--self-test', $argv ?? [], true );
// -------------------------------------------------------------------------
// 1. Build the defined set from prautoblogger.php only.
@@ -208,5 +209,44 @@ if ( $errors !== [] ) {
exit( 1 );
}
+// -------------------------------------------------------------------------
+// 5. Self-test mode: synthesize a missing constant and assert non-zero exit.
+// -------------------------------------------------------------------------
+
+if ( $self_test ) {
+ // Temporarily inject a fake reference that must NOT be in prautoblogger.php.
+ // The scan above will flag PRAUTOBLOGGER_SELFTEST_SENTINEL as unguarded.
+ // We verify this script would have exited non-zero in that scenario.
+ $sentinel = 'PRAUTOBLOGGER_SELFTEST_SENTINEL';
+ $bootstrap_src = (string) file_get_contents( CONSTANTS_BOOTSTRAP_FILE );
+ if ( false !== strpos( $bootstrap_src, $sentinel ) ) {
+ fwrite( STDERR, "SELF-TEST FAIL: sentinel constant already defined in prautoblogger.php -- pick a unique name.\n" );
+ exit( 2 );
+ }
+
+ // If $errors is empty at this point, the real codebase is clean (good).
+ // Now simulate an unguarded reference by re-running the scan on a temp file.
+ $tmp_file = tempnam( sys_get_temp_dir(), 'prab_selftest_' ) . '.php';
+ file_put_contents( $tmp_file, " $raw_line ) {
+ if ( preg_match_all( '/PRAUTOBLOGGER_[A-Z0-9_]+/', $raw_line, $m ) ) {
+ foreach ( $m[0] as $n ) {
+ if ( ! isset( $defined_set[ $n ] ) ) {
+ $tmp_errors[] = $n;
+ }
+ }
+ }
+ }
+ @unlink( $tmp_file );
+ if ( ! in_array( $sentinel, $tmp_errors, true ) ) {
+ fwrite( STDERR, "SELF-TEST FAIL: scanner did not detect unguarded sentinel constant.\n" );
+ exit( 2 );
+ }
+ echo "SELF-TEST OK -- scanner correctly detected the synthetic missing constant.\n";
+ exit( 0 );
+}
+
echo "OK -- All PRAUTOBLOGGER_* constants are production-defined or defined()-guarded.\n";
exit( 0 );
diff --git a/templates/admin/board-page.php b/templates/admin/board-page.php
index 318db30..8f3db0f 100644
--- a/templates/admin/board-page.php
+++ b/templates/admin/board-page.php
@@ -98,7 +98,16 @@ function prab_render_board_card( array $card, string $column_key ): void {
+
+
+
+
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index c12c42f..75a209d 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -209,6 +209,11 @@ if ( ! class_exists( 'WP_Post' ) ) {
// Plugin URL and version constants used by admin enqueue methods.
// Defined here so all tests that load admin classes can stub WP enqueue calls
// without hitting "Undefined constant" errors.
+if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT' ) ) {
+ define( 'PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT', 20 );
+}
+
+// Plugin URL and version constants used by admin enqueue methods.
if ( ! defined( 'PRAUTOBLOGGER_PLUGIN_URL' ) ) {
define( 'PRAUTOBLOGGER_PLUGIN_URL', 'https://test.example.com/wp-content/plugins/prautoblogger/' );
}
diff --git a/tests/unit/Admin/BoardGenLogQueryTest.php b/tests/unit/Admin/BoardGenLogQueryTest.php
new file mode 100644
index 0000000..87144d9
--- /dev/null
+++ b/tests/unit/Admin/BoardGenLogQueryTest.php
@@ -0,0 +1,239 @@
+= and LIMIT . These tests assert the correct
+ * binding order (string first, int second) so any future regression is caught
+ * before CI.
+ *
+ * @package PRAutoBlogger\Tests\Admin
+ */
+
+namespace PRAutoBlogger\Tests\Admin;
+
+use PRAutoBlogger\Tests\BaseTestCase;
+use Brain\Monkey\Functions;
+
+class BoardGenLogQueryTest extends BaseTestCase {
+
+ /**
+ * Captured prepare() calls: each entry is ['sql' => string, 'args' => array].
+ *
+ * @var array
+ */
+ private array $prepare_calls = array();
+
+ /**
+ * Build a fresh mock $wpdb whose get_results() always returns $rows.
+ *
+ * @param array $rows Rows to return from get_results().
+ * @return \PHPUnit\Framework\MockObject\MockObject
+ */
+ private function make_wpdb( array $rows = array() ): object {
+ $wpdb = $this->create_mock_wpdb();
+ $wpdb->prefix = 'wp_';
+ $GLOBALS['wpdb'] = $wpdb;
+ $self = $this;
+
+ $wpdb->method( 'prepare' )->willReturnCallback(
+ static function ( string $sql, ...$args ) use ( $self ) {
+ if ( 1 === count( $args ) && is_array( $args[0] ) ) {
+ $args = $args[0];
+ }
+ $self->prepare_calls[] = array( 'sql' => $sql, 'args' => $args );
+ return 'PREPARED_SQL';
+ }
+ );
+
+ $wpdb->method( 'get_results' )->willReturn( $rows );
+ return $wpdb;
+ }
+
+ protected function setUp(): void {
+ parent::setUp();
+ $this->prepare_calls = array();
+
+ Functions\when( 'get_transient' )->justReturn( false );
+ Functions\when( 'admin_url' )->alias(
+ function ( string $path = '' ) {
+ return 'https://test.example.com/wp-admin/' . ltrim( $path, '/' );
+ }
+ );
+ Functions\when( 'get_option' )->alias(
+ function ( string $name, $default = false ) {
+ if ( 'prautoblogger_board_column_limit' === $name ) {
+ return 10;
+ }
+ return $default;
+ }
+ );
+ }
+
+ protected function tearDown(): void {
+ $this->prepare_calls = array();
+ unset( $GLOBALS['wpdb'] );
+ parent::tearDown();
+ }
+
+ // ── get_generating_cards() binding order ────────────────────────────────
+
+ /**
+ * get_generating_cards() must pass $since (string) as the FIRST bound arg
+ * and the integer limit as the SECOND — matching placeholder order %s … %d.
+ *
+ * This would have caught the reversed-order bug (limit int passed first,
+ * datetime string second) that silently bound created_at >= .
+ */
+ public function test_generating_cards_prepare_arg_order(): void {
+ $this->make_wpdb();
+ $query = new \PRAutoBlogger_Board_Gen_Log_Query();
+ $query->get_generating_cards();
+
+ // Find the prepare() call containing LIMIT %d (the gen-log secondary query).
+ $gen_call = null;
+ foreach ( $this->prepare_calls as $call ) {
+ if (
+ str_contains( $call['sql'], 'LIMIT %d' ) &&
+ str_contains( $call['sql'], 'post_id IS NULL' ) &&
+ ! str_contains( $call['sql'], "response_status = 'error'" )
+ ) {
+ $gen_call = $call;
+ break;
+ }
+ }
+
+ $this->assertNotNull( $gen_call, 'get_generating_cards() must call $wpdb->prepare() with LIMIT %d' );
+ $this->assertCount( 2, $gen_call['args'], 'prepare() must receive exactly 2 bound args' );
+
+ // First arg must be a string (the $since datetime).
+ $this->assertIsString( $gen_call['args'][0], 'First arg to prepare() must be the $since string (for %s)' );
+ // Second arg must be an integer (the column limit).
+ $this->assertIsInt( $gen_call['args'][1], 'Second arg to prepare() must be the integer limit (for %d)' );
+ }
+
+ /**
+ * get_generating_cards() must return an empty array (not error) when DB returns nothing.
+ */
+ public function test_generating_cards_returns_empty_array_on_no_results(): void {
+ $this->make_wpdb();
+ $query = new \PRAutoBlogger_Board_Gen_Log_Query();
+ $cards = $query->get_generating_cards();
+ $this->assertIsArray( $cards );
+ }
+
+ /**
+ * When the status transient signals a running job, get_generating_cards()
+ * returns a card from the transient (no DB call needed for that card).
+ */
+ public function test_generating_cards_returns_transient_card_when_running(): void {
+ $this->make_wpdb();
+
+ Functions\when( 'get_transient' )->alias(
+ function ( $key ) {
+ if ( 'prautoblogger_generation_status' === $key ) {
+ return array(
+ 'status' => 'running',
+ 'stage' => 'Drafting…',
+ 'started' => time() - 30,
+ );
+ }
+ return false;
+ }
+ );
+
+ $query = new \PRAutoBlogger_Board_Gen_Log_Query();
+ $cards = $query->get_generating_cards();
+
+ $this->assertNotEmpty( $cards, 'Transient running state must produce at least one card' );
+ $this->assertSame( '', $cards[0]['run_id'], 'Transient card has empty run_id' );
+ $this->assertSame( 'logs', $cards[0]['click_action'] );
+ }
+
+ // ── get_failed_cards() binding order ────────────────────────────────────
+
+ /**
+ * get_failed_cards() must pass $since (string) as the FIRST bound arg
+ * and the integer limit as the SECOND — matching placeholder order %s … %d.
+ */
+ public function test_failed_cards_prepare_arg_order(): void {
+ $this->make_wpdb();
+ $query = new \PRAutoBlogger_Board_Gen_Log_Query();
+ $query->get_failed_cards();
+
+ // Find the prepare() call for the failed query.
+ $fail_call = null;
+ foreach ( $this->prepare_calls as $call ) {
+ if ( str_contains( $call['sql'], "response_status = 'error'" ) ) {
+ $fail_call = $call;
+ break;
+ }
+ }
+
+ $this->assertNotNull( $fail_call, 'get_failed_cards() must call $wpdb->prepare() with response_status filter' );
+ $this->assertCount( 2, $fail_call['args'], 'prepare() must receive exactly 2 bound args' );
+
+ // First arg must be a string (the $since datetime).
+ $this->assertIsString( $fail_call['args'][0], 'First arg to prepare() must be the $since string (for %s)' );
+ // Second arg must be an integer (the column limit).
+ $this->assertIsInt( $fail_call['args'][1], 'Second arg to prepare() must be the integer limit (for %d)' );
+ }
+
+ /**
+ * get_failed_cards() returns the correct card shape from a seeded result row.
+ */
+ public function test_failed_cards_returns_correct_shape(): void {
+ // Use a fresh wpdb with non-empty get_results rows.
+ $this->make_wpdb(
+ array(
+ array(
+ 'run_id' => 'fail-run-abc',
+ 'started_at' => gmdate( 'Y-m-d H:i:s', time() - 3600 ),
+ 'last_error' => 'Rate limit exceeded',
+ 'last_stage' => 'draft',
+ 'call_count' => '3',
+ ),
+ )
+ );
+
+ $query = new \PRAutoBlogger_Board_Gen_Log_Query();
+ $cards = $query->get_failed_cards();
+
+ $this->assertCount( 1, $cards );
+ $this->assertSame( 'fail-run-abc', $cards[0]['run_id'] );
+ $this->assertStringContainsString( 'Rate limit', $cards[0]['error_message'] );
+ $this->assertSame( 'logs', $cards[0]['click_action'] );
+ $this->assertArrayHasKey( 'started_at', $cards[0] );
+ }
+
+ /**
+ * Column-limit option is honoured: limit 7 → second arg to prepare() is 7.
+ */
+ public function test_column_limit_option_is_respected(): void {
+ Functions\when( 'get_option' )->alias(
+ function ( string $name, $default = false ) {
+ if ( 'prautoblogger_board_column_limit' === $name ) {
+ return 7;
+ }
+ return $default;
+ }
+ );
+
+ $this->make_wpdb();
+ $query = new \PRAutoBlogger_Board_Gen_Log_Query();
+ $query->get_generating_cards();
+
+ $gen_call = null;
+ foreach ( $this->prepare_calls as $call ) {
+ if ( str_contains( $call['sql'], 'LIMIT %d' ) && ! str_contains( $call['sql'], "response_status = 'error'" ) ) {
+ $gen_call = $call;
+ break;
+ }
+ }
+
+ $this->assertNotNull( $gen_call );
+ $this->assertSame( 7, $gen_call['args'][1], 'Limit arg must match the option value' );
+ }
+}
diff --git a/tests/unit/Admin/BoardHumanModifiedFlagTest.php b/tests/unit/Admin/BoardHumanModifiedFlagTest.php
index 2b8ebe0..abc998b 100644
--- a/tests/unit/Admin/BoardHumanModifiedFlagTest.php
+++ b/tests/unit/Admin/BoardHumanModifiedFlagTest.php
@@ -54,6 +54,15 @@ class BoardHumanModifiedFlagTest extends BaseTestCase {
);
Functions\when( 'get_edit_post_link' )->justReturn( 'https://example.com/edit' );
Functions\when( 'admin_url' )->alias( static fn( $p = '' ) => 'https://example.com/wp-admin/' . $p );
+ Functions\when( 'get_option' )->alias(
+ function ( string $name, $default = false ) {
+ $map = array(
+ 'prautoblogger_board_column_limit' => PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT,
+ 'prautoblogger_board_published_window_days' => 7,
+ );
+ return $map[ $name ] ?? $default;
+ }
+ );
\PRAutoBlogger_Run_Stage_State::flush_cache();
}
diff --git a/tests/unit/Core/GenerationCheckpointRunnerTest.php b/tests/unit/Core/GenerationCheckpointRunnerTest.php
new file mode 100644
index 0000000..2a8e104
--- /dev/null
+++ b/tests/unit/Core/GenerationCheckpointRunnerTest.php
@@ -0,0 +1,230 @@
+ Option writes captured from update_option(). */
+ private array $options = array();
+
+ /** @var array Transient writes captured from set_transient(). */
+ private array $transients = array();
+
+ protected function setUp(): void {
+ parent::setUp();
+ $this->scheduled = array();
+ $this->options = array();
+ $this->transients = array();
+
+ 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' )->justReturn( true );
+ Functions\when( 'wp_remote_post' )->justReturn( array() );
+ Functions\when( 'site_url' )->justReturn( 'https://example.com' );
+
+ Functions\when( 'set_transient' )->alias(
+ function ( $key, $value, $ttl = 0 ) {
+ $this->transients[] = array( 'key' => $key, 'value' => (array) $value );
+ return 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( 'test-run-uuid-001' );
+ // Monthly budget 0 = no limit.
+ $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_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 = 'Test 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(),
+ );
+ }
+
+ // ── kick_off() ────────────────────────────────────────────────────────
+
+ /**
+ * kick_off() schedules exactly the ORCHESTRATE cron action and writes
+ * an initial 'running' status transient. The pipeline is NOT called.
+ */
+ public function test_kick_off_schedules_orchestrate_cron_and_writes_initial_transient(): void {
+ \PRAutoBlogger_Generation_Checkpoint_Runner::kick_off();
+
+ $this->assertContains(
+ \PRAutoBlogger_Generation_Checkpoint_Runner::ORCHESTRATE_ACTION,
+ $this->scheduled,
+ 'kick_off() must schedule ORCHESTRATE_ACTION'
+ );
+
+ $found_running_transient = false;
+ foreach ( $this->transients as $t ) {
+ if ( isset( $t['value']['status'] ) && 'running' === $t['value']['status'] ) {
+ $found_running_transient = true;
+ break;
+ }
+ }
+ $this->assertTrue( $found_running_transient, 'kick_off() must write a running status transient' );
+ }
+
+ // ── on_generate_tick() ────────────────────────────────────────────────
+
+ /**
+ * An empty queue causes generate tick to finalize without scheduling
+ * another GENERATE action (no Article_Worker call).
+ */
+ public function test_generate_tick_finalizes_when_queue_is_empty(): void {
+ // No queue option => get_option returns false (default from setUp alias).
+ // wire_wpdb_for_lock() provides $wpdb so Helpers::finalize() ->
+ // Generation_Lock::release() can run without "Call to member on null".
+ $this->wire_wpdb_for_lock();
+ \PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
+
+ $this->assertNotContains(
+ \PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
+ $this->scheduled,
+ 'Empty queue must not reschedule GENERATE'
+ );
+ }
+
+ /**
+ * A halted run causes generate tick to abort: queue is cleaned up and
+ * GENERATE is not rescheduled (Article_Worker would not be called again).
+ */
+ public function test_generate_tick_aborts_on_halted_run_without_rescheduling(): void {
+ $run_id = 'halted-run-uuid';
+ $this->options['prautoblogger_article_queue'] = array(
+ 'run_id' => $run_id,
+ 'ideas' => array( $this->make_idea() ),
+ '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' => 'halted', 'ceiling_usd' => '0' ) );
+ $wpdb->method( 'query' )->willReturn( 1 );
+ $wpdb->method( 'get_var' )->willReturn( null );
+
+ \PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
+
+ $this->assertArrayNotHasKey(
+ 'prautoblogger_article_queue',
+ $this->options,
+ 'Halted run must trigger queue cleanup'
+ );
+ $this->assertNotContains(
+ \PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
+ $this->scheduled,
+ 'Halted run must not reschedule GENERATE'
+ );
+ }
+
+ /**
+ * With 2 ideas in queue, first generate tick pops one, reschedules GENERATE,
+ * and leaves 1 idea remaining in the persisted queue.
+ */
+ public function test_generate_tick_pops_one_idea_and_reschedules_when_more_remain(): void {
+ $run_id = 'multi-idea-run-uuid';
+ $this->options['prautoblogger_article_queue'] = array(
+ 'run_id' => $run_id,
+ 'ideas' => array( $this->make_idea( 'Idea A' ), $this->make_idea( 'Idea B' ) ),
+ 'results' => array( 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ),
+ );
+ $this->options['prautoblogger_checkpoint_run_id'] = $run_id;
+
+ $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 );
+
+ // Provide stub Article_Worker so CI doesn't need real LLM dependencies.
+ 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]; }
+ }' );
+ }
+ if ( ! class_exists( 'PRAutoBlogger_Post_Assembler', false ) ) {
+ // phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired
+ eval( 'class PRAutoBlogger_Post_Assembler { public static function amortize_research_costs($r){} }' );
+ }
+
+ \PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
+
+ $this->assertContains(
+ \PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
+ $this->scheduled,
+ 'More ideas remaining: GENERATE must be rescheduled'
+ );
+ if ( isset( $this->options['prautoblogger_article_queue'] ) ) {
+ $remaining = $this->options['prautoblogger_article_queue']['ideas'] ?? array();
+ $this->assertCount( 1, $remaining, 'Queue must have exactly 1 idea left after first tick' );
+ }
+ }
+}
diff --git a/uninstall.php b/uninstall.php
index 784ae84..ddfeed1 100644
--- a/uninstall.php
+++ b/uninstall.php
@@ -97,6 +97,9 @@ $hooks = array(
'prautoblogger_opik_dispatch',
'prautoblogger_rerun_stage_replay',
'prautoblogger_rerun_from_stage',
+ // v0.21.0 (M4): chained-cron checkpoint ticks.
+ 'prautoblogger_gen_orchestrate',
+ 'prautoblogger_gen_tick',
);
foreach ( $hooks as $hook ) {