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:
@@ -6,40 +6,33 @@ declare(strict_types=1);
|
||||
*
|
||||
* Per-run per-stage state machine over `wp_prautoblogger_run_stages`.
|
||||
*
|
||||
* What: One row per (run_id, stage, agent_role, item_key) holding
|
||||
* pending|running|done|failed|halted plus the stage's output
|
||||
* snapshot (meta_json). This is what makes resume idempotent on the
|
||||
* chained-cron architecture: a `done` stage is NEVER re-run or
|
||||
* re-charged — re-entry reuses its stored output; `done` is sticky
|
||||
* (an upsert cannot demote it). agent_role is the Phase-2 fan-out
|
||||
* dimension (quorum members; the quorum logic itself is Phase 2);
|
||||
* item_key scopes article-level stages because one run_id spans all
|
||||
* N articles of a batch run ('' for run-level stages). Self-healing:
|
||||
* a missing table degrades every method to a no-op/false.
|
||||
* Who triggers it: Pipeline_Runner (run-level research/analysis),
|
||||
* Article_Worker (per-article generate/review/publish),
|
||||
* Content_Generator (per-LLM-stage outline/draft/polish),
|
||||
* Run_Reaper (stuck-stage sweep + payload pruning).
|
||||
* Dependencies: WordPress $wpdb, wp_json_encode.
|
||||
* One row per (run_id, stage, agent_role, item_key) holding
|
||||
* pending|running|done|failed|halted plus the output snapshot (meta_json).
|
||||
* Done stages are sticky and never re-charged on resume. Self-healing:
|
||||
* a missing table degrades every method to a no-op/false.
|
||||
*
|
||||
* @see core/class-run-state.php — Run-level lifecycle + ledger row.
|
||||
* @see core/class-run-reaper.php — Sweeps stages stuck in 'running'.
|
||||
* @see ARCHITECTURE.md #21 — Idempotency-key design.
|
||||
* v0.18.2: role is resolved from Stage_Display_Map when caller passes ''
|
||||
* so start/done/is_done/get_output are always symmetric. Explicit non-empty
|
||||
* roles (Phase-2 fan-out) are honoured as-is. get() falls back to role=''
|
||||
* on miss for mid-run-upgrade compat with v0.18.1 rows.
|
||||
*
|
||||
* @see core/class-run-state.php — Run-level lifecycle + ledger row.
|
||||
* @see core/class-run-reaper.php — Sweeps stages stuck in 'running'.
|
||||
* @see ARCHITECTURE.md #22 — Pipeline v2 Phase 1 substrate.
|
||||
*/
|
||||
class PRAutoBlogger_Run_Stage_State {
|
||||
|
||||
/** @var bool|null Per-request "run_stages table exists" probe result. */
|
||||
private static ?bool $table_ok = null;
|
||||
|
||||
/** Fully-qualified run_stages table name. */
|
||||
/** @return string Fully-qualified run_stages table name. */
|
||||
public static function table_name(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'prautoblogger_run_stages';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the run_stages table exists and is queryable. Cached per
|
||||
* request; never throws.
|
||||
* Whether the run_stages table exists and is queryable. Cached per request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
@@ -49,7 +42,7 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
}
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return false; // Not cached: $wpdb may appear later in boot.
|
||||
return false;
|
||||
}
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
@@ -59,18 +52,17 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable hash identifying one article idea within a run (post-creation
|
||||
* idempotency + stage item scoping both key off this).
|
||||
* Stable 16-hex hash for one article idea within a run.
|
||||
*
|
||||
* @param PRAutoBlogger_Article_Idea $idea The idea.
|
||||
* @return string 16-hex-char hash.
|
||||
* @return string
|
||||
*/
|
||||
public static function idea_hash( PRAutoBlogger_Article_Idea $idea ): string {
|
||||
return substr( md5( $idea->get_suggested_title() . '|' . $idea->get_topic() ), 0, 16 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage item_key for an article idea ('' is reserved for run-level stages).
|
||||
* Stage item_key for an article idea ('' reserved for run-level stages).
|
||||
*
|
||||
* @param PRAutoBlogger_Article_Idea $idea The idea.
|
||||
* @return string e.g. 'idea:1a2b3c4d5e6f7a8b'.
|
||||
@@ -80,22 +72,18 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a stage as entered (running). Upsert: first entry inserts the
|
||||
* row; re-entry bumps `attempt` — but a `done` stage is sticky and is
|
||||
* neither demoted nor re-counted.
|
||||
*
|
||||
* Side effects: one INSERT … ON DUPLICATE KEY UPDATE.
|
||||
* Mark a stage as running. Sticky: done stages are never demoted.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name (see Stage_Display_Map).
|
||||
* @param string $agent_role Fan-out dimension ('' default in Phase 1).
|
||||
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
|
||||
* @param string $item_key Article scope ('' for run-level stages).
|
||||
* @return void
|
||||
*/
|
||||
public static function start( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return;
|
||||
}
|
||||
$agent_role = self::resolve_role( $stage, $agent_role );
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
@@ -109,33 +97,26 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
attempt = IF(status = 'done', attempt, attempt + 1),
|
||||
status = IF(status = 'done', 'done', 'running'),
|
||||
updated_at = VALUES(updated_at)",
|
||||
$run_id,
|
||||
$stage,
|
||||
$agent_role,
|
||||
$item_key,
|
||||
$now,
|
||||
$now
|
||||
$run_id, $stage, $agent_role, $item_key, $now, $now
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a stage done, persisting its output for resume-without-recharge.
|
||||
*
|
||||
* Side effects: one INSERT … ON DUPLICATE KEY UPDATE.
|
||||
* Mark a stage done and persist its output for resume-without-recharge.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Fan-out dimension.
|
||||
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
|
||||
* @param string $item_key Article scope.
|
||||
* @param string|null $output Stage output to snapshot (pruned after R days).
|
||||
* @param float $cost_usd Cost attributed to the stage.
|
||||
* @return void
|
||||
* @param string|null $output Stage output snapshot (pruned after R days).
|
||||
* @param float $cost_usd Cost attributed to this stage.
|
||||
*/
|
||||
public static function done( string $run_id, string $stage, string $agent_role = '', string $item_key = '', ?string $output = null, float $cost_usd = 0.0 ): void {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return;
|
||||
}
|
||||
$agent_role = self::resolve_role( $stage, $agent_role );
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
@@ -152,32 +133,24 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
meta_json = VALUES(meta_json),
|
||||
updated_at = VALUES(updated_at),
|
||||
finished_at = VALUES(finished_at)",
|
||||
$run_id,
|
||||
$stage,
|
||||
$agent_role,
|
||||
$item_key,
|
||||
$cost_usd,
|
||||
$meta,
|
||||
$now,
|
||||
$now,
|
||||
$now
|
||||
$run_id, $stage, $agent_role, $item_key, $cost_usd, $meta, $now, $now, $now
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a stage failed (does not demote a `done` stage).
|
||||
* Mark a stage failed (does not demote a done stage).
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Fan-out dimension.
|
||||
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
|
||||
* @param string $item_key Article scope.
|
||||
* @return void
|
||||
*/
|
||||
public static function fail( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return;
|
||||
}
|
||||
$agent_role = self::resolve_role( $stage, $agent_role );
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
@@ -186,43 +159,53 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s
|
||||
WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s AND status != 'done'",
|
||||
$now,
|
||||
$now,
|
||||
$run_id,
|
||||
$stage,
|
||||
$agent_role,
|
||||
$item_key
|
||||
$now, $now, $run_id, $stage, $agent_role, $item_key
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one stage row.
|
||||
* Fetch one stage row. Falls back to role='' on miss for mid-run-upgrade
|
||||
* compat with v0.18.1 rows (one extra indexed query, only on miss).
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Fan-out dimension.
|
||||
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
|
||||
* @param string $item_key Article scope.
|
||||
* @return array<string, mixed>|null Row or null.
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function get( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): ?array {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return null;
|
||||
}
|
||||
$agent_role = self::resolve_role( $stage, $agent_role );
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s",
|
||||
$run_id,
|
||||
$stage,
|
||||
$agent_role,
|
||||
$item_key
|
||||
$run_id, $stage, $agent_role, $item_key
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
return is_array( $row ) ? $row : null;
|
||||
if ( is_array( $row ) ) {
|
||||
return $row;
|
||||
}
|
||||
// v0.18.1 upgrade compat: rows started before this fix carry role=''.
|
||||
// One extra indexed query, only on miss, only for known stages.
|
||||
if ( '' !== $agent_role ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$legacy = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE run_id = %s AND stage = %s AND agent_role = '' AND item_key = %s",
|
||||
$run_id, $stage, $item_key
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
return is_array( $legacy ) ? $legacy : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,7 +213,7 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Fan-out dimension.
|
||||
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
|
||||
* @param string $item_key Article scope.
|
||||
* @return bool
|
||||
*/
|
||||
@@ -244,7 +227,7 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Fan-out dimension.
|
||||
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
|
||||
* @param string $item_key Article scope.
|
||||
* @return string|null
|
||||
*/
|
||||
@@ -258,13 +241,10 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail every still-open (pending/running) stage of one item — used by
|
||||
* the worker's catch-all so a crashed article leaves no stage stuck
|
||||
* in 'running' until the reaper. Done stages are untouched.
|
||||
* Fail every still-open stage of one item. Done stages are untouched.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Stage item key ('' = run-level stages).
|
||||
* @return void
|
||||
*/
|
||||
public static function fail_open_for_item( string $run_id, string $item_key ): void {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
@@ -278,10 +258,7 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s
|
||||
WHERE run_id = %s AND item_key = %s AND status IN ('pending','running')",
|
||||
$now,
|
||||
$now,
|
||||
$run_id,
|
||||
$item_key
|
||||
$now, $now, $run_id, $item_key
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -290,4 +267,21 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
public static function flush_cache(): void {
|
||||
self::$table_ok = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective agent role for a stage.
|
||||
*
|
||||
* '' → derive from Stage_Display_Map (unknown stages map to '').
|
||||
* Non-empty → returned as-is (Phase-2 explicit fan-out role).
|
||||
*
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Caller-supplied role ('' = auto-resolve).
|
||||
* @return string Effective agent role.
|
||||
*/
|
||||
private static function resolve_role( string $stage, string $agent_role ): string {
|
||||
if ( '' !== $agent_role ) {
|
||||
return $agent_role;
|
||||
}
|
||||
return (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage );
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user