fix(stage-state): v0.18.2b — symmetric agent_role via in-class resolve_role seam

QA REJECT-P1 remediation (seq 29). start()/done()/is_done()/get_output()
were asymmetric: start() passed the mapped role but done()/is_done()/
get_output() passed '' via the default, addressing a different unique key
(run_id, stage, agent_role, item_key). On every normal run this orphaned
the start() row in 'running'; on resume is_done() always returned false
→ duplicate-post vector.

Fix: add resolve_role(stage, agent_role) private static seam in
Run_Stage_State. When the caller passes '' the role is derived from
Stage_Display_Map::default_agent_role(stage) before any DB operation.
Non-empty roles (Phase-2 fan-out) pass through unchanged. All four
write/lookup methods (start, done, fail, get) call resolve_role.
is_done() and get_output() go through get() so they benefit too.

Mid-run-upgrade compat (v0.18.1 → v0.18.2): get() falls back to a
legacy role='' row on miss so a run started under v0.18.1 (all rows
have agent_role='') can still resume under v0.18.2. One extra indexed
query, only on miss, only for known stages.

Tests: two new RunStageStateTest cases —
  test_role_tagged_start_and_done_are_symmetric: start+done with
  'editor', assert is_done('editor')=true and is_done('')=false.
  test_legacy_empty_role_row_is_found_on_fallback: resolve to 'editor',
  primary miss, legacy role='' hit → is_done=true.

ARCHITECTURE.md §22: agent_role column and #22 Phase 1 description
updated — no longer 'Phase-2 only / '' default'; now documents Phase 1
population from Stage_Display_Map.

Agent-Session: prab-v0182-maintenance-20260612
This commit is contained in:
peptiderepo
2026-06-12 02:01:01 +00:00
committed by peptiderepo
parent 6b1908d628
commit 3af65bbe39
4 changed files with 163 additions and 91 deletions

View File

@@ -137,4 +137,76 @@ class RunStageStateTest extends BaseTestCase {
$this->assertFalse( \PRAutoBlogger_Run_Stage_State::is_done( 'run-1', 'draft' ) );
$this->assertNull( \PRAutoBlogger_Run_Stage_State::get_output( 'run-1', 'draft' ) );
}
/**
* start(role) → done(role) → is_done(role) must be true; is_done('') must
* be false — this catches the v0.18.2 regression where done() passed ''
* while start() passed a real role, so done() addressed a different row.
*
* In this test the wpdb mock records inserts via query() and simulates
* the SELECT via get_row():
* is_done('review','editor') → get_row finds the done row → true.
* is_done('review','') → resolve_role→'editor'; primary: miss;
* legacy fallback (role=''): miss → false.
*/
public function test_role_tagged_start_and_done_are_symmetric(): void {
$get_row_results = array(
array( 'status' => 'done', 'meta_json' => null ), // primary hit for 'editor'.
null, // primary miss for legacy check (role='editor' resolved, not '').
null, // legacy fallback (role='') also not found.
);
$call_index = 0;
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
$this->wpdb->method( 'query' )->willReturn( 1 );
$this->wpdb->method( 'get_row' )->willReturnCallback(
static function () use ( &$get_row_results, &$call_index ) {
$result = $get_row_results[ $call_index ] ?? null;
++$call_index;
return $result;
}
);
\PRAutoBlogger_Run_Stage_State::start( 'run-2', 'review', 'editor', 'idea:xyz' );
\PRAutoBlogger_Run_Stage_State::done( 'run-2', 'review', 'editor', 'idea:xyz' );
// Explicit role finds the done row.
$this->assertTrue(
\PRAutoBlogger_Run_Stage_State::is_done( 'run-2', 'review', 'editor', 'idea:xyz' ),
'is_done with explicit role should be true'
);
// '' resolves to 'editor'; primary misses; legacy '' fallback also misses.
$this->assertFalse(
\PRAutoBlogger_Run_Stage_State::is_done( 'run-2', 'review', '', 'idea:xyz' ),
'is_done with legacy empty role should be false (no legacy row exists)'
);
}
/**
* Mid-run-upgrade resume: a row created by v0.18.1 (agent_role='') must
* still be found by is_done('') under v0.18.2 via the legacy fallback.
* resolve_role('review','') → 'editor'; primary query misses; legacy
* fallback (role='') hits → is_done returns true.
*/
public function test_legacy_empty_role_row_is_found_on_fallback(): void {
$call_index = 0;
$get_row_results = array(
null, // primary query (role='editor'): miss.
array( 'status' => 'done', 'meta_json' => null ), // legacy fallback (role=''): hit.
);
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
$this->wpdb->method( 'get_row' )->willReturnCallback(
static function () use ( &$get_row_results, &$call_index ) {
$result = $get_row_results[ $call_index ] ?? null;
++$call_index;
return $result;
}
);
// is_done('') resolves to 'editor', misses, falls back to role='',
// finds the v0.18.1 row → true.
$this->assertTrue(
\PRAutoBlogger_Run_Stage_State::is_done( 'run-3', 'review', '', 'idea:abc' ),
'v0.18.1 empty-role row must be found via legacy fallback'
);
}
}