Compare commits

..

4 Commits

Author SHA1 Message Date
peptiderepo
da1ccf394e fix(settings): sanitize reasoning token cap as numeric on save
QA F4 (P3): prautoblogger_reasoning_max_tokens was missing from the
sanitize_field() numeric allowlist, so it saved as raw text (absint at
read kept runtime safe). Saves as numeric-or-0 now, matching every other
number field. CHANGELOG 0.18.1 entry notes the server-side sanitize (no
new version — 0.18.1 is still unreleased).

Agent-Session: prab-empty-draft-guard-20260611
2026-06-11 13:50:26 +00:00
peptiderepo
5a62347bd1 test: split PublisherTest and OpenRouterProviderTest under the 300-line rule
QA F2 (P2): this PR pushed PublisherTest to 333 lines and
OpenRouterProviderTest to 318. Both splits are mechanical (test groups
moved verbatim to a sibling file; setUp duplicated; no test logic or
assertion changes):

- OpenRouterProviderTest (318 -> 169): wire_full_provider() + the two
  v0.18.1 retry-path integration tests move to
  OpenRouterProviderRetryTest.php (223). The remaining file is
  byte-identical to its pre-PR state on main.
- PublisherTest (333 -> 202): the five post-creation side-effect tests
  (taxonomy, tags, run_id log linking, action/filter hooks — incl. the
  two pre-existing skipped ones, moved as-is) move to
  PublisherSideEffectsTest.php (183). Guard tests stay with the
  create_post() funnel tests.

Suite count unchanged (302 tests); all four files pass locally.

Agent-Session: prab-empty-draft-guard-20260611
2026-06-11 13:50:26 +00:00
peptiderepo
1e77aff7c9 docs(architecture): document v0.18.1 completion guard, reasoning cap option, request-builder scope
QA F1 (REJECT-P1 on 2bdc763, qa-reviews/prautoblogger/2026-06-11-2bdc763.md):
ARCHITECTURE.md was not updated in the commit that introduced the new
class and option (Standard §7 same-commit rule).

- File tree: add class-open-router-completion-guard.php (empty-completion
  guard, v0.18.1) under includes/providers/.
- Options table: add prautoblogger_reasoning_max_tokens (v0.18.1 reasoning
  token cap; default PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS = 2048;
  0 = legacy uncapped effort mode).
- File tree: refresh class-open-router-request-builder.php description —
  it now owns request body assembly + the reasoning token cap/headroom,
  not just headers.

No version bump: 0.18.1 is still unreleased on this branch.

Agent-Session: prab-empty-draft-guard-20260611
2026-06-11 13:50:26 +00:00
peptiderepo
2bdc763dc0 fix: empty-content guard + finish_reason=length handling + reasoning budget (v0.18.1)
Reasoning models at high effort could spend the entire max_tokens budget
on thinking and return zero visible content with finish_reason=length;
the empty article flowed writer -> editor (reject) -> empty draft post,
with the cost booked as success (2026-06-11 incident: prod run acf24029,
draft post 921; diagnosis: convo pipeline-v2-phase1 seq 12).

- Empty-completion guard at the provider seam (new
  OpenRouter_Completion_Guard): empty/whitespace content is a failure;
  one retry with reasoning disabled (per-call override) when
  finish_reason=length or reasoning was active, then the stage fails.
- finish_reason=length always warning-logged (model, stage, tokens).
- Failed/retried attempts booked to generation_log with
  response_status=error + real token burn; run_decisions verdict rows
  (retry_reasoning_disabled / failed_empty_completion) for v0.18.0 runs.
- Writer-path guard in Content_Generator::execute_stage and
  belt-and-braces empty check in Publisher::create_post.
- Reasoning budget sanity: reasoning.max_tokens cap + completion
  headroom via new prautoblogger_reasoning_max_tokens setting (default
  constant 2048, 0 = legacy uncapped effort mode); cap replaces effort
  when active; governor reservations use the effective ceiling.
- Body assembly moved to Request_Builder::build_body (300-line cap).
- Tests: guard verdict matrix, builder budget shaping, writer/publisher
  guards, full provider retry path.

Agent-Session: prab-empty-draft-guard-20260611
2026-06-11 13:15:58 +00:00
12 changed files with 68 additions and 277 deletions

View File

