feat(runs): state machine, idempotent resume, reaper extension, retention

Pipeline v2 Phase 1, commit 4/4. Run_Stage_State persists per-run
per-stage state keyed run_id + stage + agent_role + item_key (item_key
scopes articles because one run_id spans a whole batch; agent_role is
the Phase-2 quorum dimension, schema-only). Done stages snapshot output
and are reused on re-entry — writer stages via
Content_Generator::set_run_item, the review via its stored verdict, a
published item skipped outright — never re-run, never re-charged. Post
creation is keyed by _prautoblogger_run_id + new _prautoblogger_idea_hash
(check-before-insert in Publisher) so retries cannot duplicate posts.
Run-level research/analysis checkpoints + done/failed lifecycle marks
ride Pipeline_Runner/Executor (incl. orphan-abort + budget-abort paths).

Run_Reaper rides the EXISTING #19 daily cron: stages stuck running >2x
expected stage wall-clock and runs silent >2x expected run wall-clock ->
failed (filterable), open stages reaped, queryable as 'incomplete'.
Retention: request_json + stage output payloads NULLed after R days, R
from the new Audit Payload Retention setting (default constant 14,
0 = keep forever, no literal at point of use). Audit_Writer records
editorial verdicts (run_decisions) and per-article kept sources
(run_sources) for new runs.

