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
103 lines
3.1 KiB
PHP
103 lines
3.1 KiB
PHP
<?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 );
|
|
}
|
|
}
|