feat(M3): re-run engine — replay + rebuild as chained-cron jobs under CPO guardrails
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
This commit is contained in:
@@ -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' ) );
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
214
includes/core/class-rerun-eligibility.php
Normal file
214
includes/core/class-rerun-eligibility.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Policy gates for edit + single-step re-run (v0.20.0, M3).
|
||||
*
|
||||
* What: Single source of truth for WHO may re-run WHAT:
|
||||
* - Published posts are frozen (CPO guardrail 5): publish/future/
|
||||
* private statuses lock the run; edits go through the WP editor.
|
||||
* - Runs that are actively executing (status pending/running or
|
||||
* the global generation lock held) cannot be re-run — "in
|
||||
* progress" in the CPO sense means in the WORKFLOW (unpublished),
|
||||
* not mid-execution.
|
||||
* - Editable stages (input fork + replay) are the item-scoped
|
||||
* writer chat stages of the CURRENT pipeline vocabulary:
|
||||
* outline/draft/polish (single-pass uses draft only). review's
|
||||
* input is fully derived from content (re-run it from here
|
||||
* instead); analysis/research are run-level idea-selection
|
||||
* stages; image stages are not chat-replayable (Phase 2b).
|
||||
* - The canonical per-item chain orders downstream computation
|
||||
* for stale-marking and rebuild sets.
|
||||
* Who triggers it: Dossier_Actions (UI affordances + request validation),
|
||||
* Rerun_Executor (re-validation under the lock before mutating).
|
||||
* Dependencies: get_post(), Run_State, Generation_Lock, Stage_Input_Store.
|
||||
*
|
||||
* @see admin/class-dossier-actions.php — AJAX validation.
|
||||
* @see core/class-rerun-executor.php — Cron-side re-validation.
|
||||
* @see ARCHITECTURE.md #24 — Edit + re-run design.
|
||||
*/
|
||||
class PRAutoBlogger_Rerun_Eligibility {
|
||||
|
||||
/** Post statuses that freeze a run (CPO guardrail 5). */
|
||||
public const FROZEN_STATUSES = array( 'publish', 'future', 'private' );
|
||||
|
||||
/** Stages whose input may be edited and replayed (chat writer stages). */
|
||||
public const EDITABLE_STAGES = array( 'outline', 'draft', 'polish' );
|
||||
|
||||
/** Canonical per-item stage chain (current pipeline vocabulary). */
|
||||
public const ITEM_STAGE_CHAIN = array( 'outline', 'draft', 'polish', 'review', 'publish' );
|
||||
|
||||
/**
|
||||
* Whether a stage's input may be edited + replayed.
|
||||
*
|
||||
* @param string $stage Stage name.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_editable_stage( string $stage ): bool {
|
||||
return in_array( $stage, self::EDITABLE_STAGES, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether re-run-from-here may target this stage (any chain stage).
|
||||
*
|
||||
* @param string $stage Stage name.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_chain_stage( string $stage ): bool {
|
||||
return in_array( $stage, self::ITEM_STAGE_CHAIN, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Stages strictly AFTER a stage in the canonical chain — the set that
|
||||
* goes stale when the stage is re-run. Stages absent from an item
|
||||
* (e.g. outline/polish on single-pass runs) simply match no rows.
|
||||
*
|
||||
* @param string $stage Stage name.
|
||||
* @return array<string> 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<string>
|
||||
*/
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
222
includes/core/class-rerun-executor.php
Normal file
222
includes/core/class-rerun-executor.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Queues and executes operator re-runs as chained cron jobs (v0.20.0, M3).
|
||||
*
|
||||
* What: The chained-cron seam for the dossier's two re-run actions —
|
||||
* NEVER synchronous (CPO hard constraint): the AJAX layer only
|
||||
* schedules; ALL state mutations happen in the cron handler after
|
||||
* re-validating eligibility under the global generation lock.
|
||||
* - Replay job: single-stage re-run of the latest edited input
|
||||
* fork (Stage_Replay). Demotes the one stage row to running
|
||||
* (human_modified=1), stale-marks downstream, executes, restores
|
||||
* a terminal run status.
|
||||
* - Rebuild job ("re-run from here"): demotes the target + all
|
||||
* downstream chain stages to pending, then re-enters
|
||||
* Article_Worker with the idea reconstructed from the stored
|
||||
* seed — upstream done stages are reused (never re-charged),
|
||||
* demoted stages re-run with prompts rebuilt from CURRENT
|
||||
* upstream snapshots, and the publisher refreshes the
|
||||
* unpublished post.
|
||||
* Both jobs: monthly budget check, Run_State::reopen() (ceiling
|
||||
* re-snapshotted from the current setting — a deliberate new-spend
|
||||
* decision), M1 status transient for board/dossier pickup
|
||||
* visibility, lock released in finally. Failures restore/record a
|
||||
* terminal run status so the run-reaper's stuck-running sweep
|
||||
* stays meaningful.
|
||||
* Who triggers it: Dossier_Actions (queue_*), WP-Cron (on_* handlers via
|
||||
* class-prautoblogger.php registration).
|
||||
* Dependencies: Rerun_Job_Support (lock/budget/transient plumbing),
|
||||
* Run_State, Run_Stage_Rerun_State, Stage_Input_Store,
|
||||
* Stage_Replay, Article_Worker, Rerun_Eligibility.
|
||||
*
|
||||
* @see admin/class-dossier-actions.php — The AJAX layer (queues via Job_Support).
|
||||
* @see core/class-rerun-job-support.php — Shared queue/lock/status plumbing.
|
||||
* @see core/class-stage-replay.php — The governed replay call.
|
||||
* @see ARCHITECTURE.md #24 — Edit + re-run design.
|
||||
*/
|
||||
class PRAutoBlogger_Rerun_Executor {
|
||||
|
||||
/** Cron action for single-stage replay jobs. */
|
||||
public const REPLAY_ACTION = 'prautoblogger_rerun_stage_replay';
|
||||
|
||||
/** Cron action for re-run-from-here rebuild jobs. */
|
||||
public const REBUILD_ACTION = 'prautoblogger_rerun_from_stage';
|
||||
|
||||
/**
|
||||
* Cron handler: replay one stage from its latest edited input fork.
|
||||
*
|
||||
* Side effects: run_stages/runs mutations, one governed LLM call,
|
||||
* generation_log row, status transient, lock acquire/release.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param int $post_id Post the dossier acted on.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Stage row agent role.
|
||||
* @param string $item_key Stage row item key.
|
||||
* @return void
|
||||
*/
|
||||
public function on_replay_job( string $run_id, int $post_id, string $stage, string $agent_role, string $item_key ): void {
|
||||
PRAutoBlogger_Rerun_Job_Support::harden_process();
|
||||
if ( ! PRAutoBlogger_Rerun_Job_Support::acquire_lock_or_report() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$previous_status = null;
|
||||
try {
|
||||
// Re-validate under the lock — state may have changed between
|
||||
// click and pickup (e.g. the post was published).
|
||||
// require_idle_lock=false: THIS handler owns the lock.
|
||||
$check = PRAutoBlogger_Rerun_Eligibility::check_replay( $run_id, $post_id, $stage, $agent_role, $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;
|
||||
}
|
||||
|
||||
$fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $run_id, $stage, $agent_role, $item_key );
|
||||
$body = null !== $fork ? PRAutoBlogger_Stage_Replay::decode_body( (string) $fork['request_json'] ) : null;
|
||||
if ( null === $body ) {
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The saved input could not be decoded as a replayable request.', 'prautoblogger' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
$previous_status = PRAutoBlogger_Run_State::get_status( $run_id );
|
||||
if ( ! PRAutoBlogger_Run_State::reopen( $run_id ) ) {
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The run could not be reopened for new spend.', 'prautoblogger' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! PRAutoBlogger_Run_Stage_Rerun_State::restart( $run_id, $stage, $agent_role, $item_key, true ) ) {
|
||||
PRAutoBlogger_Rerun_Job_Support::restore_terminal( $run_id, $previous_status );
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The stage row could not be demoted for replay (it may be executing).', 'prautoblogger' ) );
|
||||
return;
|
||||
}
|
||||
// Guardrail 3: explicit, visible downstream invalidation.
|
||||
PRAutoBlogger_Run_Stage_Rerun_State::mark_stale( $run_id, $item_key, PRAutoBlogger_Rerun_Eligibility::downstream_of( $stage ) );
|
||||
|
||||
PRAutoBlogger_Rerun_Job_Support::broadcast(
|
||||
sprintf(
|
||||
/* translators: %s: stage display label. */
|
||||
__( 'Re-running %s with the edited input…', 'prautoblogger' ),
|
||||
PRAutoBlogger_Stage_Display_Map::label( $stage )
|
||||
)
|
||||
);
|
||||
|
||||
$result = PRAutoBlogger_Stage_Replay::run( $run_id, $stage, $agent_role, $item_key, $body );
|
||||
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'done' );
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_complete( $result['cost'] );
|
||||
} catch ( PRAutoBlogger_Cost_Ceiling_Exception $e ) {
|
||||
// Governor already halted the run + recorded the overage.
|
||||
PRAutoBlogger_Run_Stage_State::fail( $run_id, $stage, $agent_role, $item_key );
|
||||
PRAutoBlogger_Rerun_Job_Support::finish_error( $e->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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
158
includes/core/class-rerun-job-support.php
Normal file
158
includes/core/class-rerun-job-support.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Shared plumbing for re-run cron jobs (v0.20.0, M3).
|
||||
*
|
||||
* What: The queue/lock/budget/status-transient helpers both
|
||||
* Rerun_Executor handlers share — extracted so the executor stays
|
||||
* under the 300-line cap. The status transient writes use the M1
|
||||
* Generation_Status_Poller shape so the kanban board (and the
|
||||
* dossier's poll) show queued → pickup → result without any new
|
||||
* polling infrastructure; the poller's existing R2/R3 orphan
|
||||
* recovery covers a dead re-run process unchanged.
|
||||
* Who triggers it: PRAutoBlogger_Rerun_Executor (handlers) and
|
||||
* PRAutoBlogger_Dossier_Actions (queue()).
|
||||
* Dependencies: Generation_Lock, Generation_Status_Poller (constants),
|
||||
* Cost_Tracker, Run_State, Logger, WP-Cron.
|
||||
*
|
||||
* @see core/class-rerun-executor.php — The job handlers.
|
||||
* @see ARCHITECTURE.md #24 — Edit + re-run design.
|
||||
*/
|
||||
class PRAutoBlogger_Rerun_Job_Support {
|
||||
|
||||
/**
|
||||
* Schedule a re-run job and fire the cron spawner (non-blocking).
|
||||
*
|
||||
* The uniq argument keeps wp_schedule_single_event()'s identical-
|
||||
* args dedup from swallowing a legitimate second click after a
|
||||
* failed attempt within the 10-minute window.
|
||||
*
|
||||
* Side effects: one single cron event, status transient write,
|
||||
* non-blocking loopback request.
|
||||
*
|
||||
* @param string $action Cron action name.
|
||||
* @param array $args Handler args.
|
||||
* @param string $queued_message Operator-facing "queued" copy.
|
||||
* @return void
|
||||
*/
|
||||
public static function queue( string $action, array $args, string $queued_message ): void {
|
||||
$args[] = (string) microtime( true ); // Uniqueness token.
|
||||
wp_schedule_single_event( time(), $action, $args );
|
||||
|
||||
self::broadcast( $queued_message );
|
||||
|
||||
spawn_cron();
|
||||
wp_remote_post(
|
||||
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
|
||||
array(
|
||||
'timeout' => 0.01,
|
||||
'blocking' => false,
|
||||
'sslverify' => false,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
189
includes/core/class-stage-replay.php
Normal file
189
includes/core/class-stage-replay.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Executes one stage replay from a saved input fork (v0.20.0, M3).
|
||||
*
|
||||
* What: Decodes an immutable stage_inputs fork body back into the
|
||||
* (messages, model, options) triple and sends it through the
|
||||
* normal provider seam — so the cost governor's reserve-before-
|
||||
* call (on the SAME run ledger row), the retry/backoff loop, the
|
||||
* empty-completion guard, and the B1 request recorder all apply
|
||||
* to replays exactly as to pipeline calls (CPO guardrail 4). The
|
||||
* fresh output lands in the stage's run_stages snapshot (the same
|
||||
* place downstream rebuilds read from) and a generation_log row
|
||||
* is written with the replayed request body.
|
||||
*
|
||||
* options_from_body() is the exact inverse of Request_Builder::
|
||||
* build_body() + apply_reasoning_budget(): the v0.18.1 reasoning
|
||||
* headroom is subtracted back out (the builder re-adds it), and
|
||||
* reasoning is pinned explicitly — enabled:false when the original
|
||||
* body had none — so a later change to the global reasoning
|
||||
* setting can never alter the replay's shape.
|
||||
* Who triggers it: PRAutoBlogger_Rerun_Executor::on_replay_job() only
|
||||
* (after eligibility re-validation, lock, reopen, restart and
|
||||
* stale-marking).
|
||||
* Dependencies: OpenRouter_Provider, Cost_Tracker, Run_Stage_State,
|
||||
* Audit_Writer, Stage_Display_Map.
|
||||
*
|
||||
* @see core/class-rerun-executor.php — Orchestration around this.
|
||||
* @see providers/class-open-router-request-builder.php — The forward transform.
|
||||
* @see ARCHITECTURE.md #24 — Edit + re-run design.
|
||||
*/
|
||||
class PRAutoBlogger_Stage_Replay {
|
||||
|
||||
/**
|
||||
* Reconstruct provider options from a stored request body.
|
||||
*
|
||||
* Inverse of build_body()/apply_reasoning_budget(): copies
|
||||
* temperature/response_format; recovers the caller's max_tokens by
|
||||
* subtracting the reasoning cap the builder added as headroom; pins
|
||||
* reasoning explicitly (the stored body is the contract — global
|
||||
* setting drift must not leak in). Adds the caller-metadata keys
|
||||
* (stage, prompt_key) used by the guard's logging — never sent
|
||||
* upstream.
|
||||
*
|
||||
* @param array<string, mixed> $body Decoded request body.
|
||||
* @param string $stage Stage being replayed.
|
||||
* @return array<string, mixed> 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<string, mixed>|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<string, mixed> $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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
134
tests/unit/Core/PublisherRerunUpdateTest.php
Normal file
134
tests/unit/Core/PublisherRerunUpdateTest.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for the v0.20.0 Publisher refresh-unpublished behavior.
|
||||
*
|
||||
* A re-run's regenerated content must land on the existing UNPUBLISHED
|
||||
* post (idempotency previously skip-returned and silently discarded
|
||||
* re-run output); published posts remain frozen and untouched (CPO
|
||||
* guardrail 5 enforced at the data layer).
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class PublisherRerunUpdateTest extends BaseTestCase {
|
||||
|
||||
private \PRAutoBlogger_Article_Idea $idea;
|
||||
private \PRAutoBlogger_Editorial_Review $review;
|
||||
|
||||
/**
|
||||
* Args captured from wp_update_post().
|
||||
*
|
||||
* @var array|null
|
||||
*/
|
||||
private $updated_args = null;
|
||||
|
||||
/**
|
||||
* Meta captured from update_post_meta().
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
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( '<p>Regenerated content</p>', $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( '<p>Regenerated content</p>', $this->idea, $this->review, 'run-1' );
|
||||
|
||||
$this->assertSame( 55, $post_id );
|
||||
$this->assertNull( $this->updated_args );
|
||||
$this->assertSame( array(), $this->updated_meta );
|
||||
}
|
||||
}
|
||||
182
tests/unit/Core/RerunEligibilityTest.php
Normal file
182
tests/unit/Core/RerunEligibilityTest.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Rerun_Eligibility (v0.20.0, M3).
|
||||
*
|
||||
* Locks the published-post lockout (CPO guardrail 5 incl. future/private),
|
||||
* the actively-executing-run block, the editable-stage policy, and the
|
||||
* downstream/rebuild set computation that drives stale marking.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class RerunEligibilityTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
||||
*/
|
||||
private $wpdb;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->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' )
|
||||
);
|
||||
}
|
||||
}
|
||||
217
tests/unit/Core/RerunExecutorTest.php
Normal file
217
tests/unit/Core/RerunExecutorTest.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Rerun_Executor + Job_Support (v0.20.0, M3).
|
||||
*
|
||||
* Locks the chained-cron contract (queue() schedules + writes the
|
||||
* queued transient — nothing executes synchronously), the under-lock
|
||||
* re-validation (a post published between click and pickup aborts the
|
||||
* job with ZERO stage mutations), and the terminal-status restore when
|
||||
* the stage demotion fails after the run was reopened.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class RerunExecutorTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
||||
*/
|
||||
private $wpdb;
|
||||
|
||||
/**
|
||||
* SQL captured from $wpdb->query().
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private array $queries = array();
|
||||
|
||||
/**
|
||||
* Transients captured from set_transient().
|
||||
*
|
||||
* @var array<int, 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'] );
|
||||
}
|
||||
}
|
||||
102
tests/unit/Core/RunStateReopenTest.php
Normal file
102
tests/unit/Core/RunStateReopenTest.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Run_State::reopen() (v0.20.0, M3).
|
||||
*
|
||||
* Locks: terminal-only atomic gate, ceiling re-snapshot from the
|
||||
* CURRENT setting, and — critical for CPO guardrail 4 — that the
|
||||
* accumulated reserved/settled spend is never reset, so re-runs are
|
||||
* new spend on the same run ledger.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class RunStateReopenTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
||||
*/
|
||||
private $wpdb;
|
||||
|
||||
/**
|
||||
* SQL captured from $wpdb->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 );
|
||||
}
|
||||
}
|
||||
157
tests/unit/Core/StageReplayTest.php
Normal file
157
tests/unit/Core/StageReplayTest.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Stage_Replay (v0.20.0, M3).
|
||||
*
|
||||
* Locks the body -> 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}" );
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user