From c5e2a73e42f1047ea227aa483554639d6a9f63a9 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Thu, 11 Jun 2026 04:33:22 +0000 Subject: [PATCH] fix(schema): self-heal generation_log writes on half-migrated state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cron-triggered generation can run right after a deploy, before any admin_init migration pass — inserts carrying the new agent_role / prompt_version columns would fail silently on the old schema and lose the cost row. Two-sided heal: (1) log_api_call/log_image_generation retry the insert without the v0.18.0 columns when it fails; (2) the db-version check now also runs on cron requests (init + wp_doing_cron), so the schema migrates on the first cron hit, not the first admin visit. Agent-Session: pipeline-v2-phase1-engineer-20260612 --- CHANGELOG.md | 4 ++ includes/class-prautoblogger.php | 19 ++++++++++ includes/core/class-cost-tracker.php | 56 ++++++++++++++++++---------- 3 files changed, 59 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aea9b1b..a50ebb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,10 @@ behavior change** to the Economy (single-pass) and multi-step publish paths. ### Fixed - ARCHITECTURE.md documented the multi-step edit stage as `edit`; the code has always written `polish`. Canonicalized to **`polish`** everywhere. +- Half-migrated-schema hardening: generation_log inserts retry without the new audit + columns when the columns are missing (a cron run right after deploy no longer loses cost + rows), and the db-version self-heal now also runs on cron requests (`init` + + `wp_doing_cron()`), not only on `admin_init`. ## [0.17.0] - 2026-06-11 diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index ee4c417..f846679 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -46,6 +46,10 @@ class PRAutoBlogger { $this->ajax_handlers = new PRAutoBlogger_Ajax_Handlers( $this->executor->get_model_registry() ); add_action( 'admin_init', array( $this, 'on_check_db_version' ) ); + // v0.18.0: cron requests must also self-heal the schema — after a + // deploy, scheduled generation can run before any admin page load, + // and new-column inserts would silently fail on the old schema. + add_action( 'init', array( $this, 'on_check_db_version_for_cron' ) ); add_filter( 'cron_schedules', array( $this, 'filter_add_cron_schedules' ) ); if ( is_admin() ) { @@ -306,6 +310,21 @@ class PRAutoBlogger { } } + /** + * Run the db-version check on cron requests (admin_init never fires + * there). Gated to wp_doing_cron() so normal frontend requests pay + * nothing beyond one autoloaded option read. + * + * Side effects: may run the same migrations as on_check_db_version(). + * + * @return void + */ + public function on_check_db_version_for_cron(): void { + if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) { + $this->on_check_db_version(); + } + } + /** * Add custom cron schedules (six-hourly for metrics collection). * diff --git a/includes/core/class-cost-tracker.php b/includes/core/class-cost-tracker.php index 65d01f4..cd970eb 100644 --- a/includes/core/class-cost-tracker.php +++ b/includes/core/class-cost-tracker.php @@ -127,9 +127,19 @@ class PRAutoBlogger_Cost_Tracker { $table = $wpdb->prefix . 'prautoblogger_generation_log'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery - $wpdb->insert( $table, $log_entry->to_db_row() ); + $inserted = $wpdb->insert( $table, $log_entry->to_db_row() ); - if ( false === $wpdb->insert_id ) { + // Self-healing on a half-migrated schema (e.g. cron fires after a + // deploy but before any admin_init migration pass): retry without + // the v0.18.0 audit columns rather than losing the cost row. + if ( false === $inserted ) { + $legacy_row = $log_entry->to_db_row(); + unset( $legacy_row['agent_role'], $legacy_row['prompt_version'] ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $inserted = $wpdb->insert( $table, $legacy_row ); + } + + if ( false === $inserted || false === $wpdb->insert_id ) { PRAutoBlogger_Logger::instance()->error( 'Failed to log API call: ' . $wpdb->last_error, 'cost-tracker' ); } } @@ -179,25 +189,31 @@ class PRAutoBlogger_Cost_Tracker { } $table = $wpdb->prefix . 'prautoblogger_generation_log'; - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery - $wpdb->insert( - $table, - array( - 'post_id' => $post_id, - 'run_id' => $this->run_id, - 'stage' => $stage, - 'provider' => 'cloudflare-workers-ai', - 'model' => $model, - 'prompt_tokens' => 0, - 'completion_tokens' => 0, - 'estimated_cost' => $cost_usd, - 'response_status' => 'success', - 'error_message' => '', - 'agent_role' => PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ), - 'prompt_version' => $this->resolve_prompt_version( $stage, null ), - 'created_at' => current_time( 'mysql' ), - ) + $row = array( + 'post_id' => $post_id, + 'run_id' => $this->run_id, + 'stage' => $stage, + 'provider' => 'cloudflare-workers-ai', + 'model' => $model, + 'prompt_tokens' => 0, + 'completion_tokens' => 0, + 'estimated_cost' => $cost_usd, + 'response_status' => 'success', + 'error_message' => '', + 'agent_role' => PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ), + 'prompt_version' => $this->resolve_prompt_version( $stage, null ), + 'created_at' => current_time( 'mysql' ), ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $inserted = $wpdb->insert( $table, $row ); + + // Self-healing on a half-migrated schema — see log_api_call(). + if ( false === $inserted ) { + unset( $row['agent_role'], $row['prompt_version'] ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( $table, $row ); + } } /**