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
parent 04f09f5f24
commit ed8ec9425f
17 changed files with 1358 additions and 102 deletions

57
includes/core/class-publisher.php Executable file → Normal file
View File

@@ -82,6 +82,22 @@ class PRAutoBlogger_Publisher {
?string $run_id = null,
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
): int {
// v0.18.0 idempotency: post creation is keyed by run + idea
// (check-before-insert) so a retried/resumed run cannot create a
// duplicate post. One run_id spans all N articles of a batch, so
// the idea hash disambiguates within the run.
$idea_hash = PRAutoBlogger_Run_Stage_State::idea_hash( $idea );
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;
}
}
// Clean LLM artifacts, then inject peptide hyperlinks deterministically
// before the content enters WordPress. Peptide linker no-ops gracefully
// if PR Core is not active.
@@ -94,7 +110,7 @@ class PRAutoBlogger_Publisher {
'post_status' => $post_status,
'post_type' => 'post',
'post_author' => PRAutoBlogger_Post_Assembler::get_default_author_id(),
'meta_input' => $this->build_meta( $idea, $review, $run_id ),
'meta_input' => $this->build_meta( $idea, $review, $run_id, $idea_hash ),
);
/** @see class-prautoblogger.php — listeners registered in main loader. */
@@ -140,7 +156,8 @@ class PRAutoBlogger_Publisher {
private function build_meta(
PRAutoBlogger_Article_Idea $idea,
PRAutoBlogger_Editorial_Review $review,
?string $run_id = null
?string $run_id = null,
string $idea_hash = ''
): array {
$meta = array(
'_prautoblogger_generated' => '1',
@@ -160,6 +177,42 @@ class PRAutoBlogger_Publisher {
if ( null !== $run_id && '' !== $run_id ) {
$meta['_prautoblogger_run_id'] = $run_id;
}
if ( '' !== $idea_hash ) {
$meta['_prautoblogger_idea_hash'] = $idea_hash;
}
return $meta;
}
/**
* Find a post already created for this (run, idea) pair.
*
* Direct postmeta self-join (any post_status, including trash) — the
* idempotency check must see drafts and pending posts too.
*
* @param string $run_id Pipeline run UUID.
* @param string $idea_hash Idea hash from Run_Stage_State::idea_hash().
* @return int Existing post ID, or 0 when none.
*/
private function find_existing_post( string $run_id, string $idea_hash ): int {
global $wpdb;
if ( null === $wpdb ) {
return 0;
}
$postmeta = $wpdb->prefix . 'postmeta';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT pm_run.post_id FROM {$postmeta} pm_run
INNER JOIN {$postmeta} pm_hash
ON pm_hash.post_id = pm_run.post_id
AND pm_hash.meta_key = '_prautoblogger_idea_hash'
AND pm_hash.meta_value = %s
WHERE pm_run.meta_key = '_prautoblogger_run_id' AND pm_run.meta_value = %s
LIMIT 1",
$idea_hash,
$run_id
)
);
return null !== $post_id ? (int) $post_id : 0;
}
}