Under-300: status-transient/summary helpers extracted verbatim to
Pipeline_Status (Pipeline_Runner 271; removed the worker's duplicate).

Agent-Session: pipeline-v2-phase1-engineer-20260612
This commit is contained in:
peptiderepo
2026-06-11 04:31:55 +00:00
committed by peptiderepo
parent c6d051c87a
commit 50da840553
17 changed files with 1358 additions and 102 deletions

View File

@@ -0,0 +1,115 @@
<?php
/**
* Tests for PRAutoBlogger_Run_Reaper.
*
* Locks the retention contract (R days from the SETTING, 0 = keep
* forever) and the stuck-sweep SQL shapes (2x expected wall-clock,
* running-only).
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class RunReaperTest extends BaseTestCase {
/**
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
*/
private $wpdb;
/**
* Captured SQL passed through $wpdb->query().
*
* @var string[]
*/
private array $queries = array();
protected function setUp(): void {
parent::setUp();
$this->queries = array();
// Local mock: the reaper also uses get_col(), which the shared
// BaseTestCase mock does not declare.
$this->wpdb = $this->getMockBuilder( \stdClass::class )
->addMethods( array( 'prepare', 'get_var', 'get_results', 'get_col', 'insert', 'query', 'get_row', 'update' ) )
->getMock();
$this->wpdb->prefix = 'wp_';
$this->wpdb->insert_id = 0;
$this->wpdb->last_error = '';
$GLOBALS['wpdb'] = $this->wpdb;
$this->wpdb->method( 'prepare' )->willReturnCallback(
static function ( $sql, ...$args ) {
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
}
);
Functions\when( 'apply_filters' )->returnArg( 2 );
\PRAutoBlogger_Run_State::flush_cache();
\PRAutoBlogger_Run_Stage_State::flush_cache();
}
protected function tearDown(): void {
\PRAutoBlogger_Run_State::flush_cache();
\PRAutoBlogger_Run_Stage_State::flush_cache();
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/**
* Retention 0 (or negative) means "keep forever": the pruner must not
* issue any UPDATE.
*/
public function test_zero_retention_keeps_payloads_forever(): void {
$this->stub_get_option(
array(
'prautoblogger_request_json_retention_days' => 0,
)
);
$this->wpdb->method( 'get_var' )->willReturn( null ); // No state tables.
$this->wpdb->method( 'get_col' )->willReturn( array() );
$this->wpdb->expects( $this->never() )->method( 'query' );
$stats = \PRAutoBlogger_Run_Reaper::reap();
$this->assertSame( 0, $stats['payloads_pruned'] );
}
/**
* With the default setting the pruner NULLs request_json older than R
* days on the generation log (and stage payloads when that table
* exists). The sweeps only touch 'running'/open rows.
*/
public function test_reap_prunes_and_sweeps_with_setting_backed_window(): void {
$this->stub_get_option(
array(
'prautoblogger_request_json_retention_days' => PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS,
)
);
// Both state tables exist.
$this->wpdb->method( 'get_var' )->willReturnCallback(
static function ( $sql ) {
if ( false !== strpos( (string) $sql, 'run_stages' ) ) {
return 'wp_prautoblogger_run_stages';
}
return 'wp_prautoblogger_runs';
}
);
$this->wpdb->method( 'get_col' )->willReturn( array() ); // No stuck runs.
$this->wpdb->method( 'query' )->willReturnCallback(
function ( $sql ) {
$this->queries[] = (string) $sql;
return 2;
}
);
$stats = \PRAutoBlogger_Run_Reaper::reap();
$all_sql = implode( "\n", $this->queries );
$this->assertStringContainsString( 'request_json = NULL', $all_sql );
$this->assertStringContainsString( 'meta_json = NULL', $all_sql );
$this->assertStringContainsString( "status = 'failed'", $all_sql );
$this->assertStringContainsString( "WHERE status = 'running' AND updated_at <", $all_sql );
$this->assertSame( 4, $stats['payloads_pruned'] ); // 2 rows x 2 tables.
}
}

View File

@@ -0,0 +1,140 @@
<?php
/**
* Tests for PRAutoBlogger_Run_Stage_State.
*
* Locks the idempotency-key contract (item scoping for multi-article
* runs), the sticky-done upsert semantics, and the self-healing
* no-table degradation.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
class RunStageStateTest extends BaseTestCase {
/**
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
*/
private $wpdb;
/**
* Captured SQL passed through $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( 'prepare' )->willReturnCallback(
static function ( $sql, ...$args ) {
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
}
);
\PRAutoBlogger_Run_Stage_State::flush_cache();
}
protected function tearDown(): void {
\PRAutoBlogger_Run_Stage_State::flush_cache();
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/**
* Two different ideas in the same run must get different item keys
* (one run_id spans all N articles of a batch run); the same idea must
* hash stably across processes (queue serialization round-trip).
*/
public function test_item_keys_scope_articles_within_a_run(): void {
$idea_a = new \PRAutoBlogger_Article_Idea(
array(
'topic' => 'BPC-157 dosing',
'article_type' => 'guide',
'suggested_title' => 'BPC-157 Dosing Guide',
'summary' => 's',
)
);
$idea_b = new \PRAutoBlogger_Article_Idea(
array(
'topic' => 'TB-500 storage',
'article_type' => 'question',
'suggested_title' => 'How To Store TB-500',
'summary' => 's',
)
);
$idea_a_again = new \PRAutoBlogger_Article_Idea( $idea_a->to_array() );
$key_a = \PRAutoBlogger_Run_Stage_State::item_key_for_idea( $idea_a );
$key_b = \PRAutoBlogger_Run_Stage_State::item_key_for_idea( $idea_b );
$this->assertNotSame( $key_a, $key_b );
$this->assertSame( $key_a, \PRAutoBlogger_Run_Stage_State::item_key_for_idea( $idea_a_again ) );
$this->assertStringStartsWith( 'idea:', $key_a );
$this->assertLessThanOrEqual( 64, strlen( $key_a ) );
$this->assertSame(
\PRAutoBlogger_Run_Stage_State::idea_hash( $idea_a ),
substr( $key_a, 5 )
);
}
/**
* start() upserts must never demote a done stage and must bump attempt
* on re-entry; done() must be sticky.
*/
public function test_start_upsert_protects_done_status(): void {
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
$this->wpdb->method( 'query' )->willReturnCallback(
function ( $sql ) {
$this->queries[] = (string) $sql;
return 1;
}
);
\PRAutoBlogger_Run_Stage_State::start( 'run-1', 'draft', '', 'idea:abc' );
$sql = implode( "\n", $this->queries );
$this->assertStringContainsString( 'ON DUPLICATE KEY UPDATE', $sql );
$this->assertStringContainsString( "IF(status = 'done', attempt, attempt + 1)", $sql );
$this->assertStringContainsString( "IF(status = 'done', 'done', 'running')", $sql );
}
/**
* fail() and fail_open_for_item() must never touch done rows.
*/
public function test_fail_paths_exclude_done_rows(): void {
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
$this->wpdb->method( 'query' )->willReturnCallback(
function ( $sql ) {
$this->queries[] = (string) $sql;
return 1;
}
);
\PRAutoBlogger_Run_Stage_State::fail( 'run-1', 'draft', '', 'idea:abc' );
\PRAutoBlogger_Run_Stage_State::fail_open_for_item( 'run-1', 'idea:abc' );
$this->assertStringContainsString( "status != 'done'", $this->queries[0] );
$this->assertStringContainsString( "status IN ('pending','running')", $this->queries[1] );
}
/**
* Without the table (half-migrated site) every method degrades to a
* harmless no-op/false/null — never a fatal.
*/
public function test_no_table_degrades_to_noops(): void {
$this->wpdb->method( 'get_var' )->willReturn( null ); // SHOW TABLES miss.
$this->wpdb->expects( $this->never() )->method( 'query' );
\PRAutoBlogger_Run_Stage_State::start( 'run-1', 'draft' );
\PRAutoBlogger_Run_Stage_State::done( 'run-1', 'draft', '', '', 'output' );
\PRAutoBlogger_Run_Stage_State::fail( 'run-1', 'draft' );
$this->assertFalse( \PRAutoBlogger_Run_Stage_State::is_done( 'run-1', 'draft' ) );
$this->assertNull( \PRAutoBlogger_Run_Stage_State::get_output( 'run-1', 'draft' ) );
}
}