diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a8d48a5..f853c39 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -126,3 +126,7 @@ jobs:
# by defined() at the reference site. Prevents the v0.19.0 incident
# class (constant deleted from bootstrap, test fallback masks the gap).
run: php scripts/check-constants.php
+ - name: Self-test check-constants guard (v0.21.0)
+ # Verifies the guard script itself catches a synthetic missing constant.
+ # Exit 2 = self-test failure (scanner is broken). Exit 0 = scanner works.
+ run: php scripts/check-constants.php --self-test
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 581ad5f..aba3fe4 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1084,3 +1084,65 @@ PRAutoBlogger and Peptide News both call OpenRouter and may share a single API k
**Important:** If your OpenRouter account has a global spending limit, set each plugin's budget to less than half the total. PRAutoBlogger will hard-stop when its budget is exhausted, but Peptide News currently has no budget enforcement — a spike in news fetches could consume shared quota.
**Future improvement:** A shared `wp_options` key (e.g., `ecosystem_monthly_llm_budget`) that both plugins read, with each plugin reserving its allocation on startup. This requires coordination at the ecosystem level and is tracked as a medium-term goal.
+
+## §25 Chained-Cron Checkpoint Generation Path (v0.21.0, M4)
+
+### Problem
+
+Hostinger shared hosting kills PHP background processes after ~120 s. The former
+`on_manual_generation()` path in `Executor` ran the complete orchestrate +
+generate pipeline in a single PHP process — meaning a host kill mid-run left the
+generation lock held, the run row stuck in `running`, and partially-generated
+articles orphaned with no status broadcast.
+
+### Solution: Two-Tick Checkpoint Machine
+
+`PRAutoBlogger_Generation_Checkpoint_Runner` (v0.21.0) splits the pipeline into
+independent cron ticks, each of which completes well within Hostinger's limit:
+
+| Tick | WP-Cron action | What happens |
+|------|---------------|-------------|
+| 1 | `prautoblogger_gen_orchestrate` | Collect sources → analyze → score ideas. Serialize ideas to `prautoblogger_article_queue` option. Schedule Tick 2. |
+| 2..N | `prautoblogger_gen_tick` | Pop ONE idea from queue. Call `Article_Worker::generate()`. Persist updated queue. Reschedule next Tick, or finalize. |
+
+If the host kills any tick:
+- The idea queue is persisted (option survived the crash).
+- The next CRON run picks up naturally (no orphan).
+- `Run_Reaper` reclaims truly stuck runs via its daily sweep.
+
+### Entry Points
+
+| 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()` |
+| Old cron hook (retained) | `prautoblogger_manual_generation` → `Executor::on_manual_generation()` → `kick_off()` |
+
+### SSH Workaround Retirement
+
+The former `wp eval 'do_action("prautoblogger_manual_generation")'` workaround
+fired `on_manual_generation()` synchronously in the CLI PHP process, bypassing:
+- Nonce/capability checks (running as CLI user, not WP user)
+- Cron-based loopback spawning
+- Per-process time limits
+
+This path is retired. `Executor::on_manual_generation()` now delegates immediately
+to `Generation_Checkpoint_Runner::on_orchestrate_tick()` (the Tick 1 handler),
+which acquires the lock and runs orchestration within its own process. The
+`wp prautoblogger generate` WP-CLI command provides the supported CLI path.
+
+### Key State
+
+| Store | Key | Contents |
+|-------|-----|---------|
+| DB option | `prautoblogger_article_queue` | `{run_id, ideas[], results{}}` — persisted across ticks |
+| DB option | `prautoblogger_checkpoint_run_id` | Current run UUID (for finalize/cleanup paths) |
+| Transient | `prautoblogger_generation_status` | Status broadcast (polling by board/dossier JS) |
+| DB | `prautoblogger_runs` | Per-run ledger with status, cost ceiling |
+
+### Budget / Halt Contract
+
+`Cost_Tracker::is_budget_exceeded()` is checked at the START of Tick 1 and each
+generate tick. A halted run (`Run_State::get_status() = 'halted'`) causes the
+generate tick to abort without calling `Article_Worker`, clean up the queue, and
+release the lock — same guarantee as `Pipeline_Runner`'s queued-article path.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2dfa494..720f3fe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,61 @@ and this project uses [Semantic Versioning](https://semver.org/).
### Internal
- 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.21.0] - 2026-06-14
+
+### Added
+- **Board âNew Articleâ button (Phase 2 admin M4)**. Manual generation is now
+ accessible from the kanban board (not just the Settings page). The button fires
+ the existing `prautoblogger_generate_now` AJAX action; the board status area
+ shows generation progress via the existing transient poller. Requires
+ `manage_options` capability.
+- **Chained-cron checkpoint machine** (`PRAutoBlogger_Generation_Checkpoint_Runner`).
+ Replaces the monolithic `on_manual_generation()` synchronous path with a
+ two-tick checkpoint machine so Hostingerâs PHP-process kill cannot abort a
+ mid-pipeline run:
+ - Tickâ1 (`prautoblogger_gen_orchestrate`): collect â analyze â score ideas,
+ persist to `prautoblogger_article_queue` option, schedule Tick 2.
+ - Tickâ2â¦N (`prautoblogger_gen_tick`): pop ONE idea, generate one article,
+ reschedule or finalize. Persists consumed queue BEFORE generating to prevent
+ orphan-recovery duplication on restart.
+ - Budget / halt: `Cost_Tracker::is_budget_exceeded()` checked per tick;
+ a halted run aborts all remaining ticks without calling `Article_Worker`.
+- **WP-CLI `wp prautoblogger generate` command**. Routes through the same
+ chained-cron checkpoints as the board button. Replaces the retired SSH-only
+ `wp eval 'do_action("prautoblogger_manual_generation")'` workaround.
+- **Settings-backed board pagination** (perf pass). All hardcoded LIMIT / `posts_per_page`
+ values in board queries replaced with two new settings:
+ - `prautoblogger_board_column_limit` (default 20, constant `PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT`)
+ - `prautoblogger_ideas_per_page` (default 30, constant `PRAUTOBLOGGER_DEFAULT_IDEAS_PER_PAGE`)
+- **DB indexes** (via `PRAUTOBLOGGER_DB_VERSION` â `1.4.0`):
+ - `generation_log`: `KEY run_id_post_id`, `KEY created_status` (board query + failed-cards).
+ - `analysis_results`: `KEY analyzed_type` (ideas-browser pagination + type filter).
+ - `runs`: `KEY started_at` (articles-list pagination `ORDER BY started_at DESC LIMIT n OFFSET m`).
+- **Query helper split**: `PRAutoBlogger_Ideas_Browser_Query` (new `class-ideas-browser-query.php`)
+ carries static query helpers extracted from `class-ideas-browser.php` to enforce the
+ 300-line rule (CONVENTIONS §1).
+- **Guard P2s** (`scripts/check-constants.php`):
+ - Added `--self-test` mode: synthesizes a missing constant and asserts the scanner catches it.
+ - Added inline comment documenting `CONSTANTS_GUARD_WINDOW = 4`.
+ - Wired `--self-test` as a CI step after the main constants check.
+- **PHPUnit test** `tests/unit/Core/GenerationCheckpointRunnerTest.php`: tick-by-tick
+ state-machine proof (kick_off schedules only, empty queue finalizes, halted run aborts,
+ multi-idea queue pops one and reschedules).
+- **Pipeline_Status** now exports `write_initial()` and `write_error()` for the
+ checkpoint runner to set the status transient before the first cron tick fires.
+
+### Changed
+- `Executor::on_manual_generation()` now delegates to
+ `Generation_Checkpoint_Runner::on_orchestrate_tick()` instead of running the full
+ pipeline synchronously. The method body is 1 line; the synchronous path is retired.
+- `Pipeline_Runner::orchestrate_only()` added as a public wrapper exposing the private
+ `orchestrate()` method for use by `Generation_Checkpoint_Runner`.
+
+### Removed
+- **SSH-only workaround retired**: `wp eval 'do_action("prautoblogger_manual_generation")'` no
+ longer fires the pipeline synchronously in one PHP process. Use `wp prautoblogger generate`
+ instead (routes through the chained-cron checkpoint machine). See ARCHITECTURE.md §25.
+
## [0.20.1] - 2026-06-12
### Fixed
diff --git a/INFORMATION-ARCHITECTURE.md b/INFORMATION-ARCHITECTURE.md
new file mode 100644
index 0000000..d8f9fc6
--- /dev/null
+++ b/INFORMATION-ARCHITECTURE.md
@@ -0,0 +1,84 @@
+# Information Architecture — PRAutoBlogger
+
+Auto-generated index of all admin surfaces, data flows, and integrations.
+Maintained per DoD v1.1.0 (same-PR update rule).
+
+---
+
+## Admin Pages
+
+| Slug | Class | Template | Purpose |
+|------|-------|----------|---------|
+| `prautoblogger-settings` | `PRAutoBlogger_Admin_Page` | `admin-page.php` | Main settings (schedule, models, budget, board limits) |
+| `prautoblogger-board` | `PRAutoBlogger_Board_Page` | `board-page.php` | Kanban board: Generating / Failed / In Review / Published columns. New Article button (v0.21.0). |
+| `prautoblogger-articles` | `PRAutoBlogger_Articles_Page` | `articles-page.php` | Paginated list of all articles/runs |
+| `prautoblogger-dossier` | `PRAutoBlogger_Dossier_Page` | `dossier-page.php` | Per-article generation log + stage edit/re-run (M3) |
+| `prautoblogger-ideas` | `PRAutoBlogger_Ideas_Browser` | `ideas-browser.php` | Analysis results browser with per-idea generation |
+| `prautoblogger-activity` | `PRAutoBlogger_Activity_Page` | `activity-page.php` | Cost + generation activity log |
+
+---
+
+## Generation Entry Points (v0.21.0)
+
+```
+Board "New Article" button
+ └─ AJAX prautoblogger_generate_now
+ └─ Generation_Status_Poller::on_ajax_generate_now()
+ └─ schedules prautoblogger_manual_generation cron
+ └─ Executor::on_manual_generation()
+ └─ Generation_Checkpoint_Runner::on_orchestrate_tick() ← Tick 1
+ └─ schedules prautoblogger_gen_tick
+ └─ Generation_Checkpoint_Runner::on_generate_tick() ← Tick 2..N
+
+WP-CLI: wp prautoblogger generate
+ └─ WP_CLI_Commands::generate_command()
+ └─ Generation_Checkpoint_Runner::kick_off()
+ └─ schedules prautoblogger_gen_orchestrate
+ └─ Generation_Checkpoint_Runner::on_orchestrate_tick() ← Tick 1
+
+Daily auto-generation cron
+ └─ prautoblogger_daily_generation
+ └─ Executor::on_cron_run()
+ └─ Pipeline_Runner::run() (legacy synchronous path — runs in a single PHP process)
+```
+
+---
+
+## Database Tables
+
+| Table | DB Version | Purpose |
+|-------|-----------|---------|
+| `prautoblogger_sources` | 1.0.0 | Collected Reddit/RSS source content |
+| `prautoblogger_analysis_results` | 1.0.0 | Scored article ideas |
+| `prautoblogger_generation_log` | 1.0.0 | Per-LLM-call log (cost, tokens, status) |
+| `prautoblogger_runs` | 1.0.0 | Per-run ledger (status, ceiling_usd) |
+| `prautoblogger_run_stages` | 1.2.0 | Per-stage state for M3 re-run |
+| `prautoblogger_stage_inputs` | 1.3.0 | Immutable input versions for stage edits (M3) |
+| `prautoblogger_run_decisions` | 1.2.0 | Editorial decisions per run |
+
+DB version history: 1.0→1.1 (schema normalisation), 1.1→1.2 (M2 dossier), 1.2→1.3 (M3 stage inputs), 1.3→1.4 (M4 indexes).
+
+---
+
+## Key Options
+
+| Option | Type | Default | Purpose |
+|--------|------|---------|---------|
+| `prautoblogger_board_column_limit` | int | 20 | Max cards per board column |
+| `prautoblogger_ideas_per_page` | int | 30 | Ideas browser page size |
+| `prautoblogger_article_queue` | array | — | Checkpoint queue (run_id + ideas + results) |
+| `prautoblogger_checkpoint_run_id` | string | — | Current run UUID for Tick 2..N finalize path |
+| `prautoblogger_db_version` | string | — | Installed schema version (triggers dbDelta on mismatch) |
+
+---
+
+## WP-CLI Commands
+
+| Command | Since | Description |
+|---------|-------|-------------|
+| `wp prautoblogger generate` | 0.21.0 | Queue article generation via chained-cron checkpoints |
+| `wp prautoblogger opik:eval` | 0.12.0 | Trigger Opik eval run |
+
+---
+
+*Last updated: v0.21.0 (2026-06-14)*
diff --git a/assets/js/board-generate.js b/assets/js/board-generate.js
new file mode 100644
index 0000000..2cd6833
--- /dev/null
+++ b/assets/js/board-generate.js
@@ -0,0 +1,53 @@
+/**
+ * PRAutoBlogger Board -- "New Article" generate button handler (v0.21.0, M4).
+ *
+ * Loaded on the board page alongside board.js. Depends on the prabBoard config
+ * object and jQuery, both already present when board.js loads.
+ *
+ * @see assets/js/board.js -- Board poller (loads first).
+ * @see includes/admin/class-board-page.php -- Enqueues + localizes prabBoard.
+ */
+( function ( $ ) {
+ 'use strict';
+
+ var cfg = window.prabBoard || {};
+
+ /**
+ * Handle click on the "New Article" button. Fires the existing generate_now
+ * AJAX action and triggers board backoff reset so the new card appears fast.
+ */
+ function onBoardGenerateNow() {
+ var $btn = $( '#prab-board-generate-now' );
+ if ( ! $btn.length || $btn.prop( 'disabled' ) ) {
+ return;
+ }
+ $btn.prop( 'disabled', true ).text( cfg.strings && cfg.strings.generatingBtn || 'Generating\u2026' );
+ var $status = $( '#prab-board-gen-status' );
+ $status.text( '' ).hide();
+
+ $.ajax( {
+ url: cfg.ajaxUrl,
+ method: 'POST',
+ data: { action: 'prautoblogger_generate_now', nonce: cfg.generateNonce },
+ success: function ( response ) {
+ if ( response && response.success ) {
+ $status.text( cfg.strings && cfg.strings.genStarted || 'Generation started.' ).show();
+ // Notify board poller to reset backoff (run is now active).
+ $( document ).trigger( 'prab:generation-started' );
+ } else {
+ $status.text( cfg.strings && cfg.strings.genError || 'Generation failed.' ).show();
+ $btn.prop( 'disabled', false ).text( cfg.strings && cfg.strings.newArticle || 'New Article' );
+ }
+ },
+ error: function () {
+ $status.text( cfg.strings && cfg.strings.genError || 'Generation failed.' ).show();
+ $btn.prop( 'disabled', false ).text( cfg.strings && cfg.strings.newArticle || 'New Article' );
+ }
+ } );
+ }
+
+ $( function () {
+ $( document ).on( 'click', '#prab-board-generate-now', onBoardGenerateNow );
+ } );
+
+}( jQuery ) );
diff --git a/assets/js/board.js b/assets/js/board.js
index a58f31a..f103e93 100644
--- a/assets/js/board.js
+++ b/assets/js/board.js
@@ -164,6 +164,11 @@
renderColumn( 'in_review', snapshot.in_review || [] );
renderColumn( 'published', snapshot.published || [] );
renderColumn( 'failed', snapshot.failed || [] );
+ // Re-enable board Generate button once no active runs remain.
+ if ( ! snapshot.has_active_runs ) {
+ $( '#prab-board-generate-now' ).prop( 'disabled', false )
+ .text( ( cfg.strings && cfg.strings.newArticle ) || 'New Article' );
+ }
}
/**
@@ -225,22 +230,7 @@
} );
}
- /** Initialise the board poller on DOM ready. */
- $( function () {
- $board = $( '#prab-board' );
- if ( ! $board.length ) {
- return;
- }
- // Inject the error banner after the heading.
- $errorBanner = $( '
' + escHtml( cfg.strings.pollError || '' ) + '
' );
- $board.before( $errorBanner );
-
- // Check if any generating card is present in the initial server render.
- var hasActive = ( $board.find( '[data-column="generating"] .prab-board-card' ).length > 0 );
- backoffFactor = hasActive ? 1 : 1; // Always start at base on first load.
-
- schedulePoll( nextDelay( hasActive ) );
} );
}( jQuery ) );
diff --git a/includes/admin/class-admin-page.php b/includes/admin/class-admin-page.php
index e9eb6c0..a8bbfad 100644
--- a/includes/admin/class-admin-page.php
+++ b/includes/admin/class-admin-page.php
@@ -324,7 +324,7 @@ class PRAutoBlogger_Admin_Page {
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $items ) ) );
}
- $numeric = array( 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit', 'prautoblogger_board_poll_interval', 'prautoblogger_board_published_window_days' );
+ $numeric = array( 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit', 'prautoblogger_board_poll_interval', 'prautoblogger_board_published_window_days', 'prautoblogger_board_column_limit', 'prautoblogger_ideas_per_page' );
if ( in_array( $option_name, $numeric, true ) ) {
return is_numeric( $value ) ? $value : 0;
}
diff --git a/includes/admin/class-board-data-provider.php b/includes/admin/class-board-data-provider.php
index 1b4c7ec..17347ed 100644
--- a/includes/admin/class-board-data-provider.php
+++ b/includes/admin/class-board-data-provider.php
@@ -94,7 +94,7 @@ class PRAutoBlogger_Board_Data_Provider {
array(
'post_type' => 'post',
'post_status' => 'draft',
- 'posts_per_page' => 20,
+ 'posts_per_page' => max( 5, (int) get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) ),
'meta_query' => array(
array(
'key' => '_prautoblogger_generated',
@@ -144,7 +144,7 @@ class PRAutoBlogger_Board_Data_Provider {
array(
'post_type' => 'post',
'post_status' => 'publish',
- 'posts_per_page' => 20,
+ 'posts_per_page' => max( 5, (int) get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) ),
'date_query' => array(
array(
'after' => $date_after,
diff --git a/includes/admin/class-board-gen-log-query.php b/includes/admin/class-board-gen-log-query.php
index 4f9f278..7dc5a10 100644
--- a/includes/admin/class-board-gen-log-query.php
+++ b/includes/admin/class-board-gen-log-query.php
@@ -76,7 +76,8 @@ class PRAutoBlogger_Board_Gen_Log_Query {
AND post_id IS NULL
GROUP BY run_id
ORDER BY started_at DESC
- LIMIT 10",
+ LIMIT %d",
+ (int) max( 5, get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) )
$since
),
ARRAY_A
@@ -148,7 +149,8 @@ class PRAutoBlogger_Board_Gen_Log_Query {
AND post_id IS NULL
GROUP BY run_id
ORDER BY started_at DESC
- LIMIT 20",
+ LIMIT %d",
+ (int) max( 5, get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) )
$since
),
ARRAY_A
diff --git a/includes/admin/class-board-page.php b/includes/admin/class-board-page.php
index 4b1e3c0..28a557d 100644
--- a/includes/admin/class-board-page.php
+++ b/includes/admin/class-board-page.php
@@ -128,6 +128,13 @@ class PRAutoBlogger_Board_Page {
PRAUTOBLOGGER_VERSION,
true
);
+ wp_enqueue_script(
+ 'prautoblogger-board-generate',
+ PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/board-generate.js',
+ array( 'jquery', 'prautoblogger-board' ),
+ PRAUTOBLOGGER_VERSION,
+ true
+ );
$poll_interval = max( 3, (int) get_option( 'prautoblogger_board_poll_interval', 5 ) );
$published_days = max( 1, (int) get_option( 'prautoblogger_board_published_window_days', 7 ) );
@@ -138,6 +145,7 @@ class PRAutoBlogger_Board_Page {
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( self::NONCE_ACTION ),
+ 'generateNonce' => wp_create_nonce( 'prautoblogger_generate_now' ),
'pollInterval' => $poll_interval * 1000,
'publishedWindowDays' => $published_days,
'action' => self::AJAX_ACTION,
@@ -152,7 +160,11 @@ class PRAutoBlogger_Board_Page {
'edit' => __( 'Edit', 'prautoblogger' ),
'viewLog' => __( 'View Log', 'prautoblogger' ),
'pollError' => __( 'Board update failed — retrying.', 'prautoblogger' ),
- 'humanModified' => __( 'Human-modified', 'prautoblogger' ),
+ 'humanModified' => __( 'Human-modified', 'prautoblogger' ),
+ 'newArticle' => __( 'New Article', 'prautoblogger' ),
+ 'generatingBtn' => __( 'Generatingâ¦', 'prautoblogger' ),
+ 'genStarted' => __( 'Generation started. Board will update automatically.', 'prautoblogger' ),
+ 'genError' => __( 'Generation failed to start. Check the Activity Log.', 'prautoblogger' ),
),
)
);
diff --git a/includes/admin/class-ideas-browser-query.php b/includes/admin/class-ideas-browser-query.php
new file mode 100644
index 0000000..df4a046
--- /dev/null
+++ b/includes/admin/class-ideas-browser-query.php
@@ -0,0 +1,136 @@
+prefix . 'prautoblogger_analysis_results';
+ $where = array();
+ $params = array();
+
+ if ( '' !== $type ) {
+ $where[] = 'analysis_type = %s';
+ $params[] = $type;
+ }
+
+ $where_sql = empty( $where ) ? '' : 'WHERE ' . implode( ' AND ', $where );
+ $per_page = max( 5, (int) get_option( 'prautoblogger_ideas_per_page', self::PER_PAGE_DEFAULT ) );
+ $offset = ( $paged - 1 ) * $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', $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. */
+ public static 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;
+ }
+
+ /**
+ * Load an analysis result row and map it to Article_Idea constructor data.
+ *
+ * @param int $idea_id Analysis result row ID.
+ * @return array|null Article_Idea-compatible array, or null.
+ */
+ public 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(),
+ );
+ }
+
+ /** Find the most recent post created by a specific run_id. */
+ public static function find_post_by_run_id( string $run_id ): ?int {
+ global $wpdb;
+ $table = $wpdb->prefix . 'prautoblogger_generation_log';
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+ $post_id = $wpdb->get_var(
+ $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ "SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL LIMIT 1",
+ $run_id
+ )
+ );
+ return null !== $post_id ? (int) $post_id : null;
+ }
+
+ /** Update the per-idea status transient with a stage message. */
+ public static function update_idea_stage( int $idea_id, string $stage ): void {
+ $key = self::STATUS_PREFIX . $idea_id;
+ $current = get_transient( $key );
+ if ( is_array( $current ) ) {
+ $current['stage'] = $stage;
+ set_transient( $key, $current, self::STATUS_TTL );
+ }
+ }
+}
diff --git a/includes/admin/class-ideas-browser.php b/includes/admin/class-ideas-browser.php
index 9f1aa1d..e0e0f98 100644
--- a/includes/admin/class-ideas-browser.php
+++ b/includes/admin/class-ideas-browser.php
@@ -8,20 +8,14 @@ declare(strict_types=1);
*
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu`.
* Dependencies: WordPress $wpdb, Article_Worker, Cost_Tracker, Generation_Lock.
+ * Query helpers: class-ideas-browser-query.php (split for 300-line rule, CONVENTIONS §1).
*
* @see core/class-article-worker.php — Generates articles from ideas.
* @see templates/admin/ideas-browser.php — Renders the HTML.
+ * @see class-ideas-browser-query.php — Static query helpers.
*/
class PRAutoBlogger_Ideas_Browser {
- private const PER_PAGE = 30;
-
- /** Transient prefix for per-idea generation status. */
- private const STATUS_PREFIX = 'prab_idea_gen_';
-
- /** How long to keep per-idea status (seconds). */
- private const STATUS_TTL = 600;
-
/** Register the Ideas submenu page under PRAutoBlogger. */
public function on_register_menu(): void {
add_submenu_page(
@@ -43,11 +37,12 @@ class PRAutoBlogger_Ideas_Browser {
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
$type_filter = isset( $_GET['type'] ) ? sanitize_key( $_GET['type'] ) : '';
- $result = $this->query_ideas( $paged, $type_filter );
- $rows = $result['rows'];
- $total = $result['total'];
- $total_pages = (int) ceil( $total / self::PER_PAGE );
- $type_counts = $this->get_type_counts();
+ $result = PRAutoBlogger_Ideas_Browser_Query::query_ideas( $paged, $type_filter );
+ $rows = $result['rows'];
+ $total = $result['total'];
+ $per_page_view = max( 5, (int) get_option( 'prautoblogger_ideas_per_page', PRAutoBlogger_Ideas_Browser_Query::PER_PAGE_DEFAULT ) );
+ $total_pages = (int) ceil( $total / $per_page_view );
+ $type_counts = PRAutoBlogger_Ideas_Browser_Query::get_type_counts();
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/ideas-browser.php';
}
@@ -74,23 +69,26 @@ class PRAutoBlogger_Ideas_Browser {
return;
}
+ $status_prefix = PRAutoBlogger_Ideas_Browser_Query::STATUS_PREFIX;
+ $status_ttl = PRAutoBlogger_Ideas_Browser_Query::STATUS_TTL;
+
// Build Article_Idea from the analysis row.
- $idea_data = self::load_idea_data( $idea_id );
+ $idea_data = PRAutoBlogger_Ideas_Browser_Query::load_idea_data( $idea_id );
if ( null === $idea_data ) {
wp_send_json_error( array( 'message' => 'Idea not found.' ) );
return;
}
// Store idea for the cron handler and set initial "running" status.
- set_transient( self::STATUS_PREFIX . 'data_' . $idea_id, $idea_data, self::STATUS_TTL );
+ set_transient( $status_prefix . 'data_' . $idea_id, $idea_data, $status_ttl );
set_transient(
- self::STATUS_PREFIX . $idea_id,
+ $status_prefix . $idea_id,
array(
'status' => 'running',
'stage' => __( 'Starting generation…', 'prautoblogger' ),
'started' => time(),
),
- self::STATUS_TTL
+ $status_ttl
);
// Schedule background cron — passes idea_id as argument.
@@ -124,7 +122,7 @@ class PRAutoBlogger_Ideas_Browser {
}
$idea_id = absint( $_POST['idea_id'] ?? 0 );
- $status = get_transient( self::STATUS_PREFIX . $idea_id );
+ $status = get_transient( PRAutoBlogger_Ideas_Browser_Query::STATUS_PREFIX . $idea_id );
if ( ! is_array( $status ) ) {
wp_send_json_success( array( 'status' => 'idle' ) );
return;
@@ -149,29 +147,18 @@ class PRAutoBlogger_Ideas_Browser {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( 300 );
- $key = self::STATUS_PREFIX . $idea_id;
- $idea_data = get_transient( self::STATUS_PREFIX . 'data_' . $idea_id );
+ $prefix = PRAutoBlogger_Ideas_Browser_Query::STATUS_PREFIX;
+ $ttl = PRAutoBlogger_Ideas_Browser_Query::STATUS_TTL;
+ $key = $prefix . $idea_id;
+
+ $idea_data = get_transient( $prefix . 'data_' . $idea_id );
if ( ! is_array( $idea_data ) ) {
- set_transient(
- $key,
- array(
- 'status' => 'error',
- 'message' => 'Idea data expired.',
- ),
- self::STATUS_TTL
- );
+ set_transient( $key, array( 'status' => 'error', 'message' => 'Idea data expired.' ), $ttl );
return;
}
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
- set_transient(
- $key,
- array(
- 'status' => 'error',
- 'message' => 'Another generation is running.',
- ),
- self::STATUS_TTL
- );
+ set_transient( $key, array( 'status' => 'error', 'message' => 'Another generation is running.' ), $ttl );
return;
}
@@ -180,7 +167,7 @@ class PRAutoBlogger_Ideas_Browser {
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
$cost_tracker->set_run_id( wp_generate_uuid4() );
- self::update_idea_stage( $idea_id, __( 'Generating article draft…', 'prautoblogger' ) );
+ PRAutoBlogger_Ideas_Browser_Query::update_idea_stage( $idea_id, __( 'Generating article draft…', 'prautoblogger' ) );
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
$result = $worker->generate( $idea );
@@ -188,7 +175,7 @@ class PRAutoBlogger_Ideas_Browser {
PRAutoBlogger_Post_Assembler::amortize_research_costs( $cost_tracker->get_run_id() );
// Find the generated post ID for the "View" link.
- $post_id = self::find_post_by_run_id( $cost_tracker->get_run_id() );
+ $post_id = PRAutoBlogger_Ideas_Browser_Query::find_post_by_run_id( $cost_tracker->get_run_id() );
set_transient(
$key,
@@ -199,139 +186,17 @@ class PRAutoBlogger_Ideas_Browser {
'cost' => $result['cost'],
'post_id' => $post_id,
),
- self::STATUS_TTL
+ $ttl
);
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Idea generation %s for #%d: %s', get_class( $e ), $idea_id, $e->getMessage() ),
'pipeline'
);
- set_transient(
- $key,
- array(
- 'status' => 'error',
- 'message' => $e->getMessage(),
- ),
- self::STATUS_TTL
- );
+ set_transient( $key, array( 'status' => 'error', 'message' => $e->getMessage() ), $ttl );
}
PRAutoBlogger_Generation_Lock::release();
- delete_transient( self::STATUS_PREFIX . 'data_' . $idea_id );
- }
-
- // ── Private helpers ─────────────────────────────────────────────────
-
- /** Update the per-idea status transient with a stage message. */
- private static function update_idea_stage( int $idea_id, string $stage ): void {
- $key = self::STATUS_PREFIX . $idea_id;
- $current = get_transient( $key );
- if ( is_array( $current ) ) {
- $current['stage'] = $stage;
- set_transient( $key, $current, self::STATUS_TTL );
- }
- }
-
- /** Find the most recent post created by a specific run_id. */
- private static function find_post_by_run_id( string $run_id ): ?int {
- global $wpdb;
- $table = $wpdb->prefix . 'prautoblogger_generation_log';
-
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
- $post_id = $wpdb->get_var(
- $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- "SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL LIMIT 1",
- $run_id
- )
- );
- return null !== $post_id ? (int) $post_id : null;
- }
-
- /**
- * Load an analysis result row and map it to Article_Idea constructor data.
- *
- * @param int $idea_id Analysis result row ID.
- * @return array|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..9b0c96e 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,28 @@ 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..ad97617 100644
--- a/includes/class-executor.php
+++ b/includes/class-executor.php
@@ -91,62 +91,22 @@ class PRAutoBlogger_Executor {
* chained cron events — each in its own PHP process. The lock is released
* by the pipeline when the last article completes, not here.
*/
+ /**
+ * Cron handler: kick off the chained-cron orchestration tick (v0.21.0, M4).
+ *
+ * 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-runner.php b/includes/core/class-generation-checkpoint-runner.php
new file mode 100644
index 0000000..768c626
--- /dev/null
+++ b/includes/core/class-generation-checkpoint-runner.php
@@ -0,0 +1,295 @@
+ 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 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();
+ self::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 );
+ }
+ self::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'] ) ) {
+ self::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'
+ );
+ self::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' );
+ self::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 );
+ }
+ self::fire_cron_now();
+ } else {
+ PRAutoBlogger_Post_Assembler::amortize_research_costs( $run_id );
+ self::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' );
+ self::cleanup_queue();
+ PRAutoBlogger_Generation_Lock::release();
+ PRAutoBlogger_Pipeline_Status::write_error( $e->getMessage() );
+ }
+ }
+
+ // -- Private helpers --------------------------------------------------
+
+ /**
+ * Finalize a completed run: write final transient, mark run done, release lock.
+ *
+ * @param array $results Generation counters (generated/published/rejected/cost).
+ * @param string $run_id Run UUID (read from option if blank).
+ * @return void
+ */
+ private static function finalize( array $results, string $run_id = '' ): void {
+ if ( '' === $run_id ) {
+ $run_id = (string) get_option( self::RUN_ID_KEY, '' );
+ }
+ if ( '' !== $run_id ) {
+ PRAutoBlogger_Run_State::mark_status( $run_id, 'done' );
+ }
+ PRAutoBlogger_Pipeline_Status::write_final( $results );
+ PRAutoBlogger_Pipeline_Status::log_summary( $results );
+ PRAutoBlogger_Generation_Lock::release();
+ delete_option( self::RUN_ID_KEY );
+ }
+
+ /** Remove the persistent queue and run-id option on abort paths. */
+ private static function cleanup_queue(): void {
+ delete_option( self::QUEUE_KEY );
+ delete_option( self::RUN_ID_KEY );
+ }
+
+ /**
+ * Non-blocking loopback to fire cron events immediately.
+ * Mirrors Pipeline_Runner::schedule_next() exactly.
+ *
+ * @return void
+ */
+ private static function fire_cron_now(): void {
+ spawn_cron();
+ wp_remote_post(
+ site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
+ array(
+ 'timeout' => 0.01,
+ 'blocking' => false,
+ 'sslverify' => false,
+ )
+ );
+ }
+}
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..4a654c5 100644
--- a/includes/core/class-pipeline-status.php
+++ b/includes/core/class-pipeline-status.php
@@ -103,4 +103,42 @@ 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/unit/Core/GenerationCheckpointRunnerTest.php b/tests/unit/Core/GenerationCheckpointRunnerTest.php
new file mode 100644
index 0000000..ed54542
--- /dev/null
+++ b/tests/unit/Core/GenerationCheckpointRunnerTest.php
@@ -0,0 +1,227 @@
+ 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).
+ \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 ) {