From 38500b2781efc8252a99579c1367a7aa0e0fdc89 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 12 Jun 2026 13:15:50 +0000 Subject: [PATCH] =?UTF-8?q?feat(M3):=20re-run=20engine=20=E2=80=94=20repla?= =?UTF-8?q?y=20+=20rebuild=20as=20chained-cron=20jobs=20under=20CPO=20guar?= =?UTF-8?q?drails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two operator primitives, both queued (never synchronous — CPO hard constraint), all mutations in the cron handler after re-validating eligibility UNDER the generation lock: - Replay (Stage_Replay + on_replay_job): one governed chat call from the latest edited input fork. options_from_body() is the exact inverse of build_body()+apply_reasoning_budget() (headroom subtracted back out; reasoning pinned explicitly so global-setting drift can't reshape a replay). Goes through send_chat_completion -> cost governor reserves against the SAME run row (guardrail 4), retry/empty-guard machinery, B1 recorder. Fresh output lands in the stage snapshot; downstream stale-marked (guardrail 3); human_modified set at execution entry (guardrail 2). - Rebuild (on_rebuild_job): demote target+downstream to pending, re-enter Article_Worker with the idea from the stored seed; upstream done stages reuse (never re-charged), demoted stages rebuild prompts from CURRENT snapshots — how an upstream edit propagates. Decisions recorded while rebuilding a human-modified item are flagged. - Rerun_Eligibility: published/future/private = frozen (guardrail 5, server-side); executing runs blocked; editable stages = writer chat stages (outline/draft/polish); chain-order downstream sets. - Run_State::reopen(): atomic terminal->running gate, ceiling re-snapshotted from the current setting, accumulated spend NEVER reset. - Publisher: re-run output refreshes the existing UNPUBLISHED post (status preserved — re-runs never publish); published posts untouched. - Article_Worker persists the exact idea payload as a seed (INSERT-once) so rebuilds can't mis-derive item_key and duplicate a post. - Rerun_Job_Support: queue/lock/budget/status-transient plumbing; jobs ride the M1 status transient so the board shows queued->pickup->result. Agent-Session: engineer-prab-m3-20260612 --- includes/class-prautoblogger.php | 5 + includes/core/class-article-worker.php | 7 + includes/core/class-publisher.php | 64 ++++- includes/core/class-rerun-eligibility.php | 214 +++++++++++++++++ includes/core/class-rerun-executor.php | 222 ++++++++++++++++++ includes/core/class-rerun-job-support.php | 158 +++++++++++++ includes/core/class-run-stage-rerun-state.php | 24 ++ includes/core/class-run-state.php | 35 +++ includes/core/class-stage-replay.php | 189 +++++++++++++++ tests/unit/Core/PublisherRerunUpdateTest.php | 134 +++++++++++ tests/unit/Core/RerunEligibilityTest.php | 182 ++++++++++++++ tests/unit/Core/RerunExecutorTest.php | 217 +++++++++++++++++ tests/unit/Core/RunStateReopenTest.php | 102 ++++++++ tests/unit/Core/StageReplayTest.php | 157 +++++++++++++ 14 files changed, 1705 insertions(+), 5 deletions(-) create mode 100644 includes/core/class-rerun-eligibility.php create mode 100644 includes/core/class-rerun-executor.php create mode 100644 includes/core/class-rerun-job-support.php create mode 100644 includes/core/class-stage-replay.php create mode 100644 tests/unit/Core/PublisherRerunUpdateTest.php create mode 100644 tests/unit/Core/RerunEligibilityTest.php create mode 100644 tests/unit/Core/RerunExecutorTest.php create mode 100644 tests/unit/Core/RunStateReopenTest.php create mode 100644 tests/unit/Core/StageReplayTest.php diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index fcd2d43..0869050 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -141,6 +141,11 @@ class PRAutoBlogger { new PRAutoBlogger_Opik_Settings(); } + // v0.20.0 (M3): operator re-run jobs — chained-cron, never synchronous. + $rerun_executor = new PRAutoBlogger_Rerun_Executor(); + 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 ); + 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/core/class-article-worker.php b/includes/core/class-article-worker.php index ab56994..d65426d 100644 --- a/includes/core/class-article-worker.php +++ b/includes/core/class-article-worker.php @@ -58,6 +58,13 @@ class PRAutoBlogger_Article_Worker { $run_id = (string) ( $this->cost_tracker->get_run_id() ?? '' ); $item = PRAutoBlogger_Run_Stage_State::item_key_for_idea( $idea ); + // v0.20.0: persist the EXACT idea payload once per item so + // re-run-from-here can reconstruct it (rebuilding from post + // fields would risk an item_key hash mismatch -> duplicate post). + if ( '' !== $run_id ) { + PRAutoBlogger_Stage_Input_Store::save_seed( $run_id, $item, (string) wp_json_encode( $idea->to_array() ) ); + } + $result = array( 'generated' => 0, 'published' => 0, diff --git a/includes/core/class-publisher.php b/includes/core/class-publisher.php index 696c3d5..598a141 100644 --- a/includes/core/class-publisher.php +++ b/includes/core/class-publisher.php @@ -107,11 +107,11 @@ class PRAutoBlogger_Publisher { if ( null !== $run_id && '' !== $run_id ) { $existing_id = $this->find_existing_post( $run_id, $idea_hash ); if ( $existing_id > 0 ) { - PRAutoBlogger_Logger::instance()->info( - sprintf( 'Post for run %s / idea %s already exists (ID=%d) — skipping duplicate insert.', $run_id, $idea_hash, $existing_id ), - 'publisher' - ); - return $existing_id; + // v0.20.0: a re-run's regenerated content must land on the + // existing UNPUBLISHED post (otherwise re-runs silently + // discard their output). Published posts stay frozen + // (CPO guardrail 5) — refresh skips them untouched. + return $this->refresh_unpublished_post( $existing_id, $content, $idea, $review ); } } @@ -157,6 +157,60 @@ class PRAutoBlogger_Publisher { return $post_id; } + /** + * Refresh an existing post from re-run output — unpublished only. + * + * Idempotent-resume calls land here too (same content in = same + * content out, so behavior is unchanged for plain resumes). The post + * keeps its CURRENT status: a re-run never publishes an unpublished + * post — publication stays an explicit Review Queue / WP editor + * action. Published (and scheduled/private) posts are returned + * untouched: the run is frozen (CPO guardrail 5). + * + * Side effects: wp_update_post + post meta updates (unpublished only). + * + * @param int $post_id Existing post ID. + * @param string $content Regenerated HTML content. + * @param PRAutoBlogger_Article_Idea $idea The idea. + * @param PRAutoBlogger_Editorial_Review $review The fresh review. + * @return int The post ID. + */ + private function refresh_unpublished_post( int $post_id, string $content, PRAutoBlogger_Article_Idea $idea, PRAutoBlogger_Editorial_Review $review ): int { + $post = get_post( $post_id ); + if ( PRAutoBlogger_Rerun_Eligibility::post_frozen( $post ) ) { + PRAutoBlogger_Logger::instance()->info( + sprintf( 'Post %d already exists and is published — frozen, skipping content refresh.', $post_id ), + 'publisher' + ); + return $post_id; + } + + // Same sanitize chain as create_post(). + $clean_content = PRAutoBlogger_Post_Assembler::sanitize_llm_content( $content ); + $linked_content = PRAutoBlogger_Peptide_Linker::inject_links( $clean_content ); + + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => sanitize_text_field( $idea->get_suggested_title() ), + 'post_content' => wp_kses_post( $linked_content ), + ) + ); + + update_post_meta( $post_id, '_prautoblogger_editor_verdict', $review->get_verdict() ); + update_post_meta( $post_id, '_prautoblogger_editor_notes', $review->get_notes() ); + update_post_meta( $post_id, '_prautoblogger_quality_score', $review->get_quality_score() ); + update_post_meta( $post_id, '_prautoblogger_seo_score', $review->get_seo_score() ); + update_post_meta( $post_id, '_prautoblogger_generated_at', gmdate( 'c' ) ); + + PRAutoBlogger_Logger::instance()->info( + sprintf( 'Post %d refreshed from re-run output (status preserved: %s).', $post_id, (string) ( $post->post_status ?? '' ) ), + 'publisher' + ); + + return $post_id; + } + /** * Build post_meta array for generation metadata. * diff --git a/includes/core/class-rerun-eligibility.php b/includes/core/class-rerun-eligibility.php new file mode 100644 index 0000000..6ac77ca --- /dev/null +++ b/includes/core/class-rerun-eligibility.php @@ -0,0 +1,214 @@ + Downstream stage names ([] for non-chain stages). + */ + public static function downstream_of( string $stage ): array { + $idx = array_search( $stage, self::ITEM_STAGE_CHAIN, true ); + if ( false === $idx ) { + return array(); + } + return array_slice( self::ITEM_STAGE_CHAIN, $idx + 1 ); + } + + /** + * The demotion set for re-run-from-here: the stage itself plus all + * downstream chain stages. + * + * @param string $stage Stage name. + * @return array + */ + public static function rebuild_set( string $stage ): array { + $idx = array_search( $stage, self::ITEM_STAGE_CHAIN, true ); + if ( false === $idx ) { + return array(); + } + return array_slice( self::ITEM_STAGE_CHAIN, $idx ); + } + + /** + * Whether a post is frozen for pipeline re-runs (guardrail 5). + * + * @param WP_Post|null $post The post, or null when none exists yet. + * @return bool + */ + public static function post_frozen( ?WP_Post $post ): bool { + return ( $post instanceof WP_Post ) + && in_array( (string) $post->post_status, self::FROZEN_STATUSES, true ); + } + + /** + * Full re-run eligibility check for a (run, post) pair. + * + * Returns ok=false with an operator-readable reason for: missing/ + * frozen post, missing run row, run actively executing, or the + * global generation lock being held. Called from the AJAX layer for + * UI affordances AND re-validated by the cron handler under the lock + * (state can change between click and pickup). + * + * @param string $run_id Run UUID. + * @param int $post_id Post ID (0 = run has no post yet). + * @param bool $require_idle_lock Whether a held generation lock blocks (true for actions). + * @return array{ok: bool, reason: string} Verdict + reason ('' when ok). + */ + public static function check( string $run_id, int $post_id, bool $require_idle_lock = true ): array { + if ( '' === $run_id ) { + return self::no( __( 'This article has no recorded generation run.', 'prautoblogger' ) ); + } + + $post = $post_id > 0 ? get_post( $post_id ) : null; + if ( self::post_frozen( $post ) ) { + return self::no( __( 'This article is published — its run is frozen. Edit the article in the WordPress editor instead.', 'prautoblogger' ) ); + } + + $run = PRAutoBlogger_Run_State::get_run( $run_id ); + if ( null === $run ) { + return self::no( __( 'No run ledger row exists for this article (pre-substrate run).', 'prautoblogger' ) ); + } + if ( in_array( (string) $run['status'], array( 'pending', 'running' ), true ) ) { + return self::no( __( 'This run is currently executing. Wait for it to finish (or for the reaper to mark it failed).', 'prautoblogger' ) ); + } + + if ( $require_idle_lock && null !== PRAutoBlogger_Generation_Lock::get_acquired_at() ) { + return self::no( __( 'Another generation is in progress (lock held). Try again when it completes.', 'prautoblogger' ) ); + } + + return array( + 'ok' => true, + 'reason' => '', + ); + } + + /** + * Replay-specific check: base eligibility + editable stage + a saved + * input fork whose body has not been retention-pruned. + * + * @param string $run_id Run UUID. + * @param int $post_id Post ID. + * @param string $stage Stage name. + * @param string $agent_role Stage row agent role. + * @param string $item_key Stage row item key. + * @param bool $require_idle_lock False when the caller already owns the lock (cron handler). + * @return array{ok: bool, reason: string} + */ + public static function check_replay( string $run_id, int $post_id, string $stage, string $agent_role, string $item_key, bool $require_idle_lock = true ): array { + if ( ! self::is_editable_stage( $stage ) ) { + return self::no( __( 'This stage cannot be replayed with an edited input.', 'prautoblogger' ) ); + } + $base = self::check( $run_id, $post_id, $require_idle_lock ); + if ( ! $base['ok'] ) { + return $base; + } + $fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $run_id, $stage, $agent_role, $item_key ); + if ( null === $fork ) { + return self::no( __( 'Save an edited input for this stage first — re-run executes your latest saved version.', 'prautoblogger' ) ); + } + if ( empty( $fork['request_json'] ) ) { + return self::no( __( 'The saved input for this stage was pruned by the retention cron and can no longer be replayed.', 'prautoblogger' ) ); + } + return $base; + } + + /** + * Rebuild-specific check: base eligibility + chain stage + idea seed + * available (runs started before v0.20.0 have no seed). + * + * @param string $run_id Run UUID. + * @param int $post_id Post ID. + * @param string $stage Target stage. + * @param string $item_key Item key. + * @param bool $require_idle_lock False when the caller already owns the lock (cron handler). + * @return array{ok: bool, reason: string} + */ + public static function check_rebuild( string $run_id, int $post_id, string $stage, string $item_key, bool $require_idle_lock = true ): array { + if ( ! self::is_chain_stage( $stage ) ) { + return self::no( __( 'Re-run from here is only available on article pipeline stages.', 'prautoblogger' ) ); + } + $base = self::check( $run_id, $post_id, $require_idle_lock ); + if ( ! $base['ok'] ) { + return $base; + } + if ( null === PRAutoBlogger_Stage_Input_Store::get_seed( $run_id, $item_key ) ) { + return self::no( __( 'No idea seed is stored for this run (recorded from v0.20.0 onward) — the pipeline cannot rebuild it safely.', 'prautoblogger' ) ); + } + return $base; + } + + /** + * Shorthand for a negative verdict. + * + * @param string $reason Operator-readable reason. + * @return array{ok: bool, reason: string} + */ + private static function no( string $reason ): array { + return array( + 'ok' => false, + 'reason' => $reason, + ); + } +} diff --git a/includes/core/class-rerun-executor.php b/includes/core/class-rerun-executor.php new file mode 100644 index 0000000..ce2411f --- /dev/null +++ b/includes/core/class-rerun-executor.php @@ -0,0 +1,222 @@ +getMessage() ); + } catch ( \Throwable $e ) { + PRAutoBlogger_Run_Stage_State::fail( $run_id, $stage, $agent_role, $item_key ); + PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' ); + PRAutoBlogger_Logger::instance()->error( + sprintf( 'Stage replay %s for run %s/%s: %s', get_class( $e ), $run_id, $stage, $e->getMessage() ), + 'rerun' + ); + PRAutoBlogger_Rerun_Job_Support::finish_error( $e->getMessage() ); + } finally { + PRAutoBlogger_Generation_Lock::release(); + } + } + + /** + * Cron handler: re-run from a stage — demote it + downstream, then + * re-enter the worker so demoted stages rebuild from current + * upstream snapshots. + * + * Side effects: run_stages/runs mutations, worker LLM calls + * (governed), post refresh, status transient, lock acquire/release. + * + * @param string $run_id Run UUID. + * @param int $post_id Post the dossier acted on. + * @param string $stage Target stage (start of the rebuild set). + * @param string $item_key Item key. + * @return void + */ + public function on_rebuild_job( string $run_id, int $post_id, string $stage, string $item_key ): void { + PRAutoBlogger_Rerun_Job_Support::harden_process(); + if ( ! PRAutoBlogger_Rerun_Job_Support::acquire_lock_or_report() ) { + return; + } + + try { + // require_idle_lock=false: THIS handler owns the lock. + $check = PRAutoBlogger_Rerun_Eligibility::check_rebuild( $run_id, $post_id, $stage, $item_key, false ); + if ( ! $check['ok'] ) { + PRAutoBlogger_Rerun_Job_Support::finish_error( $check['reason'] ); + return; + } + if ( PRAutoBlogger_Rerun_Job_Support::monthly_budget_exceeded() ) { + PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'Monthly API budget exceeded. Re-run not started.', 'prautoblogger' ) ); + return; + } + + $idea_json = PRAutoBlogger_Stage_Input_Store::get_seed( $run_id, $item_key ); + $idea_data = null !== $idea_json ? json_decode( $idea_json, true ) : null; + if ( ! is_array( $idea_data ) ) { + PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The stored idea seed could not be decoded.', 'prautoblogger' ) ); + return; + } + $idea = new PRAutoBlogger_Article_Idea( $idea_data ); + + $job_start = current_time( 'mysql' ); + $demoted = PRAutoBlogger_Rerun_Eligibility::rebuild_set( $stage ); + + if ( ! PRAutoBlogger_Run_State::reopen( $run_id ) ) { + PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The run could not be reopened for new spend.', 'prautoblogger' ) ); + return; + } + PRAutoBlogger_Run_Stage_Rerun_State::demote_to_pending( $run_id, $item_key, $demoted ); + + PRAutoBlogger_Rerun_Job_Support::broadcast( + sprintf( + /* translators: %s: stage display label. */ + __( 'Re-running the pipeline from %s…', 'prautoblogger' ), + PRAutoBlogger_Stage_Display_Map::label( $stage ) + ) + ); + + $tracker = new PRAutoBlogger_Cost_Tracker(); + $tracker->set_run_id( $run_id ); + $result = ( new PRAutoBlogger_Article_Worker( $tracker ) )->generate( $idea ); + + // The worker traps its own failures (fail_open_for_item); + // publish-done is the success signal. + $succeeded = PRAutoBlogger_Run_Stage_State::is_done( $run_id, 'publish', '', $item_key ); + + // Decisions recorded while rebuilding a human-modified item + // derive from edited content — flag them (guardrail 2). + if ( PRAutoBlogger_Run_Stage_Rerun_State::item_has_human_modified( $run_id, $item_key ) ) { + PRAutoBlogger_Audit_Writer::flag_decisions_human_modified( $run_id, $demoted, $job_start ); + } + + PRAutoBlogger_Run_State::mark_status( $run_id, $succeeded ? 'done' : 'failed' ); + if ( $succeeded ) { + PRAutoBlogger_Rerun_Job_Support::finish_complete( (float) $result['cost'] ); + } else { + PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The re-run did not complete — check the Activity Log and the dossier stage states.', 'prautoblogger' ) ); + } + } catch ( \Throwable $e ) { + PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' ); + PRAutoBlogger_Logger::instance()->error( + sprintf( 'Rebuild from %s %s for run %s: %s', $stage, get_class( $e ), $run_id, $e->getMessage() ), + 'rerun' + ); + PRAutoBlogger_Rerun_Job_Support::finish_error( $e->getMessage() ); + } finally { + PRAutoBlogger_Generation_Lock::release(); + } + } + +} diff --git a/includes/core/class-rerun-job-support.php b/includes/core/class-rerun-job-support.php new file mode 100644 index 0000000..69ddc9a --- /dev/null +++ b/includes/core/class-rerun-job-support.php @@ -0,0 +1,158 @@ + 0.01, + 'blocking' => false, + 'sslverify' => false, + ) + ); + } + + /** Raise process limits for a background LLM job (cron context). */ + public static function harden_process(): void { + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + @ignore_user_abort( true ); + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + @set_time_limit( 300 ); + } + + /** + * Acquire the global generation mutex or report the conflict. + * + * @return bool Whether the lock was acquired. + */ + public static function acquire_lock_or_report(): bool { + if ( PRAutoBlogger_Generation_Lock::acquire() ) { + return true; + } + self::finish_error( __( 'Could not acquire the generation lock — another run is in progress. Re-run again when it completes.', 'prautoblogger' ) ); + return false; + } + + /** + * Whether the monthly budget hard-stop blocks new spend. + * + * @return bool + */ + public static function monthly_budget_exceeded(): bool { + return ( new PRAutoBlogger_Cost_Tracker() )->is_budget_exceeded(); + } + + /** + * Restore a terminal run status after an aborted-but-reopened job. + * + * @param string $run_id Run UUID. + * @param string|null $status The pre-reopen status (done/failed/halted). + * @return void + */ + public static function restore_terminal( string $run_id, ?string $status ): void { + if ( null !== $status && in_array( $status, array( 'done', 'failed', 'halted' ), true ) ) { + PRAutoBlogger_Run_State::mark_status( $run_id, $status ); + } + } + + /** + * Update the live status transient (board + dossier polling). + * + * @param string $message Stage message. + * @return void + */ + public static function broadcast( string $message ): void { + set_transient( + PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT, + array( + 'status' => 'running', + 'stage' => $message, + 'started' => time(), + 'last_updated' => time(), + ), + PRAutoBlogger_Generation_Status_Poller::STATUS_TTL + ); + } + + /** + * Final transient: success (board-compatible shape). + * + * @param float $cost Job spend in USD. + * @return void + */ + public static function finish_complete( float $cost ): void { + set_transient( + PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT, + array( + 'status' => 'complete', + 'generated' => 0, + 'published' => 0, + 'rejected' => 0, + 'cost' => $cost, + 'message' => __( 'Re-run complete.', 'prautoblogger' ), + ), + PRAutoBlogger_Generation_Status_Poller::STATUS_TTL + ); + } + + /** + * Final transient: error (board-compatible shape) + log. + * + * @param string $message Operator-facing error. + * @return void + */ + public static function finish_error( string $message ): void { + PRAutoBlogger_Logger::instance()->warning( 'Re-run job: ' . $message, 'rerun' ); + set_transient( + PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT, + array( + 'status' => 'error', + 'message' => $message, + ), + PRAutoBlogger_Generation_Status_Poller::STATUS_TTL + ); + } +} diff --git a/includes/core/class-run-stage-rerun-state.php b/includes/core/class-run-stage-rerun-state.php index 30c5b14..7471e76 100644 --- a/includes/core/class-run-stage-rerun-state.php +++ b/includes/core/class-run-stage-rerun-state.php @@ -145,4 +145,28 @@ class PRAutoBlogger_Run_Stage_Rerun_State { ); return is_numeric( $updated ) ? (int) $updated : 0; } + /** + * Whether any stage row of this item carries the human_modified flag + * (drives derived-decision flagging during rebuilds — guardrail 2). + * + * @param string $run_id Run UUID. + * @param string $item_key Item key. + * @return bool + */ + public static function item_has_human_modified( string $run_id, string $item_key ): bool { + if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) { + return false; + } + global $wpdb; + $table = PRAutoBlogger_Run_Stage_State::table_name(); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $found = $wpdb->get_var( + $wpdb->prepare( + "SELECT 1 FROM {$table} WHERE run_id = %s AND item_key = %s AND human_modified = 1 LIMIT 1", + $run_id, + $item_key + ) + ); + return null !== $found; + } } diff --git a/includes/core/class-run-state.php b/includes/core/class-run-state.php index b5e4cd8..872b7ee 100644 --- a/includes/core/class-run-state.php +++ b/includes/core/class-run-state.php @@ -252,6 +252,41 @@ class PRAutoBlogger_Run_State { return is_array( $rows ) ? $rows : array(); } + /** + * Reopen a terminal run for operator-deliberate new spend (M3 + * re-runs). done/failed/halted -> running; finished_at cleared; the + * cost ceiling is RE-SNAPSHOTTED from the current setting — the + * deliberate re-run action adopts the operator's current per-run + * policy (can lower as well as raise), exactly as a new run would. + * Accumulated reserved/settled spend is kept: the governor treats + * re-runs as new spend on the SAME run (CPO guardrail 4). + * + * The conditional UPDATE is the atomic gate: an actively executing + * run (pending/running) can never be reopened. + * + * @param string $run_id Run UUID. + * @return bool Whether the run transitioned to running. + */ + public static function reopen( string $run_id ): bool { + if ( '' === $run_id || ! self::is_available() ) { + return false; + } + global $wpdb; + $table = self::table_name(); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $updated = $wpdb->query( + $wpdb->prepare( + "UPDATE {$table} + SET status = 'running', ceiling_usd = %f, updated_at = %s, finished_at = NULL + WHERE run_id = %s AND status IN ('done','failed','halted')", + self::ceiling_setting(), + current_time( 'mysql' ), + $run_id + ) + ); + return 1 === (int) $updated; + } + /** Reset per-request caches (tests). */ public static function flush_cache(): void { self::$table_ok = null; diff --git a/includes/core/class-stage-replay.php b/includes/core/class-stage-replay.php new file mode 100644 index 0000000..d1741ab --- /dev/null +++ b/includes/core/class-stage-replay.php @@ -0,0 +1,189 @@ + $body Decoded request body. + * @param string $stage Stage being replayed. + * @return array Options for send_chat_completion(). + */ + public static function options_from_body( array $body, string $stage ): array { + $options = array(); + if ( isset( $body['temperature'] ) ) { + $options['temperature'] = $body['temperature']; + } + if ( isset( $body['response_format'] ) ) { + $options['response_format'] = $body['response_format']; + } + + $reasoning_active = isset( $body['reasoning'] ) && false !== ( $body['reasoning']['enabled'] ?? true ); + if ( $reasoning_active ) { + $options['reasoning'] = $body['reasoning']; + if ( isset( $body['max_tokens'] ) ) { + $cap = isset( $body['reasoning']['max_tokens'] ) ? (int) $body['reasoning']['max_tokens'] : 0; + // The builder raised max_tokens by the cap (completion + // headroom); hand back the caller-level value so the + // builder's re-application is not compounded. + $options['max_tokens'] = max( 1, (int) $body['max_tokens'] - $cap ); + } + } else { + // Pin reasoning OFF (same per-call override shape the + // empty-completion retry uses) so a globally-enabled + // reasoning setting cannot reshape the replay. + $options['reasoning'] = array( 'enabled' => false ); + if ( isset( $body['max_tokens'] ) ) { + $options['max_tokens'] = (int) $body['max_tokens']; + } + } + + $options['stage'] = $stage; + $options['prompt_key'] = PRAutoBlogger_Stage_Display_Map::default_prompt_key( $stage ); + + return $options; + } + + /** + * Decode + validate a fork body. Returns null when the JSON is not a + * replayable chat body (model string + non-empty messages list of + * role/content pairs). + * + * @param string $request_json Stored fork body. + * @return array|null Decoded body, or null when invalid. + */ + public static function decode_body( string $request_json ): ?array { + $body = json_decode( $request_json, true ); + if ( ! is_array( $body ) || empty( $body['model'] ) || ! is_string( $body['model'] ) ) { + return null; + } + if ( empty( $body['messages'] ) || ! is_array( $body['messages'] ) ) { + return null; + } + foreach ( $body['messages'] as $message ) { + if ( ! is_array( $message ) || ! isset( $message['role'], $message['content'] ) + || ! is_string( $message['role'] ) || ! is_string( $message['content'] ) ) { + return null; + } + } + return $body; + } + + /** + * Execute the replay call and persist its results. + * + * The caller (Rerun_Executor) has already: re-validated eligibility, + * taken the generation lock, reopened the run, demoted the stage row + * to running and stale-marked downstream. This method performs the + * governed LLM call and the success-path writes. + * + * Side effects: one OpenRouter call (governor-reserved), run_stages + * done-write, one generation_log row (with the replayed request via + * the B1 recorder), run_decisions human_modified flags. + * + * @param string $run_id Run UUID. + * @param string $stage Stage name. + * @param string $agent_role Stage row agent role. + * @param string $item_key Stage row item key. + * @param array $body Decoded fork body (from decode_body()). + * @return array{content: string, cost: float, model: string} Replay result. + * + * @throws \RuntimeException On provider failure or empty output. + * @throws PRAutoBlogger_Cost_Ceiling_Exception When the governor blocks the call. + */ + public static function run( string $run_id, string $stage, string $agent_role, string $item_key, array $body ): array { + $tracker = new PRAutoBlogger_Cost_Tracker(); + // Joins this process to the run: Run_Context (governor guard), + // ledger row, prompt pins — replay spend reserves against the + // SAME per-run ceiling as the original calls (guardrail 4). + $tracker->set_run_id( $run_id ); + + $llm = new PRAutoBlogger_OpenRouter_Provider(); + $options = self::options_from_body( $body, $stage ); + $response = $llm->send_chat_completion( $body['messages'], (string) $body['model'], $options ); + + // Same belt as the writer path: an empty replay output must fail + // the stage, never overwrite a good snapshot with nothing. + if ( '' === trim( (string) $response['content'] ) ) { + throw new \RuntimeException( + sprintf( + /* translators: 1: stage name, 2: model identifier. */ + __( 'Replay of stage "%1$s" produced empty content (model %2$s) — failing the replay instead of storing an empty output.', 'prautoblogger' ), + $stage, + (string) $response['model'] + ) + ); + } + + $cost = $llm->estimate_cost( + (string) $response['model'], + (int) $response['prompt_tokens'], + (int) $response['completion_tokens'] + ); + + PRAutoBlogger_Run_Stage_State::done( $run_id, $stage, $agent_role, $item_key, (string) $response['content'], $cost ); + + $tracker->log_api_call( + null, + $stage, + $llm->get_provider_name(), + (string) $response['model'], + (int) $response['prompt_tokens'], + (int) $response['completion_tokens'], + 'success', + '', + '' !== $agent_role ? $agent_role : null, + null + ); + + // Guardrail 2: decision rows for the replayed stage carry the + // human_modified flag (no-op when the stage has none). + PRAutoBlogger_Audit_Writer::flag_decisions_human_modified( $run_id, array( $stage ) ); + + return array( + 'content' => (string) $response['content'], + 'cost' => $cost, + 'model' => (string) $response['model'], + ); + } +} diff --git a/tests/unit/Core/PublisherRerunUpdateTest.php b/tests/unit/Core/PublisherRerunUpdateTest.php new file mode 100644 index 0000000..2ae02d8 --- /dev/null +++ b/tests/unit/Core/PublisherRerunUpdateTest.php @@ -0,0 +1,134 @@ + + */ + private array $updated_meta = array(); + + protected function setUp(): void { + parent::setUp(); + + $this->idea = new \PRAutoBlogger_Article_Idea( $this->get_article_idea_fixture() ); + $this->review = new \PRAutoBlogger_Editorial_Review( $this->get_editorial_review_fixture() ); + + $this->stub_get_option( array() ); + Functions\when( 'sanitize_text_field' )->returnArg(); + Functions\when( 'wp_kses_post' )->returnArg(); + Functions\when( 'wp_strip_all_tags' )->alias( + static function ( $text ) { + return trim( strip_tags( (string) $text ) ); + } + ); + Functions\when( 'is_wp_error' )->justReturn( false ); + Functions\when( 'apply_filters' )->returnArg( 2 ); + + $this->updated_args = null; + $this->updated_meta = array(); + Functions\when( 'wp_update_post' )->alias( + function ( $args ) { + $this->updated_args = (array) $args; + return $args['ID'] ?? 0; + } + ); + Functions\when( 'update_post_meta' )->alias( + function ( $post_id, $key, $value ) { + $this->updated_meta[ (string) $key ] = $value; + return true; + } + ); + + // find_existing_post() resolves post 55 for this (run, idea). + $wpdb = $this->create_mock_wpdb(); + $wpdb->method( 'prepare' )->willReturn( 'prepared' ); + $wpdb->method( 'get_var' )->willReturn( '55' ); + $GLOBALS['wpdb'] = $wpdb; + } + + protected function tearDown(): void { + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** Stub get_post for post 55 with a status. */ + private function stub_existing_post( string $status ): void { + $post = new \WP_Post(); + $post->ID = 55; + $post->post_status = $status; + Functions\when( 'get_post' )->justReturn( $post ); + } + + /** + * Re-run output refreshes the existing draft: content + title are + * updated, verdict meta refreshed — and the post status is NEVER + * touched (a re-run must not publish; publication stays an explicit + * operator action). + */ + public function test_rerun_refreshes_existing_draft_without_status_change(): void { + $this->stub_existing_post( 'draft' ); + Functions\when( 'wp_insert_post' )->alias( + static function () { + throw new \RuntimeException( 'wp_insert_post must not be called for an existing post' ); + } + ); + + $publisher = new \PRAutoBlogger_Publisher(); + $post_id = $publisher->save_as_draft( '

Regenerated content

', $this->idea, $this->review, 'run-1' ); + + $this->assertSame( 55, $post_id ); + $this->assertIsArray( $this->updated_args ); + $this->assertSame( 55, $this->updated_args['ID'] ); + $this->assertStringContainsString( 'Regenerated content', (string) $this->updated_args['post_content'] ); + $this->assertArrayNotHasKey( 'post_status', $this->updated_args ); + $this->assertSame( $this->review->get_verdict(), $this->updated_meta['_prautoblogger_editor_verdict'] ); + $this->assertArrayHasKey( '_prautoblogger_generated_at', $this->updated_meta ); + } + + /** + * Published post: frozen — returned untouched, no wp_update_post, no + * meta writes, no duplicate insert (guardrail 5 at the data layer). + */ + public function test_published_post_is_never_updated(): void { + $this->stub_existing_post( 'publish' ); + Functions\when( 'wp_insert_post' )->alias( + static function () { + throw new \RuntimeException( 'wp_insert_post must not be called for an existing post' ); + } + ); + + $publisher = new \PRAutoBlogger_Publisher(); + $post_id = $publisher->save_as_draft( '

Regenerated content

', $this->idea, $this->review, 'run-1' ); + + $this->assertSame( 55, $post_id ); + $this->assertNull( $this->updated_args ); + $this->assertSame( array(), $this->updated_meta ); + } +} diff --git a/tests/unit/Core/RerunEligibilityTest.php b/tests/unit/Core/RerunEligibilityTest.php new file mode 100644 index 0000000..2005de3 --- /dev/null +++ b/tests/unit/Core/RerunEligibilityTest.php @@ -0,0 +1,182 @@ +wpdb = $this->create_mock_wpdb(); + $this->wpdb->options = 'wp_options'; + $GLOBALS['wpdb'] = $this->wpdb; + $this->wpdb->method( 'prepare' )->willReturnCallback( + static function ( $sql, ...$args ) { + return $sql . ' /* ' . implode( ',', array_map( 'strval', array_filter( $args, 'is_scalar' ) ) ) . ' */'; + } + ); + \PRAutoBlogger_Run_State::flush_cache(); + \PRAutoBlogger_Stage_Input_Store::flush_cache(); + } + + protected function tearDown(): void { + \PRAutoBlogger_Run_State::flush_cache(); + \PRAutoBlogger_Stage_Input_Store::flush_cache(); + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** + * Wire the mock for: runs table exists, run row with given status, + * generation lock free. + */ + private function wire_run( string $run_status ): void { + $this->wpdb->method( 'get_var' )->willReturnCallback( + static function ( $sql ) { + $sql = (string) $sql; + if ( false !== strpos( $sql, 'SHOW TABLES' ) ) { + // Both runs + stage_inputs probes succeed. + if ( false !== strpos( $sql, 'stage_inputs' ) ) { + return 'wp_prautoblogger_stage_inputs'; + } + return 'wp_prautoblogger_runs'; + } + return null; // Lock option absent -> lock free; seed absent. + } + ); + $this->wpdb->method( 'get_row' )->willReturn( + array( + 'run_id' => 'run-1', + 'status' => $run_status, + 'ceiling_usd' => '0.50', + ) + ); + } + + /** Stub get_post to return a post with the given status. */ + private function stub_post( string $status ): void { + $post = new \WP_Post(); + $post->ID = 99; + $post->post_status = $status; + Functions\when( 'get_post' )->justReturn( $post ); + } + + /** + * Published, scheduled, and private posts are all frozen — no + * edit+rerun (CPO guardrail 5). Draft and pending are not. + */ + public function test_published_post_lockout_covers_all_frozen_statuses(): void { + foreach ( array( 'publish', 'future', 'private' ) as $frozen ) { + $post = new \WP_Post(); + $post->post_status = $frozen; + $this->assertTrue( \PRAutoBlogger_Rerun_Eligibility::post_frozen( $post ), "status {$frozen} must freeze" ); + } + foreach ( array( 'draft', 'pending' ) as $open ) { + $post = new \WP_Post(); + $post->post_status = $open; + $this->assertFalse( \PRAutoBlogger_Rerun_Eligibility::post_frozen( $post ), "status {$open} must not freeze" ); + } + // A run with no post yet is not frozen. + $this->assertFalse( \PRAutoBlogger_Rerun_Eligibility::post_frozen( null ) ); + } + + /** + * check() rejects a published post with the WP-editor redirect copy + * (server-side enforcement, not just hidden UI). + */ + public function test_check_rejects_published_post(): void { + $this->wire_run( 'done' ); + $this->stub_post( 'publish' ); + + $verdict = \PRAutoBlogger_Rerun_Eligibility::check( 'run-1', 99 ); + + $this->assertFalse( $verdict['ok'] ); + $this->assertStringContainsString( 'published', $verdict['reason'] ); + } + + /** + * check() rejects an actively executing run — "in progress" in the + * CPO sense means unpublished workflow state, not mid-execution. + */ + public function test_check_rejects_actively_executing_run(): void { + $this->wire_run( 'running' ); + $this->stub_post( 'draft' ); + + $verdict = \PRAutoBlogger_Rerun_Eligibility::check( 'run-1', 99 ); + + $this->assertFalse( $verdict['ok'] ); + $this->assertStringContainsString( 'currently executing', $verdict['reason'] ); + } + + /** + * A terminal run on an unpublished post is eligible: done, failed + * and halted (held) runs can all be re-run. + */ + public function test_check_accepts_terminal_runs_on_unpublished_posts(): void { + foreach ( array( 'done', 'failed', 'halted' ) as $status ) { + $wpdb = $this->create_mock_wpdb(); + $wpdb->options = 'wp_options'; + $wpdb->method( 'prepare' )->willReturnCallback( static fn( $sql, ...$a ) => (string) $sql ); + $wpdb->method( 'get_var' )->willReturnCallback( + static function ( $sql ) { + return ( false !== strpos( (string) $sql, 'SHOW TABLES' ) ) ? 'wp_prautoblogger_runs' : null; + } + ); + $wpdb->method( 'get_row' )->willReturn( array( 'status' => $status ) ); + $GLOBALS['wpdb'] = $wpdb; + \PRAutoBlogger_Run_State::flush_cache(); + $this->stub_post( 'draft' ); + + $verdict = \PRAutoBlogger_Rerun_Eligibility::check( 'run-1', 99 ); + $this->assertTrue( $verdict['ok'], "run status {$status} must be eligible: {$verdict['reason']}" ); + } + } + + /** + * Editable-stage policy: writer chat stages only. review/analysis/ + * image stages get the replay affordance refused at the policy layer. + */ + public function test_editable_stage_policy(): void { + foreach ( array( 'outline', 'draft', 'polish' ) as $stage ) { + $this->assertTrue( \PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $stage ) ); + } + foreach ( array( 'review', 'publish', 'analysis', 'research', 'llm_research', 'image_a', 'image_b', 'curate' ) as $stage ) { + $this->assertFalse( \PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $stage ) ); + } + + $verdict = \PRAutoBlogger_Rerun_Eligibility::check_replay( 'run-1', 99, 'review', 'editor', 'idea:abc' ); + $this->assertFalse( $verdict['ok'] ); + } + + /** + * Downstream/rebuild sets follow the canonical chain — these sets + * drive stale marking and demotion, so order and bounds matter. + */ + public function test_downstream_and_rebuild_sets(): void { + $this->assertSame( array( 'polish', 'review', 'publish' ), \PRAutoBlogger_Rerun_Eligibility::downstream_of( 'draft' ) ); + $this->assertSame( array( 'publish' ), \PRAutoBlogger_Rerun_Eligibility::downstream_of( 'review' ) ); + $this->assertSame( array(), \PRAutoBlogger_Rerun_Eligibility::downstream_of( 'publish' ) ); + $this->assertSame( array(), \PRAutoBlogger_Rerun_Eligibility::downstream_of( 'image_a' ) ); + $this->assertSame( array( 'review', 'publish' ), \PRAutoBlogger_Rerun_Eligibility::rebuild_set( 'review' ) ); + $this->assertSame( + array( 'outline', 'draft', 'polish', 'review', 'publish' ), + \PRAutoBlogger_Rerun_Eligibility::rebuild_set( 'outline' ) + ); + } +} diff --git a/tests/unit/Core/RerunExecutorTest.php b/tests/unit/Core/RerunExecutorTest.php new file mode 100644 index 0000000..db3c57d --- /dev/null +++ b/tests/unit/Core/RerunExecutorTest.php @@ -0,0 +1,217 @@ +query(). + * + * @var string[] + */ + private array $queries = array(); + + /** + * Transients captured from set_transient(). + * + * @var array + */ + private array $transients = array(); + + protected function setUp(): void { + parent::setUp(); + $this->queries = array(); + $this->transients = array(); + $this->wpdb = $this->create_mock_wpdb(); + $this->wpdb->options = 'wp_options'; + $GLOBALS['wpdb'] = $this->wpdb; + $this->wpdb->method( 'prepare' )->willReturnCallback( + static function ( $sql, ...$args ) { + if ( 1 === count( $args ) && is_array( $args[0] ) ) { + $args = $args[0]; + } + return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */'; + } + ); + Functions\when( 'set_transient' )->alias( + function ( $key, $value, $ttl = 0 ) { + $this->transients[] = (array) $value; + return true; + } + ); + // Monthly budget 0 = no limit (fast false path). + $this->stub_get_option( array( 'prautoblogger_monthly_budget_usd' => '0' ) ); + \PRAutoBlogger_Run_State::flush_cache(); + \PRAutoBlogger_Run_Stage_State::flush_cache(); + \PRAutoBlogger_Stage_Input_Store::flush_cache(); + } + + protected function tearDown(): void { + \PRAutoBlogger_Run_State::flush_cache(); + \PRAutoBlogger_Run_Stage_State::flush_cache(); + \PRAutoBlogger_Stage_Input_Store::flush_cache(); + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** SHOW TABLES probes succeed for every substrate table. */ + private function tables_exist(): void { + $this->wpdb->method( 'get_var' )->willReturnCallback( + static function ( $sql ) { + $sql = (string) $sql; + if ( false !== strpos( $sql, 'SHOW TABLES' ) ) { + if ( false !== strpos( $sql, 'stage_inputs' ) ) { + return 'wp_prautoblogger_stage_inputs'; + } + if ( false !== strpos( $sql, 'run_stages' ) ) { + return 'wp_prautoblogger_run_stages'; + } + return 'wp_prautoblogger_runs'; + } + return null; + } + ); + } + + /** + * queue() never executes anything: it schedules a single cron event + * with a uniqueness token appended and broadcasts a 'running' status + * transient so board/dossier polling shows the queued state + * (chained-cron semantics — the CPO hard constraint). + */ + public function test_queue_schedules_cron_and_broadcasts_queued_state(): void { + $scheduled = null; + Functions\when( 'wp_schedule_single_event' )->alias( + static function ( $ts, $hook, $args ) use ( &$scheduled ) { + $scheduled = array( + 'hook' => $hook, + 'args' => $args, + ); + return true; + } + ); + Functions\when( 'spawn_cron' )->justReturn( true ); + Functions\when( 'wp_remote_post' )->justReturn( array() ); + Functions\when( 'site_url' )->justReturn( 'https://example.com' ); + + \PRAutoBlogger_Rerun_Job_Support::queue( + \PRAutoBlogger_Rerun_Executor::REPLAY_ACTION, + array( 'run-1', 99, 'draft', 'writer', 'idea:abc' ), + 'Queued: re-running Draft…' + ); + + $this->assertSame( \PRAutoBlogger_Rerun_Executor::REPLAY_ACTION, $scheduled['hook'] ); + $this->assertCount( 6, $scheduled['args'] ); // 5 args + uniqueness token. + $this->assertSame( 'run-1', $scheduled['args'][0] ); + $this->assertCount( 1, $this->transients ); + $this->assertSame( 'running', $this->transients[0]['status'] ); + $this->assertSame( 'Queued: re-running Draft…', $this->transients[0]['stage'] ); + } + + /** + * Under-lock re-validation: a post published between click and cron + * pickup aborts the replay with an error transient and ZERO + * run_stages mutations (guardrail 5 cannot be raced). + */ + public function test_replay_job_aborts_on_frozen_post_without_mutations(): void { + $this->tables_exist(); + $this->wpdb->method( 'query' )->willReturnCallback( + function ( $sql ) { + $this->queries[] = (string) $sql; + return 1; // Lock cleanup + acquire succeed. + } + ); + $post = new \WP_Post(); + $post->ID = 99; + $post->post_status = 'publish'; + Functions\when( 'get_post' )->justReturn( $post ); + + ( new \PRAutoBlogger_Rerun_Executor() )->on_replay_job( 'run-1', 99, 'draft', 'writer', 'idea:abc' ); + + foreach ( $this->queries as $sql ) { + $this->assertStringNotContainsString( 'run_stages', $sql, 'no stage mutation may happen on a frozen post' ); + $this->assertStringNotContainsString( 'run_id = %s AND status IN', $sql, 'the run must not be reopened' ); + } + $last = end( $this->transients ); + $this->assertSame( 'error', $last['status'] ); + $this->assertStringContainsString( 'published', $last['message'] ); + } + + /** + * When the stage demotion fails after the run was reopened (e.g. the + * row is concurrently 'running'), the previous terminal status is + * restored — the run cannot be left dangling in 'running' for the + * reaper to misread. + */ + public function test_replay_job_restores_terminal_status_when_restart_fails(): void { + $this->tables_exist(); + $this->wpdb->method( 'get_row' )->willReturnCallback( + static function ( $sql ) { + $sql = (string) $sql; + if ( false !== strpos( $sql, 'stage_inputs' ) ) { + return array( + 'version' => '2', + 'request_json' => '{"model":"m","messages":[{"role":"user","content":"edited"}]}', + ); + } + return array( + 'run_id' => 'run-1', + 'status' => 'done', + 'ceiling_usd' => '0.50', + ); + } + ); + $this->wpdb->method( 'query' )->willReturnCallback( + function ( $sql ) { + $sql = (string) $sql; + $this->queries[] = $sql; + if ( false !== strpos( $sql, 'attempt = attempt + 1' ) ) { + return 0; // restart() fails — row not demotable. + } + return 1; + } + ); + $post = new \WP_Post(); + $post->ID = 99; + $post->post_status = 'draft'; + Functions\when( 'get_post' )->justReturn( $post ); + + ( new \PRAutoBlogger_Rerun_Executor() )->on_replay_job( 'run-1', 99, 'draft', 'writer', 'idea:abc' ); + + $reopened = false; + $restored = false; + foreach ( $this->queries as $sql ) { + if ( false !== strpos( $sql, "ceiling_usd = %f" ) ) { + $reopened = true; + } + // restore_terminal -> mark_status('done') over the reopened 'running' row. + if ( false !== strpos( $sql, 'SET status = %s' ) && false !== strpos( $sql, 'done' ) + && false !== strpos( $sql, "status IN ('pending','running')" ) ) { + $restored = true; + } + } + $this->assertTrue( $reopened, 'run must have been reopened before restart' ); + $this->assertTrue( $restored, 'terminal status must be restored after the aborted demotion' ); + $last = end( $this->transients ); + $this->assertSame( 'error', $last['status'] ); + } +} diff --git a/tests/unit/Core/RunStateReopenTest.php b/tests/unit/Core/RunStateReopenTest.php new file mode 100644 index 0000000..70d4234 --- /dev/null +++ b/tests/unit/Core/RunStateReopenTest.php @@ -0,0 +1,102 @@ +query(). + * + * @var string[] + */ + private array $queries = array(); + + protected function setUp(): void { + parent::setUp(); + $this->queries = array(); + $this->wpdb = $this->create_mock_wpdb(); + $GLOBALS['wpdb'] = $this->wpdb; + $this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_runs' ); + $this->wpdb->method( 'prepare' )->willReturnCallback( + static function ( $sql, ...$args ) { + return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */'; + } + ); + $this->stub_get_option( array( 'prautoblogger_per_run_cost_ceiling_usd' => '0.75' ) ); + \PRAutoBlogger_Run_State::flush_cache(); + } + + protected function tearDown(): void { + \PRAutoBlogger_Run_State::flush_cache(); + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** + * reopen() transitions ONLY terminal runs (atomic conditional UPDATE) + * and re-snapshots the ceiling from the current setting. + */ + public function test_reopen_gates_on_terminal_status_and_resnapshots_ceiling(): void { + $this->wpdb->method( 'query' )->willReturnCallback( + function ( $sql ) { + $this->queries[] = (string) $sql; + return 1; + } + ); + + $this->assertTrue( \PRAutoBlogger_Run_State::reopen( 'run-1' ) ); + + $sql = $this->queries[0]; + $this->assertStringContainsString( "SET status = 'running'", $sql ); + $this->assertStringContainsString( "status IN ('done','failed','halted')", $sql ); + $this->assertStringContainsString( 'ceiling_usd = %f', $sql ); + $this->assertStringContainsString( '0.75', $sql ); // Current setting value. + $this->assertStringContainsString( 'finished_at = NULL', $sql ); + // Guardrail 4: accumulated spend is NEVER reset by a reopen. + $this->assertStringNotContainsString( 'reserved_usd', $sql ); + $this->assertStringNotContainsString( 'settled_usd', $sql ); + $this->assertStringNotContainsString( 'overage_usd', $sql ); + } + + /** + * No row matched (run is pending/running or absent) -> false, so the + * executor aborts instead of replaying against a closed gate. + */ + public function test_reopen_returns_false_when_not_terminal(): void { + $this->wpdb->method( 'query' )->willReturn( 0 ); + + $this->assertFalse( \PRAutoBlogger_Run_State::reopen( 'run-1' ) ); + } + + /** + * Self-healing: missing table / empty run id -> false, no query. + */ + public function test_reopen_noop_paths(): void { + $this->wpdb->method( 'query' )->willReturnCallback( + function ( $sql ) { + $this->queries[] = (string) $sql; + return 1; + } + ); + + $this->assertFalse( \PRAutoBlogger_Run_State::reopen( '' ) ); + $this->assertCount( 0, $this->queries ); + } +} diff --git a/tests/unit/Core/StageReplayTest.php b/tests/unit/Core/StageReplayTest.php new file mode 100644 index 0000000..c2d5856 --- /dev/null +++ b/tests/unit/Core/StageReplayTest.php @@ -0,0 +1,157 @@ + options inverse transform (the replay must + * round-trip through Request_Builder::build_body() to the SAME wire + * shape, including the v0.18.1 reasoning headroom) and the decode + * validation that guards the replay path against non-chat payloads. + * + * @package PRAutoBlogger\Tests\Core + */ + +namespace PRAutoBlogger\Tests\Core; + +use PRAutoBlogger\Tests\BaseTestCase; + +class StageReplayTest extends BaseTestCase { + + protected function setUp(): void { + parent::setUp(); + // Builder reads the global reasoning setting; pin it OFF here and + // flip it in the drift test below. + $this->stub_get_option( array( 'prautoblogger_reasoning_enabled' => '0' ) ); + } + + /** + * Round-trip: a body WITHOUT reasoning replayed through build_body() + * reproduces the original wire shape byte-for-byte. + */ + public function test_round_trip_without_reasoning(): void { + $original = array( + 'model' => 'google/gemini-2.5-flash-lite', + 'messages' => array( + array( 'role' => 'system', 'content' => 'sys' ), + array( 'role' => 'user', 'content' => 'edited prompt' ), + ), + 'temperature' => 0.7, + 'max_tokens' => 4000, + ); + + $options = \PRAutoBlogger_Stage_Replay::options_from_body( $original, 'draft' ); + $rebuilt = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )->build_body( + $original['messages'], + $original['model'], + $options + ); + + // The explicit reasoning-off pin is the only permitted delta + // (same shape the empty-completion retry sends upstream). + $this->assertSame( array( 'enabled' => false ), $rebuilt['reasoning'] ); + unset( $rebuilt['reasoning'] ); + $this->assertSame( $original, array_merge( array( 'model' => $original['model'], 'messages' => $original['messages'] ), $rebuilt ) ); + $this->assertSame( 4000, $rebuilt['max_tokens'] ); + } + + /** + * Round-trip WITH reasoning: the stored body's max_tokens carries the + * builder's +cap headroom; the inverse subtracts it so the rebuilt + * body is identical (headroom applied once, never compounded). + */ + public function test_round_trip_with_reasoning_headroom(): void { + $original = array( + 'model' => 'deepseek/deepseek-v4-flash', + 'messages' => array( array( 'role' => 'user', 'content' => 'p' ) ), + 'max_tokens' => 6048, // Caller asked 4000; builder added the 2048 cap. + 'reasoning' => array( + 'enabled' => true, + 'max_tokens' => 2048, + ), + ); + + $options = \PRAutoBlogger_Stage_Replay::options_from_body( $original, 'draft' ); + $this->assertSame( 4000, $options['max_tokens'] ); + + $rebuilt = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )->build_body( + $original['messages'], + $original['model'], + $options + ); + + $this->assertSame( 6048, $rebuilt['max_tokens'] ); + $this->assertSame( $original['reasoning'], $rebuilt['reasoning'] ); + } + + /** + * Global-setting drift cannot reshape a replay: even with reasoning + * globally ENABLED, a body stored without reasoning replays with the + * explicit off-pin (build_body honours the per-call override). + */ + public function test_global_reasoning_drift_cannot_leak_into_replay(): void { + $this->stub_get_option( + array( + 'prautoblogger_reasoning_enabled' => '1', + 'prautoblogger_reasoning_effort' => 'high', + ) + ); + + $body = array( + 'model' => 'google/gemini-2.5-flash-lite', + 'messages' => array( array( 'role' => 'user', 'content' => 'p' ) ), + 'max_tokens' => 1000, + ); + $options = \PRAutoBlogger_Stage_Replay::options_from_body( $body, 'polish' ); + $rebuilt = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )->build_body( $body['messages'], $body['model'], $options ); + + $this->assertSame( array( 'enabled' => false ), $rebuilt['reasoning'] ); + $this->assertSame( 1000, $rebuilt['max_tokens'] ); // No headroom added. + } + + /** + * Caller metadata (stage/prompt_key) rides options for the guard's + * audit rows but never reaches the wire body. + */ + public function test_options_carry_stage_metadata_not_sent_upstream(): void { + $body = array( + 'model' => 'm', + 'messages' => array( array( 'role' => 'user', 'content' => 'p' ) ), + ); + $options = \PRAutoBlogger_Stage_Replay::options_from_body( $body, 'draft' ); + + $this->assertSame( 'draft', $options['stage'] ); + $this->assertSame( 'content.draft', $options['prompt_key'] ); + + $rebuilt = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )->build_body( $body['messages'], $body['model'], $options ); + $this->assertArrayNotHasKey( 'stage', $rebuilt ); + $this->assertArrayNotHasKey( 'prompt_key', $rebuilt ); + } + + /** + * decode_body() accepts only well-formed chat bodies — model string + + * non-empty role/content message list. Everything else returns null + * (the executor aborts cleanly instead of replaying garbage). + */ + public function test_decode_body_validation(): void { + $valid = wp_json_encode( + array( + 'model' => 'm', + 'messages' => array( array( 'role' => 'user', 'content' => 'p' ) ), + ) + ); + $this->assertIsArray( \PRAutoBlogger_Stage_Replay::decode_body( (string) $valid ) ); + + $invalid = array( + 'not json at all', + '"just a string"', + '{"messages":[{"role":"user","content":"p"}]}', // No model. + '{"model":"m"}', // No messages. + '{"model":"m","messages":[]}', // Empty messages. + '{"model":"m","messages":["plain string"]}', // Malformed message. + '{"model":"m","messages":[{"role":"user"}]}', // No content. + '{"model":"m","messages":[{"role":1,"content":"p"}]}', // Non-string role. + ); + foreach ( $invalid as $payload ) { + $this->assertNull( \PRAutoBlogger_Stage_Replay::decode_body( $payload ), "must reject: {$payload}" ); + } + } +}