@@ -536,7 +536,7 @@ dropped on uninstall.
| id | BIGINT UNSIGNED | Auto-increment PK | | id | BIGINT UNSIGNED | Auto-increment PK |
| run_id | VARCHAR(36) | Pipeline run UUID | | run_id | VARCHAR(36) | Pipeline run UUID |
| stage | VARCHAR(50) | Stage name (see stage vocabulary) | | stage | VARCHAR(50) | Stage name (see stage vocabulary) |
| agent_role | VARCHAR(50) | Fan-out dimension. Populated in Phase 1 from `Stage_Display_Map::default_agent_role()` (e.g. 'writer', 'editor', 'publisher'). Phase-2 quorum passes multiple roles for the same stage. | | agent_role | VARCHAR(50) | Fan-out dimension for Phase-2 quorum ('' default) |
| item_key | VARCHAR(64) | Scopes article-level stages within multi-article runs ('' for run-level) | | item_key | VARCHAR(64) | Scopes article-level stages within multi-article runs ('' for run-level) |
| status | VARCHAR(20) | pending / running / done / failed / halted | | status | VARCHAR(20) | pending / running / done / failed / halted |
| attempt | SMALLINT UNSIGNED | Re-entry counter | | attempt | SMALLINT UNSIGNED | Re-entry counter |
@@ -831,9 +831,8 @@ multi-step publish paths. Four subsystems:
keyed by `_prautoblogger_run_id` + `_prautoblogger_idea_hash` (check-before-insert) so a keyed by `_prautoblogger_run_id` + `_prautoblogger_idea_hash` (check-before-insert) so a
retried run cannot duplicate a post. `PRAutoBlogger_Run_Reaper` (same daily cron as #19) retried run cannot duplicate a post. `PRAutoBlogger_Run_Reaper` (same daily cron as #19)
marks runs stuck `running` > 2× expected stage wall-clock as `failed`; such runs render marks runs stuck `running` > 2× expected stage wall-clock as `failed`; such runs render
as **"incomplete"** in audit queries. Quorum logic (multiple roles per stage) itself is Phase 2. In Phase 1, as **"incomplete"** in audit queries. Quorum logic itself is Phase 2 — the schema just
`agent_role` is populated on every row from `Stage_Display_Map::default_agent_role()` reserves its dimensions.
so the run timeline is self-describing and the idempotency key is fully populated.
### #20: PHPUnit test infrastructure + WordPress-Core PHPCS compliance (v0.10.1) ### #20: PHPUnit test infrastructure + WordPress-Core PHPCS compliance (v0.10.1)

View File

@@ -5,38 +5,6 @@ All notable changes to PRAutoBlogger will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project uses [Semantic Versioning](https://semver.org/). and this project uses [Semantic Versioning](https://semver.org/).
## [0.18.2] - 2026-06-12
Four confirmed-live maintenance fixes (CTO thread `2026-06-pipeline-v2-phase1` seq 25).
### Fixed
- **Cost-reporter SQL (P2)**: `phpcs:ignore` annotations were embedded inside two SQL string
literals in `class-cost-reporter.php` (`get_daily_spend` and `get_spend_by_stage`). MariaDB
received the comment text as literal SQL and rejected both queries, breaking the Metrics &
Costs admin page. Annotations moved above the `$wpdb->prepare()` calls. Regression tests
added to `CostReporterTest` asserting no `// phpcs:ignore` text appears in the SQL passed
to `prepare()`.
- **Collector failure observability (P2)**: `RuntimeException` catch in
`Source_Collector::collect_from_all_sources()` now logs structured meta — model slug,
HTTP status parsed from the exception message, and the first 200 chars of the error body —
so a 27-day outage like the seq-22 llm_research 404 incident is diagnosable after the fact.
No behaviour change; logging only.
- **`run_stages.agent_role` symmetric write (P3)**: `Pipeline_Runner`, `Article_Worker`, and
`Content_Generator` were calling `start()` with the mapped role but leaving `done()`,
`is_done()`, and `get_output()` with `agent_role = ''`. With `agent_role` in the UNIQUE key,
the mismatch addressed different rows — stranding `start()` rows in `running` forever and
breaking idempotent resume (duplicate-post vector). Fixed via an in-class seam in
`Run_Stage_State::resolve_role()`: when the caller passes `''`, the role is derived from
`Stage_Display_Map::default_agent_role($stage)` before any DB query, ensuring every method
addresses the same row. Explicit non-empty roles (Phase-2 fan-out) are passed through
unchanged. `get()` falls back to `role=''` on miss for mid-run-upgrade compat with v0.18.1
rows. Two new `RunStageStateTest` cases lock the symmetric-role and fallback contracts.
ARCHITECTURE.md §22 updated to document Phase 1 role population.
- **Runware float deprecation (P3)**: `normalize_seed()` in `class-runware-image-support.php`
applied the modulo operator to a float (`microtime(true) * 1000`), triggering
`Deprecated: Implicit conversion from float … to int` on every image generation. Fixed
with `(int) round(...)` before the `%` operator.
## [0.18.1] - 2026-06-11 ## [0.18.1] - 2026-06-11
Empty-draft hotfix (CTO diagnosis: convo thread `2026-06-pipeline-v2-phase1` seq 12; prod run Empty-draft hotfix (CTO diagnosis: convo thread `2026-06-pipeline-v2-phase1` seq 12; prod run

View File

@@ -103,7 +103,7 @@ class PRAutoBlogger_Article_Worker {
$review = $this->review_with_state( $editor, $content, $idea, $run_id, $item ); $review = $this->review_with_state( $editor, $content, $idea, $run_id, $item );
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Saving and publishing…', 'prautoblogger' ) ); PRAutoBlogger_Pipeline_Status::broadcast( __( 'Saving and publishing…', 'prautoblogger' ) );
PRAutoBlogger_Run_Stage_State::start( $run_id, 'publish', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'publish' ), $item ); PRAutoBlogger_Run_Stage_State::start( $run_id, 'publish', '', $item );
$this->publish_or_draft( $this->publish_or_draft(
$content, $content,
$idea, $idea,
@@ -174,7 +174,7 @@ class PRAutoBlogger_Article_Worker {
return new PRAutoBlogger_Editorial_Review( $data ); return new PRAutoBlogger_Editorial_Review( $data );
} }
} }
PRAutoBlogger_Run_Stage_State::start( $run_id, 'review', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'review' ), $item ); PRAutoBlogger_Run_Stage_State::start( $run_id, 'review', '', $item );
} }
$review = $editor->review( $content, $idea ); $review = $editor->review( $content, $idea );

View File

@@ -194,7 +194,7 @@ class PRAutoBlogger_Content_Generator {
); );
return $cached; return $cached;
} }
PRAutoBlogger_Run_Stage_State::start( $this->run_id, $stage, (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ), $this->item_key ); PRAutoBlogger_Run_Stage_State::start( $this->run_id, $stage, '', $this->item_key );
} }
// v0.18.1: stage metadata for the provider's empty-completion // v0.18.1: stage metadata for the provider's empty-completion

View File

@@ -65,9 +65,8 @@ class PRAutoBlogger_Cost_Reporter {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$results = $wpdb->get_results( $results = $wpdb->get_results(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare( $wpdb->prepare(
"SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost "SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
FROM {$table} FROM {$table}
WHERE created_at >= %s AND response_status = 'success' WHERE created_at >= %s AND response_status = 'success'
GROUP BY DATE(created_at) GROUP BY DATE(created_at)
@@ -103,12 +102,11 @@ class PRAutoBlogger_Cost_Reporter {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$results = $wpdb->get_results( $results = $wpdb->get_results(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare( $wpdb->prepare(
"SELECT stage, "SELECT stage,
SUM(estimated_cost) as total_cost, SUM(estimated_cost) as total_cost,
SUM(prompt_tokens + completion_tokens) as total_tokens, SUM(prompt_tokens + completion_tokens) as total_tokens,
COUNT(*) as call_count COUNT(*) as call_count // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
FROM {$table} FROM {$table}
WHERE created_at BETWEEN %s AND %s AND response_status = 'success' WHERE created_at BETWEEN %s AND %s AND response_status = 'success'
GROUP BY stage", GROUP BY stage",

View File

@@ -185,14 +185,14 @@ class PRAutoBlogger_Pipeline_Runner {
$source_labels = is_array( $enabled ) ? implode( ', ', $enabled ) : 'reddit'; $source_labels = is_array( $enabled ) ? implode( ', ', $enabled ) : 'reddit';
/* translators: %s is a comma-separated list of enabled source names, e.g. "reddit, llm_research". */ /* translators: %s is a comma-separated list of enabled source names, e.g. "reddit, llm_research". */
PRAutoBlogger_Pipeline_Status::broadcast( sprintf( __( 'Collecting sources from %s…', 'prautoblogger' ), $source_labels ) ); PRAutoBlogger_Pipeline_Status::broadcast( sprintf( __( 'Collecting sources from %s…', 'prautoblogger' ), $source_labels ) );
PRAutoBlogger_Run_Stage_State::start( $run_id, 'research', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'research' ) ); PRAutoBlogger_Run_Stage_State::start( $run_id, 'research' );
( new PRAutoBlogger_Source_Collector() ) ( new PRAutoBlogger_Source_Collector() )
->set_cost_tracker( $cost_tracker ) ->set_cost_tracker( $cost_tracker )
->collect_from_all_sources(); ->collect_from_all_sources();
PRAutoBlogger_Run_Stage_State::done( $run_id, 'research' ); PRAutoBlogger_Run_Stage_State::done( $run_id, 'research' );
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Analyzing topics and scoring…', 'prautoblogger' ) ); PRAutoBlogger_Pipeline_Status::broadcast( __( 'Analyzing topics and scoring…', 'prautoblogger' ) );
PRAutoBlogger_Run_Stage_State::start( $run_id, 'analysis', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'analysis' ) ); PRAutoBlogger_Run_Stage_State::start( $run_id, 'analysis' );
$llm = new PRAutoBlogger_OpenRouter_Provider(); $llm = new PRAutoBlogger_OpenRouter_Provider();
$analyzer = new PRAutoBlogger_Content_Analyzer( $llm, $cost_tracker ); $analyzer = new PRAutoBlogger_Content_Analyzer( $llm, $cost_tracker );
$analysis = $analyzer->analyze_recent_data( max( $target * 2, 6 ) ); $analysis = $analyzer->analyze_recent_data( max( $target * 2, 6 ) );

View File

@@ -6,33 +6,40 @@ declare(strict_types=1);
* *
* Per-run per-stage state machine over `wp_prautoblogger_run_stages`. * Per-run per-stage state machine over `wp_prautoblogger_run_stages`.
* *
* One row per (run_id, stage, agent_role, item_key) holding * What: One row per (run_id, stage, agent_role, item_key) holding
* pending|running|done|failed|halted plus the output snapshot (meta_json). * pending|running|done|failed|halted plus the stage's output
* Done stages are sticky and never re-charged on resume. Self-healing: * snapshot (meta_json). This is what makes resume idempotent on the
* a missing table degrades every method to a no-op/false. * chained-cron architecture: a `done` stage is NEVER re-run or
* re-charged — re-entry reuses its stored output; `done` is sticky
* (an upsert cannot demote it). agent_role is the Phase-2 fan-out
* dimension (quorum members; the quorum logic itself is Phase 2);
* item_key scopes article-level stages because one run_id spans all
* N articles of a batch run ('' for run-level stages). Self-healing:
* a missing table degrades every method to a no-op/false.
* Who triggers it: Pipeline_Runner (run-level research/analysis),
* Article_Worker (per-article generate/review/publish),
* Content_Generator (per-LLM-stage outline/draft/polish),
* Run_Reaper (stuck-stage sweep + payload pruning).
* Dependencies: WordPress $wpdb, wp_json_encode.
* *
* v0.18.2: role is resolved from Stage_Display_Map when caller passes '' * @see core/class-run-state.php — Run-level lifecycle + ledger row.
* so start/done/is_done/get_output are always symmetric. Explicit non-empty * @see core/class-run-reaper.php — Sweeps stages stuck in 'running'.
* roles (Phase-2 fan-out) are honoured as-is. get() falls back to role='' * @see ARCHITECTURE.md #21 — Idempotency-key design.
* on miss for mid-run-upgrade compat with v0.18.1 rows.
*
* @see core/class-run-state.php — Run-level lifecycle + ledger row.
* @see core/class-run-reaper.php — Sweeps stages stuck in 'running'.
* @see ARCHITECTURE.md #22 — Pipeline v2 Phase 1 substrate.
*/ */
class PRAutoBlogger_Run_Stage_State { class PRAutoBlogger_Run_Stage_State {
/** @var bool|null Per-request "run_stages table exists" probe result. */ /** @var bool|null Per-request "run_stages table exists" probe result. */
private static ?bool $table_ok = null; private static ?bool $table_ok = null;
/** @return string Fully-qualified run_stages table name. */ /** Fully-qualified run_stages table name. */
public static function table_name(): string { public static function table_name(): string {
global $wpdb; global $wpdb;
return $wpdb->prefix . 'prautoblogger_run_stages'; return $wpdb->prefix . 'prautoblogger_run_stages';
} }
/** /**
* Whether the run_stages table exists and is queryable. Cached per request. * Whether the run_stages table exists and is queryable. Cached per
* request; never throws.
* *
* @return bool * @return bool
*/ */
@@ -42,7 +49,7 @@ class PRAutoBlogger_Run_Stage_State {
} }
global $wpdb; global $wpdb;
if ( null === $wpdb ) { if ( null === $wpdb ) {
return false; return false; // Not cached: $wpdb may appear later in boot.
} }
$table = self::table_name(); $table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
@@ -52,17 +59,18 @@ class PRAutoBlogger_Run_Stage_State {
} }
/** /**
* Stable 16-hex hash for one article idea within a run. * Stable hash identifying one article idea within a run (post-creation
* idempotency + stage item scoping both key off this).
* *
* @param PRAutoBlogger_Article_Idea $idea The idea. * @param PRAutoBlogger_Article_Idea $idea The idea.
* @return string * @return string 16-hex-char hash.
*/ */
public static function idea_hash( PRAutoBlogger_Article_Idea $idea ): string { public static function idea_hash( PRAutoBlogger_Article_Idea $idea ): string {
return substr( md5( $idea->get_suggested_title() . '|' . $idea->get_topic() ), 0, 16 ); return substr( md5( $idea->get_suggested_title() . '|' . $idea->get_topic() ), 0, 16 );
} }
/** /**
* Stage item_key for an article idea ('' reserved for run-level stages). * Stage item_key for an article idea ('' is reserved for run-level stages).
* *
* @param PRAutoBlogger_Article_Idea $idea The idea. * @param PRAutoBlogger_Article_Idea $idea The idea.
* @return string e.g. 'idea:1a2b3c4d5e6f7a8b'. * @return string e.g. 'idea:1a2b3c4d5e6f7a8b'.
@@ -72,18 +80,22 @@ class PRAutoBlogger_Run_Stage_State {
} }
/** /**
* Mark a stage as running. Sticky: done stages are never demoted. * Mark a stage as entered (running). Upsert: first entry inserts the
* row; re-entry bumps `attempt` — but a `done` stage is sticky and is
* neither demoted nor re-counted.
*
* Side effects: one INSERT … ON DUPLICATE KEY UPDATE.
* *
* @param string $run_id Run UUID. * @param string $run_id Run UUID.
* @param string $stage Stage name (see Stage_Display_Map). * @param string $stage Stage name (see Stage_Display_Map).
* @param string $agent_role Fan-out dimension ('' = resolve from stage map). * @param string $agent_role Fan-out dimension ('' default in Phase 1).
* @param string $item_key Article scope ('' for run-level stages). * @param string $item_key Article scope ('' for run-level stages).
* @return void
*/ */
public static function start( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void { public static function start( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
if ( '' === $run_id || ! self::is_available() ) { if ( '' === $run_id || ! self::is_available() ) {
return; return;
} }
$agent_role = self::resolve_role( $stage, $agent_role );
global $wpdb; global $wpdb;
$table = self::table_name(); $table = self::table_name();
$now = current_time( 'mysql' ); $now = current_time( 'mysql' );
@@ -108,20 +120,22 @@ class PRAutoBlogger_Run_Stage_State {
} }
/** /**
* Mark a stage done and persist its output for resume-without-recharge. * Mark a stage done, persisting its output for resume-without-recharge.
*
* Side effects: one INSERT … ON DUPLICATE KEY UPDATE.
* *
* @param string $run_id Run UUID. * @param string $run_id Run UUID.
* @param string $stage Stage name. * @param string $stage Stage name.
* @param string $agent_role Fan-out dimension ('' = resolve from stage map). * @param string $agent_role Fan-out dimension.
* @param string $item_key Article scope. * @param string $item_key Article scope.
* @param string|null $output Stage output snapshot (pruned after R days). * @param string|null $output Stage output to snapshot (pruned after R days).
* @param float $cost_usd Cost attributed to this stage. * @param float $cost_usd Cost attributed to the stage.
* @return void
*/ */
public static function done( string $run_id, string $stage, string $agent_role = '', string $item_key = '', ?string $output = null, float $cost_usd = 0.0 ): void { public static function done( string $run_id, string $stage, string $agent_role = '', string $item_key = '', ?string $output = null, float $cost_usd = 0.0 ): void {
if ( '' === $run_id || ! self::is_available() ) { if ( '' === $run_id || ! self::is_available() ) {
return; return;
} }
$agent_role = self::resolve_role( $stage, $agent_role );
global $wpdb; global $wpdb;
$table = self::table_name(); $table = self::table_name();
$now = current_time( 'mysql' ); $now = current_time( 'mysql' );
@@ -152,18 +166,18 @@ class PRAutoBlogger_Run_Stage_State {
} }
/** /**
* Mark a stage failed (does not demote a done stage). * Mark a stage failed (does not demote a `done` stage).
* *
* @param string $run_id Run UUID. * @param string $run_id Run UUID.
* @param string $stage Stage name. * @param string $stage Stage name.
* @param string $agent_role Fan-out dimension ('' = resolve from stage map). * @param string $agent_role Fan-out dimension.
* @param string $item_key Article scope. * @param string $item_key Article scope.
* @return void
*/ */
public static function fail( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void { public static function fail( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
if ( '' === $run_id || ! self::is_available() ) { if ( '' === $run_id || ! self::is_available() ) {
return; return;
} }
$agent_role = self::resolve_role( $stage, $agent_role );
global $wpdb; global $wpdb;
$table = self::table_name(); $table = self::table_name();
$now = current_time( 'mysql' ); $now = current_time( 'mysql' );
@@ -183,20 +197,18 @@ class PRAutoBlogger_Run_Stage_State {
} }
/** /**
* Fetch one stage row. Falls back to role='' on miss for mid-run-upgrade * Fetch one stage row.
* compat with v0.18.1 rows (one extra indexed query, only on miss).
* *
* @param string $run_id Run UUID. * @param string $run_id Run UUID.
* @param string $stage Stage name. * @param string $stage Stage name.
* @param string $agent_role Fan-out dimension ('' = resolve from stage map). * @param string $agent_role Fan-out dimension.
* @param string $item_key Article scope. * @param string $item_key Article scope.
* @return array<string, mixed>|null * @return array<string, mixed>|null Row or null.
*/ */
public static function get( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): ?array { public static function get( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): ?array {
if ( '' === $run_id || ! self::is_available() ) { if ( '' === $run_id || ! self::is_available() ) {
return null; return null;
} }
$agent_role = self::resolve_role( $stage, $agent_role );
global $wpdb; global $wpdb;
$table = self::table_name(); $table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
@@ -210,25 +222,7 @@ class PRAutoBlogger_Run_Stage_State {
), ),
ARRAY_A ARRAY_A
); );
if ( is_array( $row ) ) { return is_array( $row ) ? $row : null;
return $row;
}
// v0.18.1 upgrade compat: rows started before this fix carry role=''.
// One extra indexed query, only on miss, only for known stages.
if ( '' !== $agent_role ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$legacy = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE run_id = %s AND stage = %s AND agent_role = '' AND item_key = %s",
$run_id,
$stage,
$item_key
),
ARRAY_A
);
return is_array( $legacy ) ? $legacy : null;
}
return null;
} }
/** /**
@@ -236,7 +230,7 @@ class PRAutoBlogger_Run_Stage_State {
* *
* @param string $run_id Run UUID. * @param string $run_id Run UUID.
* @param string $stage Stage name. * @param string $stage Stage name.
* @param string $agent_role Fan-out dimension ('' = resolve from stage map). * @param string $agent_role Fan-out dimension.
* @param string $item_key Article scope. * @param string $item_key Article scope.
* @return bool * @return bool
*/ */
@@ -250,7 +244,7 @@ class PRAutoBlogger_Run_Stage_State {
* *
* @param string $run_id Run UUID. * @param string $run_id Run UUID.
* @param string $stage Stage name. * @param string $stage Stage name.
* @param string $agent_role Fan-out dimension ('' = resolve from stage map). * @param string $agent_role Fan-out dimension.
* @param string $item_key Article scope. * @param string $item_key Article scope.
* @return string|null * @return string|null
*/ */
@@ -264,10 +258,13 @@ class PRAutoBlogger_Run_Stage_State {
} }
/** /**
* Fail every still-open stage of one item. Done stages are untouched. * Fail every still-open (pending/running) stage of one item — used by
* the worker's catch-all so a crashed article leaves no stage stuck
* in 'running' until the reaper. Done stages are untouched.
* *
* @param string $run_id Run UUID. * @param string $run_id Run UUID.
* @param string $item_key Stage item key ('' = run-level stages). * @param string $item_key Stage item key ('' = run-level stages).
* @return void
*/ */
public static function fail_open_for_item( string $run_id, string $item_key ): void { public static function fail_open_for_item( string $run_id, string $item_key ): void {
if ( '' === $run_id || ! self::is_available() ) { if ( '' === $run_id || ! self::is_available() ) {
@@ -293,21 +290,4 @@ class PRAutoBlogger_Run_Stage_State {
public static function flush_cache(): void { public static function flush_cache(): void {
self::$table_ok = null; self::$table_ok = null;
} }
/**
* Resolve the effective agent role for a stage.
*
* '' → derive from Stage_Display_Map (unknown stages map to '').
* Non-empty → returned as-is (Phase-2 explicit fan-out role).
*
* @param string $stage Stage name.
* @param string $agent_role Caller-supplied role ('' = auto-resolve).
* @return string Effective agent role.
*/
private static function resolve_role( string $stage, string $agent_role ): string {
if ( '' !== $agent_role ) {
return $agent_role;
}
return (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage );
}
} }

View File

@@ -76,33 +76,8 @@ class PRAutoBlogger_Source_Collector {
'collector' 'collector'
); );
} catch ( \Throwable $e ) { } catch ( \Throwable $e ) {
// Structured failure meta: model slug, HTTP status (parsed from message), PRAutoBlogger_Logger::instance()->error( sprintf( 'Collection %s from %s: %s', get_class( $e ), $source_type, $e->getMessage() ), 'collector' );
// and first ~200 chars of the error body — enables post-incident diagnosis // Continue with other sources — one failure shouldn't stop everything.
// without needing to re-run the failing collection. Added v0.18.2.
$config_for_log = $this->get_config_for_source( $source_type );
$model_slug = (string) ( $config_for_log['model'] ?? '' );
$err_msg = $e->getMessage();
// Extract HTTP status from "OpenRouter API error (HTTP NNN): ..." pattern.
$http_status = 0;
if ( preg_match( '/HTTP (\d{3})/', $err_msg, $m ) ) {
$http_status = (int) $m[1];
}
PRAutoBlogger_Logger::instance()->error(
sprintf(
'Collection %s from %s: %s',
get_class( $e ),
$source_type,
$e->getMessage()
),
'collector',
array(
'source' => $source_type,
'model' => $model_slug,
'http_status' => $http_status,
'error_body' => mb_substr( $err_msg, 0, 200 ),
)
);
// Continue with other sources — one failure should not stop everything.
} }
} }

View File

@@ -181,8 +181,7 @@ class PRAutoBlogger_Runware_Image_Support {
*/ */
public function normalize_seed( ?int $seed ): int { public function normalize_seed( ?int $seed ): int {
if ( null === $seed || $seed < 1 ) { if ( null === $seed || $seed < 1 ) {
// Cast to int before modulo — float % int is deprecated in PHP 8 (v0.18.2). $derived = (int) ( ( microtime( true ) * 1000 ) % 2147483647 );
$derived = (int) round( microtime( true ) * 1000 ) % 2147483647;
return $derived > 0 ? $derived : 1; return $derived > 0 ? $derived : 1;
} }
return $seed; return $seed;

View File

@@ -9,7 +9,7 @@
* Plugin Name: PRAutoBlogger * Plugin Name: PRAutoBlogger
* Plugin URI: https://peptiderepo.com/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. * Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
* Version: 0.18.2 * Version: 0.18.1
* Requires at least: 6.0 * Requires at least: 6.0
* Requires PHP: 7.4 * Requires PHP: 7.4
* Author: PeptideRepo * Author: PeptideRepo
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
| Defined here so every file in the plugin can reference paths, versions, | Defined here so every file in the plugin can reference paths, versions,
| and limits without magic strings. | and limits without magic strings.
*/ */
define( 'PRAUTOBLOGGER_VERSION', '0.18.2' ); define( 'PRAUTOBLOGGER_VERSION', '0.18.1' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.2.0' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.2.0' );
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );

View File

@@ -90,60 +90,4 @@ class CostReporterTest extends BaseTestCase {
$this->assertIsFloat( $utilization ); $this->assertIsFloat( $utilization );
$this->assertGreaterThanOrEqual( 0.0, $utilization ); $this->assertGreaterThanOrEqual( 0.0, $utilization );
} }
/**
* Regression: get_daily_spend SQL must not embed phpcs:ignore inside the
* string literal (MariaDB would receive the comment as literal SQL text and
* reject the query). Captures the first argument passed to prepare() and
* asserts the SQL is clean.
*
* @see includes/core/class-cost-reporter.php — fixed in v0.18.2
*/
public function test_get_daily_spend_sql_contains_no_inline_phpcs_ignore(): void {
$captured_sql = null;
$this->wpdb->method( 'prepare' )->willReturnCallback(
function ( $sql, ...$args ) use ( &$captured_sql ) {
$captured_sql = $sql;
return 'prepared';
}
);
$this->wpdb->method( 'get_results' )->willReturn( [] );
$reporter = new \PRAutoBlogger_Cost_Reporter();
$reporter->get_daily_spend( 7 );
$this->assertNotNull( $captured_sql, 'prepare() was not called' );
$this->assertStringNotContainsString(
'// phpcs:ignore',
(string) $captured_sql,
'SQL string must not contain phpcs:ignore comment — MariaDB rejects it'
);
}
/**
* Regression: get_spend_by_stage SQL must not embed phpcs:ignore inside
* the string literal.
*
* @see includes/core/class-cost-reporter.php — fixed in v0.18.2
*/
public function test_get_spend_by_stage_sql_contains_no_inline_phpcs_ignore(): void {
$captured_sql = null;
$this->wpdb->method( 'prepare' )->willReturnCallback(
function ( $sql, ...$args ) use ( &$captured_sql ) {
$captured_sql = $sql;
return 'prepared';
}
);
$this->wpdb->method( 'get_results' )->willReturn( [] );
$reporter = new \PRAutoBlogger_Cost_Reporter();
$reporter->get_spend_by_stage( '2026-06-01', '2026-06-30' );
$this->assertNotNull( $captured_sql, 'prepare() was not called' );
$this->assertStringNotContainsString(
'// phpcs:ignore',
(string) $captured_sql,
'SQL string must not contain phpcs:ignore comment — MariaDB rejects it'
);
}
} }

View File

@@ -137,76 +137,4 @@ class RunStageStateTest extends BaseTestCase {
$this->assertFalse( \PRAutoBlogger_Run_Stage_State::is_done( 'run-1', 'draft' ) ); $this->assertFalse( \PRAutoBlogger_Run_Stage_State::is_done( 'run-1', 'draft' ) );
$this->assertNull( \PRAutoBlogger_Run_Stage_State::get_output( 'run-1', 'draft' ) ); $this->assertNull( \PRAutoBlogger_Run_Stage_State::get_output( 'run-1', 'draft' ) );
} }
/**
* start(role) → done(role) → is_done(role) must be true; is_done('') must
* be false — this catches the v0.18.2 regression where done() passed ''
* while start() passed a real role, so done() addressed a different row.
*
* In this test the wpdb mock records inserts via query() and simulates
* the SELECT via get_row():
* is_done('review','editor') → get_row finds the done row → true.
* is_done('review','') → resolve_role→'editor'; primary: miss;
* legacy fallback (role=''): miss → false.
*/
public function test_role_tagged_start_and_done_are_symmetric(): void {
$get_row_results = array(
array( 'status' => 'done', 'meta_json' => null ), // primary hit for 'editor'.
null, // primary miss for legacy check (role='editor' resolved, not '').
null, // legacy fallback (role='') also not found.
);
$call_index = 0;
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
$this->wpdb->method( 'query' )->willReturn( 1 );
$this->wpdb->method( 'get_row' )->willReturnCallback(
static function () use ( &$get_row_results, &$call_index ) {
$result = $get_row_results[ $call_index ] ?? null;
++$call_index;
return $result;
}
);
\PRAutoBlogger_Run_Stage_State::start( 'run-2', 'review', 'editor', 'idea:xyz' );
\PRAutoBlogger_Run_Stage_State::done( 'run-2', 'review', 'editor', 'idea:xyz' );
// Explicit role finds the done row.
$this->assertTrue(
\PRAutoBlogger_Run_Stage_State::is_done( 'run-2', 'review', 'editor', 'idea:xyz' ),
'is_done with explicit role should be true'
);
// '' resolves to 'editor'; primary misses; legacy '' fallback also misses.
$this->assertFalse(
\PRAutoBlogger_Run_Stage_State::is_done( 'run-2', 'review', '', 'idea:xyz' ),
'is_done with legacy empty role should be false (no legacy row exists)'
);
}
/**
* Mid-run-upgrade resume: a row created by v0.18.1 (agent_role='') must
* still be found by is_done('') under v0.18.2 via the legacy fallback.
* resolve_role('review','') → 'editor'; primary query misses; legacy
* fallback (role='') hits → is_done returns true.
*/
public function test_legacy_empty_role_row_is_found_on_fallback(): void {
$call_index = 0;
$get_row_results = array(
null, // primary query (role='editor'): miss.
array( 'status' => 'done', 'meta_json' => null ), // legacy fallback (role=''): hit.
);
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
$this->wpdb->method( 'get_row' )->willReturnCallback(
static function () use ( &$get_row_results, &$call_index ) {
$result = $get_row_results[ $call_index ] ?? null;
++$call_index;
return $result;
}
);
// is_done('') resolves to 'editor', misses, falls back to role='',
// finds the v0.18.1 row → true.
$this->assertTrue(
\PRAutoBlogger_Run_Stage_State::is_done( 'run-3', 'review', '', 'idea:abc' ),
'v0.18.1 empty-role row must be found via legacy fallback'
);
}
} }