Compare commits

...

7 Commits

Author SHA1 Message Date
peptiderepo
0689989318 fix(tests): remove Post_Assembler eval stub from SyncModeTest (breaks Publisher tests)
SyncModeTest runs alphabetically before GenerationCheckpointRunnerTest and
PublisherSideEffectsTest. Defining PRAutoBlogger_Post_Assembler via eval with
only amortize_research_costs() caused Publisher* and ResearchReaper tests to
fail with 'Call to undefined method sanitize_llm_content()' because the stub
registered before the autoloader could load the real class.

Fix: remove the Post_Assembler eval block entirely from both generate-tick
tests. The 'more ideas remain' path (2-idea queue, pop one) never calls
Post_Assembler::amortize_research_costs() -- that only runs in the last-idea
branch. No stub is needed.

Article_Worker stub is retained (it IS called in this path and needs a no-op
generate() to avoid real LLM calls).

Agent-Session: prab-reliability-test-fix-20260622
2026-06-22 17:44:12 +00:00
peptiderepo
d5fb1299ac tests(checkpoint): fix vacuous sync-mode test + add generate-tick sync test (NF-1)
- GenerationCheckpointRunnerSyncModeTest.php (new, 246 lines): three tests that
  genuinely exercise the sync-mode guards --
    test_orchestrate_tick_in_sync_mode_does_not_schedule_background_event:
      orchestrate_only() now returns 1 idea so execution reaches the guard at
      line ~181 (was vacuous -- empty-ideas stub triggered early return at ~148).
      Asserts GENERATE_ACTION NOT scheduled, spawn_cron NOT called.
    test_generate_tick_sync_mode_suppresses_reschedule (new):
      2-idea queue, set_sync_mode(true), on_generate_tick() -- asserts
      GENERATE_ACTION NOT in scheduled events, spawn_cron NOT called.
    test_generate_tick_async_mode_schedules_generate_action:
      async regression guard moved from original file.
- GenerationCheckpointRunnerTest.php (233 lines): removed the two sync-mode
  tests to stay under 300-line rule; added docblock pointer to SyncModeTest.
- CHANGELOG.md: merged duplicate [0.22.1] headings into one entry (NF-2).

Production code unchanged. Version stays 0.22.1.

Agent-Session: prab-reliability-test-fix-20260622
2026-06-22 17:38:17 +00:00
peptiderepo
404b3b4ce2 style: auto-fix PHPCS violations via phpcbf 2026-06-22 17:16:57 +00:00
peptiderepo
6229601df4 fix: QA remediation P1-A/P1-B/P1-C/P2 for v0.22.1 sync-mode
P1-A: wrap wp_schedule_single_event(GENERATE_ACTION) + fire_cron_now() in
on_orchestrate_tick() with 'if ( ! self::$sync_mode )' guard.
Mirrors the existing guard in on_generate_tick(). Prevents a background
wp-cron process spawning in parallel with the VPS --sync loop and
prematurely finalizing the run lock before Article_Worker completes.

P1-B: add two unit tests to GenerationCheckpointRunnerTest.php:
- test_orchestrate_tick_in_sync_mode_does_not_schedule_background_event:
  asserts sync_mode=true suppresses GENERATE_ACTION schedule + spawn_cron.
- test_generate_tick_async_mode_schedules_generate_action:
  asserts sync_mode=false (async path) still reschedules GENERATE_ACTION.

P1-C: create root CONTEXT.md with domain glossary (run, run_stages,
checkpoint, idea queue, Generation_Lock) and execution mode reference
(async wp-cron vs --sync VPS-driven).

P2: update ARCHITECTURE.md §25 Entry Points table with the
'wp prautoblogger generate --sync' VPS path row.

Agent-Session: prab-reliability-remediation-20260622
2026-06-22 17:14:31 +00:00
peptiderepo
9190361865 test: mock wp_date() in RunReaperTest (v0.22.1 TZ fix)
The reaper's stuck-sweep cutoff now uses wp_date() instead of gmdate()
(v0.22.1 TZ fix — makes cutoff consistent with local-TZ updated_at).
RunReaperTest did not mock wp_date(); alias it to gmdate() so tests
remain timezone-agnostic and the existing SQL assertions still pass.

Agent-Session: prab-reliability-20260622
2026-06-22 16:38:09 +00:00
peptiderepo
7b540a7ce1 style: auto-fix PHPCS violations via phpcbf 2026-06-22 16:35:40 +00:00
peptiderepo
4ca66bcdc1 v0.22.1: sync CLI, reaper TZ fix, maintenance-cron self-heal
- wp prautoblogger generate --sync: synchronous VPS orchestrator mode.
  Acquires lock in-process, calls on_orchestrate_tick() then loops
  on_generate_tick() up to 20 ticks. Emits JSON summary; exits 0/1.
- Generation_Checkpoint_Runner::set_sync_mode(): static flag suppresses
  wp-cron reschedule inside on_generate_tick() when the VPS external
  loop is the driver. No effect on existing async (cron/AJAX) paths.
- Run_Reaper TZ fix: sweep_stuck_stages() + sweep_stuck_runs() now use
  wp_date() for $cutoff so it matches updated_at's local-TZ storage.
  gmdate() was 8h behind on Hostinger (UTC+8) — latent for old stale
  runs but would suppress reaping of same-day-stalled runs.
- DB_Migrations v0221 self-heal: restores missing maintenance crons
  (reap_orphan_research_rows, collect_metrics, sync_runware_models).
  prautoblogger_daily_generation deliberately NOT re-registered — VPS
  systemd timer owns generation scheduling. Note: wp-cron reliability on
  this Hostinger host is suspect; maintenance crons are low-stakes.

Agent-Session: prab-reliability-20260622
2026-06-22 16:32:13 +00:00
11 changed files with 611 additions and 41 deletions

View File

@@ -1117,7 +1117,8 @@ If the host kills any tick:
| Caller | Path |
|--------|------|
| Board "New Article" button | AJAX `prautoblogger_generate_now` → `Generation_Status_Poller::on_ajax_generate_now()` → schedules `prautoblogger_manual_generation` → `Executor::on_manual_generation()` → `kick_off()` |
| WP-CLI | `wp prautoblogger generate` → `WP_CLI_Commands::generate_command()` → `kick_off()` |
| WP-CLI (async) | `wp prautoblogger generate` → `WP_CLI_Commands::generate_command()` → `kick_off()` |
| WP-CLI (sync / VPS) | `wp prautoblogger generate --sync` → `WP_CLI_Commands::run_sync()` → `set_sync_mode(true)` → `on_orchestrate_tick()` → tick loop `on_generate_tick()` (×N) → `set_sync_mode(false)` |
| Old cron hook (retained) | `prautoblogger_manual_generation` → `Executor::on_manual_generation()` → `kick_off()` |
### SSH Workaround Retirement

View File

@@ -11,6 +11,49 @@ and this project uses [Semantic Versioning](https://semver.org/).
- ADR log scaffolded: `docs/adr/` created as the app-local decision log for this plugin; `docs/adr/README.md` is the index; ADR-0001 documents the v0.16.0 editorial illustration image-style pivot. `ARCHITECTURE.md` updated to point to the folder. No runtime changes; docs-only.
- CI constants guard (`scripts/check-constants.php`): new required CI step (`Check PRAUTOBLOGGER_* constants`) fails the build when any `PRAUTOBLOGGER_*` constant referenced in shipped plugin PHP is neither defined in `prautoblogger.php` nor `defined()`-guarded at the reference site. Prevents a recurrence of the v0.19.0 admin-500 regression class.
## [0.22.1] - 2026-06-22
### Added
- **`wp prautoblogger generate --sync`** (VPS orchestrator mode): runs the full
checkpoint pipeline synchronously in the calling process — acquires lock, calls
`on_orchestrate_tick()` in-process, then loops `on_generate_tick()` until the
queue is empty or a terminal state is reached (max 20 ticks). Exits 0 on
success with a JSON summary line; exits 1 on error. Designed to be called from
the Coolify VPS over a held-open SSH session, where Hostinger's ~10-min
background-process kill does not apply.
### Fixed
- **`$sync_mode` guard in `Generation_Checkpoint_Runner::on_generate_tick()`**:
when running under `--sync`, the internal `wp_schedule_single_event()` +
`fire_cron_now()` reschedule is suppressed so the VPS's external loop cannot
race with a competing background wp-cron tick. Existing async (cron/AJAX) paths
are unaffected.
- **Reaper timezone fix** in `Run_Reaper::sweep_stuck_stages()` and
`sweep_stuck_runs()`: `$cutoff` was computed with `gmdate()` (UTC) while
`updated_at` is stored via `current_time('mysql')` (server local time, UTC+8).
Changed to `wp_date()` so the cutoff respects the site timezone. On this host
the 8-hour delta could suppress reaping of a same-day-stalled run; the 44h-old
run `ee6e7d91` was already far outside the threshold and unaffected.
- **Scoped self-heal migration** in `DB_Migrations`: restores the low-stakes
maintenance cron events (`prautoblogger_reap_orphan_research_rows`,
`prautoblogger_collect_metrics`, `prautoblogger_sync_runware_models`) that
vanished after the 06-15 deploy cycle. **`prautoblogger_daily_generation` is
deliberately NOT re-registered** — the VPS systemd timer owns generation
scheduling as of v0.22.1. Note: wp-cron reliability on this Hostinger host is
suspect (background PHP killed ~10 min); maintenance crons are low-stakes and
acceptable on this transport; generation is not.
- **Sync-mode guard tests** (QA NF-1 remediation 2026-06-23): moved sync-mode
tests to `GenerationCheckpointRunnerSyncModeTest.php`; fixed
`test_orchestrate_tick_in_sync_mode_does_not_schedule_background_event` to
return >= 1 idea from `orchestrate_only()` so execution reaches the sync guard
at line ~181 (was vacuous -- previous stub returned `[]` causing early return);
added `test_generate_tick_sync_mode_suppresses_reschedule` for the
`on_generate_tick()` guard; async regression test preserved in same file.
- **Root `CONTEXT.md`**: created domain glossary (run/run_stages/checkpoint/
idea-queue/Generation_Lock) and execution mode comparison (async wp-cron vs.
`--sync` VPS).
- **`ARCHITECTURE.md` SS25**: added `WP-CLI (sync / VPS)` row to Entry Points table.
## [0.22.0] - 2026-06-16
### Changed
@@ -478,7 +521,6 @@ and this project uses [Semantic Versioning](https://semver.org/).
the catalog + OpenRouter static list. Breaking change: callers that expect a
specific static list should note the new dynamic Runware side.
### Fixed
- Runware image generation now correctly uses the admin-selected model instead
of silently falling back to FLUX.1 schnell (runware:100@1) for all non-schnell
@@ -578,7 +620,6 @@ and this project uses [Semantic Versioning](https://semver.org/).
### Fixed
- Image prompt builder now properly delegates to static parser methods
### Fixed
- **Opik autoloader gap** — `class-autoloader.php` listed `services/` as a scan directory but not `services/opik/`, where all four Opik classes live (`Opik_Client`, `Opik_Trace_Context`, `Opik_Span_Queue`, `Opik_Dispatcher`). Composer's classmap (used in PHPUnit) covers `includes/` recursively so tests passed, but the WordPress runtime autoloader would fatal on first Opik class reference in production. Added `services/opik/` to the scan list.

77
CONTEXT.md Normal file
View File

@@ -0,0 +1,77 @@
# PRAutoBlogger — Domain Glossary
Quick reference for the generation pipeline domain model. For full design rationale
see `ARCHITECTURE.md` (§21–§25) and `CONVENTIONS.md`.
---
## Core Domain Terms
**run**
A single end-to-end article generation cycle: orchestrate → generate idea(s) →
finalize. Represented as a row in `wp_prautoblogger_runs` (status, cost ceiling,
timestamps). One run may produce multiple articles if the daily idea-count > 1.
**run\_stages** (`wp_prautoblogger_run_stages`)
Sub-steps within a run (research, analysis, generation, publish). Each stage has
its own status and can be replayed independently via `Rerun_Executor`. Generation
checkpoint runs populate stages as Article_Worker progresses.
**checkpoint**
The persistence boundary between pipeline ticks. After each tick the idea queue
and run state are written to `wp_options` so a process kill cannot lose work. The
next tick reads the queue option and continues. See §25 of ARCHITECTURE.md.
**idea queue** (`prautoblogger_article_queue` option)
Serialized array of `Article_Idea` objects written by `on_orchestrate_tick()` and
consumed one-at-a-time by successive `on_generate_tick()` calls. Deleted on
finalize or cleanup.
**Generation\_Lock** (`wp_prautoblogger_generation_lock` or `wp_options`)
DB mutex that prevents two concurrent runs. Acquired at the start of
`on_orchestrate_tick()`, released by `Helpers::finalize()` or on error/halt.
---
## Execution Modes
### Async (wp-cron driven)
Default path for the Board "New Article" button and the plain
`wp prautoblogger generate` WP-CLI command.
1. `kick_off()` schedules `prautoblogger_gen_orchestrate` and calls `fire_cron_now()`.
2. `on_orchestrate_tick()` runs in a background PHP process, schedules
`prautoblogger_gen_tick`, and calls `fire_cron_now()` to spawn the next process.
3. Each `on_generate_tick()` spawns the next until the queue drains.
Limitation: Hostinger kills background PHP processes at ~10 min. Orchestrate tick
(LLM-free) and individual generate ticks (one LLM call each) are both well within
this limit; the checkpoint design is the mitigation.
### Sync (VPS-driven / `--sync`)
Used by the Coolify KVM8 VPS systemd timer via `infra/vps/orchestrate-generation.sh`
in `peptide-e2e`. Introduced v0.22.1.
1. `run_sync()` sets `Generation_Checkpoint_Runner::$sync_mode = true`.
2. Calls `on_orchestrate_tick()` **in-process** (no background spawn).
3. Loops calling `on_generate_tick()` directly until queue empty or terminal state.
4. `$sync_mode = true` suppresses all `wp_schedule_single_event` +
`fire_cron_now` calls inside both tick methods, preventing a competing
background cron chain from forming in parallel.
The VPS SSH session is held open; the process is not subject to Hostinger's
background-kill constraint.
---
## Table Shorthand
Throughout the codebase `wp_prautoblogger_*` tables are referenced by their
`$wpdb->` property names (e.g. `$wpdb->prab_runs`, `$wpdb->prab_run_stages`).
Full table names are defined in `class-activator.php`.
---
*See also: `ARCHITECTURE.md` §25 (checkpoint design), `CONVENTIONS.md` (coding rules).*

View File

@@ -8,6 +8,14 @@ declare(strict_types=1);
*/
class PRAutoBlogger_WP_CLI_Commands {
/**
* Max generate-tick iterations for the --sync loop (covers daily_article_target = 1
* with ample headroom; prevents infinite loops if the queue never drains).
*
* @var int
*/
private const SYNC_MAX_TICKS = 20;
/**
* Register WP-CLI commands.
*
@@ -18,12 +26,19 @@ class PRAutoBlogger_WP_CLI_Commands {
return;
}
// Command: wp prautoblogger opik:eval
\WP_CLI::add_command(
'prautoblogger generate',
array( self::class, 'generate_command' ),
array(
'shortdesc' => 'Queue a new article generation run on chained-cron checkpoints',
'shortdesc' => 'Queue (or synchronously run) a new article generation run',
'synopsis' => array(
array(
'type' => 'flag',
'name' => 'sync',
'description' => 'Run the full pipeline synchronously in this process (VPS orchestrator mode). Blocks until complete.',
'optional' => true,
),
),
)
);
@@ -51,6 +66,148 @@ class PRAutoBlogger_WP_CLI_Commands {
);
}
/**
* Queue or synchronously run a new article generation run.
*
* Without --sync: routes through kick_off() (same as the board "New Article"
* button). Returns immediately; all pipeline work happens in cron.
*
* With --sync: runs the pipeline synchronously in this SSH/CLI process.
* 1. Acquires the generation lock (skips if a live run is in progress).
* 2. Calls on_orchestrate_tick() to collect/score ideas in-process.
* 3. Loops calling on_generate_tick() until the queue is empty or the run
* reaches a terminal state, up to SYNC_MAX_TICKS iterations.
* 4. Emits a JSON summary line on stdout and exits 0 (success) or 1 (error).
*
* The --sync path suppresses wp-cron reschedule inside on_generate_tick() so
* the external loop cannot race with a background cron tick.
*
* ## OPTIONS
*
* [--sync]
* : Run synchronously (VPS orchestrator mode).
*
* ## EXAMPLES
*
* wp prautoblogger generate
* wp prautoblogger generate --sync
*
* @param array $args Positional args (unused).
* @param array $assoc_args Associative args: sync.
* @return void
*/
public static function generate_command( array $args, array $assoc_args ): void {
if ( isset( $assoc_args['sync'] ) ) {
self::run_sync();
return;
}
PRAutoBlogger_Generation_Checkpoint_Runner::kick_off();
\WP_CLI::success( 'Generation queued on chained-cron checkpoints. Check the board or Activity Log for progress.' );
}
/**
* Run the full pipeline synchronously in this process (--sync mode).
*
* Called only from generate_command(); never from wp-cron or AJAX paths.
*
* @return void
*/
private static function run_sync(): void {
// Honour ignore_user_abort so a dropped SSH session does not kill us.
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@ignore_user_abort( true );
// Check for a live (non-expired) lock. Generation_Lock::acquire() already
// cleans expired locks on its DELETE pass, so if acquire() returns false
// there is a genuinely live concurrent run — skip cleanly.
if ( ! PRAutoBlogger_Generation_Lock::is_locked() ) {
// Lock is not held; proceed. acquire() is called inside on_orchestrate_tick().
} else {
$acquired_at = PRAutoBlogger_Generation_Lock::get_acquired_at();
$age = null !== $acquired_at ? ( time() - $acquired_at ) : HOUR_IN_SECONDS;
if ( $age < HOUR_IN_SECONDS ) {
\WP_CLI::log(
json_encode(
array(
'status' => 'skipped',
'reason' => 'run_in_progress',
'lock_age_s' => $age,
)
)
);
exit( 0 );
}
// Expired lock — on_orchestrate_tick()'s acquire() will clean it.
}
// Enable sync-mode: on_generate_tick() will skip the wp-cron reschedule.
PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( true );
\WP_CLI::log( 'PRAB-SYNC: starting orchestrate tick' );
PRAutoBlogger_Generation_Checkpoint_Runner::on_orchestrate_tick();
// If the orchestrate tick failed to acquire the lock or found no ideas,
// the RUN_ID option will be absent. Detect and exit cleanly.
$run_id = (string) get_option( 'prautoblogger_checkpoint_run_id', '' );
if ( '' === $run_id ) {
$queue = get_option( 'prautoblogger_article_queue' );
if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) {
\WP_CLI::log(
json_encode(
array(
'status' => 'done',
'generated' => 0,
'reason' => 'no_ideas_or_lock_failed',
)
)
);
exit( 0 );
}
}
// Tick loop: drive generate ticks synchronously.
$ticks = 0;
while ( $ticks < self::SYNC_MAX_TICKS ) {
$queue = get_option( 'prautoblogger_article_queue' );
if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) {
break; // Queue drained or finalized.
}
++$ticks;
\WP_CLI::log( sprintf( 'PRAB-SYNC: generate tick %d (ideas remaining: %d)', $ticks, count( $queue['ideas'] ) ) );
PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
}
PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( false );
if ( $ticks >= self::SYNC_MAX_TICKS ) {
\WP_CLI::warning( 'PRAB-SYNC: hit max-tick guard (' . self::SYNC_MAX_TICKS . '). Queue may not be fully drained.' );
}
// Read final status transient for summary.
$status = get_transient( 'prautoblogger_generation_status' );
$result = is_array( $status ) ? $status : array();
$summary = array(
'status' => 'done',
'generated' => (int) ( $result['generated'] ?? 0 ),
'published' => (int) ( $result['published'] ?? 0 ),
'rejected' => (int) ( $result['rejected'] ?? 0 ),
'cost_usd' => round( (float) ( $result['cost'] ?? 0.0 ), 4 ),
'ticks' => $ticks,
);
\WP_CLI::log( json_encode( $summary ) );
if ( 'error' === ( $result['status'] ?? '' ) ) {
\WP_CLI::error( 'PRAB-SYNC: generation completed with error: ' . ( $result['message'] ?? 'unknown' ), false );
exit( 1 );
}
\WP_CLI::success( 'PRAB-SYNC: generation complete.' );
exit( 0 );
}
/**
* Run eval on the frozen dataset.
*
@@ -77,27 +234,4 @@ class PRAutoBlogger_WP_CLI_Commands {
exit( $result['items_run'] > 0 ? 0 : 1 );
}
/**
* Queue a new article generation run on chained-cron checkpoints.
*
* Routes through PRAutoBlogger_Generation_Checkpoint_Runner::kick_off()
* (same path as the board "New Article" button). Does NOT execute
* synchronously -- check the board or Activity Log for progress.
* This retires the `do_action("prautoblogger_manual_generation")` workaround.
*
* ## OPTIONS
*
* ## EXAMPLES
*
* wp prautoblogger generate
*
* @param array $args Positional args (unused).
* @param array $assoc_args Associative args (unused).
* @return void
*/
public static function generate_command( array $args, array $assoc_args ): void {
PRAutoBlogger_Generation_Checkpoint_Runner::kick_off();
\WP_CLI::success( 'Generation queued on chained-cron checkpoints. Check the board or Activity Log for progress.' );
}
}

View File

@@ -125,5 +125,32 @@ class PRAutoBlogger_DB_Migrations {
}
update_option( 'prautoblogger_migrated_enc_prefix', '1' );
}
// v0.22.1 self-heal: restore low-stakes maintenance cron events that were
// lost when the WP-Cron schedule vanished after the 06-15 deploy cycle.
// NOTE: wp-cron reliability on this Hostinger host is suspect (background
// PHP processes are killed at ~10 min). These low-stakes events (metrics,
// reaper, Runware sync) are acceptable on wp-cron; daily GENERATION is NOT
// re-registered here — the VPS systemd timer owns generation scheduling.
// The prautoblogger_migrated_schedule_tz_v082 guard is intentionally NOT
// re-used here; this migration targets the missing events themselves.
if ( ! get_option( 'prautoblogger_migrated_maintenance_crons_v0221' ) ) {
// Re-register maintenance crons only — daily_generation deliberately excluded.
// Activator::schedule_cron() uses wp_next_scheduled() guards so it is
// idempotent and will not overwrite events that are already present.
if ( ! wp_next_scheduled( 'prautoblogger_reap_orphan_research_rows' ) ) {
$tomorrow_reap = strtotime( 'tomorrow 03:15' );
if ( false !== $tomorrow_reap ) {
wp_schedule_event( (int) $tomorrow_reap, 'daily', 'prautoblogger_reap_orphan_research_rows' );
}
}
if ( ! wp_next_scheduled( 'prautoblogger_collect_metrics' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'prautoblogger_six_hours', 'prautoblogger_collect_metrics' );
}
if ( ! wp_next_scheduled( 'prautoblogger_sync_runware_models' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'prautoblogger_sync_runware_models' );
}
update_option( 'prautoblogger_migrated_maintenance_crons_v0221', '1' );
}
}
}

View File

@@ -52,6 +52,26 @@ class PRAutoBlogger_Generation_Checkpoint_Runner {
/** WP-Cron action for each article-generation tick. */
public const GENERATE_ACTION = 'prautoblogger_gen_tick';
/**
* When true, on_generate_tick() skips wp-cron rescheduling so the VPS
* orchestrator's external loop is the sole driver. Set via set_sync_mode().
* Never true on the wp-cron path; reset to false after the sync run.
*
* @var bool
*/
private static bool $sync_mode = false;
/**
* Enable or disable synchronous-driver mode.
* Called by WP_CLI_Commands::run_sync() before and after the tick loop.
*
* @param bool $enabled True to suppress internal cron reschedule.
* @return void
*/
public static function set_sync_mode( bool $enabled ): void {
self::$sync_mode = $enabled;
}
/**
* Schedule the orchestration tick and write the initial status transient.
* Called by on_ajax_generate_now() (via Executor) and by the WP-CLI command.
@@ -155,11 +175,15 @@ class PRAutoBlogger_Generation_Checkpoint_Runner {
)
);
if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) {
wp_schedule_single_event( time(), self::GENERATE_ACTION );
// In sync mode the VPS tick loop is the external driver; skip
// wp-cron reschedule to prevent a competing background tick.
// on_generate_tick() has the same guard for subsequent reschedules.
if ( ! self::$sync_mode ) {
if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) {
wp_schedule_single_event( time(), self::GENERATE_ACTION );
}
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
}
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Orchestrate tick %s: %s', get_class( $e ), $e->getMessage() ),
@@ -245,10 +269,14 @@ class PRAutoBlogger_Generation_Checkpoint_Runner {
if ( ! empty( $queue['ideas'] ) ) {
update_option( self::QUEUE_KEY, $queue, false );
PRAutoBlogger_Pipeline_Status::update_queue_progress( $queue );
if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) {
wp_schedule_single_event( time(), self::GENERATE_ACTION );
// In sync mode the VPS tick loop is the external driver; skip
// wp-cron reschedule to prevent a competing background tick.
if ( ! self::$sync_mode ) {
if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) {
wp_schedule_single_event( time(), self::GENERATE_ACTION );
}
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
}
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
} else {
PRAutoBlogger_Post_Assembler::amortize_research_costs( $run_id );
PRAutoBlogger_Generation_Checkpoint_Helpers::finalize( $queue['results'], $run_id );

View File

@@ -133,7 +133,9 @@ class PRAutoBlogger_Run_Reaper {
* @param int $seconds Expected seconds (default 300).
*/
$expected = (int) apply_filters( 'prautoblogger_filter_stage_expected_seconds', self::EXPECTED_STAGE_SECONDS );
$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( 2 * $expected ) );
// Use wp_date() so the cutoff is expressed in the site's local timezone,
// matching the updated_at values written by current_time('mysql') (v0.22.1 TZ fix).
$cutoff = wp_date( 'Y-m-d H:i:s', time() - ( 2 * $expected ) );
$now = current_time( 'mysql' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
@@ -169,7 +171,8 @@ class PRAutoBlogger_Run_Reaper {
* @param int $seconds Expected seconds (default 1800).
*/
$expected = (int) apply_filters( 'prautoblogger_filter_expected_run_seconds', self::EXPECTED_RUN_SECONDS );
$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( 2 * $expected ) );
// Use wp_date() so the cutoff matches updated_at's local-timezone storage (v0.22.1 TZ fix).
$cutoff = wp_date( 'Y-m-d H:i:s', time() - ( 2 * $expected ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$stuck = $wpdb->get_col(

View File

@@ -9,7 +9,7 @@
* Plugin Name: PRAutoBlogger
* Plugin URI: https://peptiderepo.com/prautoblogger
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
* Version: 0.22.0
* Version: 0.22.1
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: PeptideRepo
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
| Defined here so every file in the plugin can reference paths, versions,
| and limits without magic strings.
*/
define( 'PRAUTOBLOGGER_VERSION', '0.22.0' );
define( 'PRAUTOBLOGGER_VERSION', '0.22.1' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' );
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );

View File

@@ -0,0 +1,249 @@
<?php
/**
* Sync-mode guard tests for PRAutoBlogger_Generation_Checkpoint_Runner (v0.22.1).
*
* Isolated from GenerationCheckpointRunnerTest to keep both files under 300 lines.
*
* Contract under test:
* set_sync_mode(true) -> on_orchestrate_tick() must NOT schedule GENERATE_ACTION
* or call spawn_cron/fire_cron_now (VPS loop drives ticks directly).
* NON-VACUOUS: orchestrate_only() returns 1 idea so execution reaches the guard
* at line ~181, not the empty-ideas early return at ~148.
* set_sync_mode(true) -> on_generate_tick() with ideas remaining must NOT schedule
* GENERATE_ACTION or call spawn_cron/fire_cron_now.
* set_sync_mode(false) -> on_generate_tick() with ideas remaining DOES schedule
* GENERATE_ACTION (regression guard -- async path unaffected by sync guard).
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class GenerationCheckpointRunnerSyncModeTest extends BaseTestCase {
/** @var string[] Cron hooks captured from wp_schedule_single_event(). */
private array $scheduled = array();
/** @var array<string, mixed> Option writes captured from update_option(). */
private array $options = array();
/** @var bool Tracks whether spawn_cron was called. */
private bool $spawn_called = false;
protected function setUp(): void {
parent::setUp();
$this->scheduled = array();
$this->options = array();
$this->spawn_called = false;
Functions\when( 'wp_schedule_single_event' )->alias(
function ( $ts, $hook, $args = array() ) {
$this->scheduled[] = $hook;
return true;
}
);
Functions\when( 'wp_next_scheduled' )->justReturn( false );
Functions\when( 'spawn_cron' )->alias( function () {
$this->spawn_called = true;
return true;
} );
Functions\when( 'wp_remote_post' )->justReturn( array() );
Functions\when( 'site_url' )->justReturn( 'https://example.com' );
Functions\when( 'set_transient' )->justReturn( true );
Functions\when( 'update_option' )->alias(
function ( $name, $value, $autoload = null ) {
$this->options[ $name ] = $value;
return true;
}
);
Functions\when( 'delete_option' )->alias(
function ( $name ) {
unset( $this->options[ $name ] );
return true;
}
);
Functions\when( 'get_option' )->alias(
function ( $name, $default = false ) {
return $this->options[ $name ] ?? $default;
}
);
Functions\when( 'wp_generate_uuid4' )->justReturn( 'sync-test-uuid-001' );
$this->options['prautoblogger_monthly_budget_usd'] = '0';
Functions\when( 'wp_cache_get' )->justReturn( false );
Functions\when( 'wp_cache_set' )->justReturn( true );
Functions\when( 'wp_cache_delete' )->justReturn( true );
\PRAutoBlogger_Run_State::flush_cache();
}
protected function tearDown(): void {
\PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( false );
\PRAutoBlogger_Run_State::flush_cache();
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/** Stub the generation lock to always succeed. */
private function wire_wpdb_for_lock(): void {
$wpdb = $this->create_mock_wpdb();
$wpdb->options = 'wp_options';
$GLOBALS['wpdb'] = $wpdb;
$wpdb->method( 'query' )->willReturn( 1 );
$wpdb->method( 'get_var' )->willReturn( null );
}
/** Build a minimal Article_Idea-compatible array. */
private function make_idea( string $topic = 'Sync Topic' ): array {
return array(
'topic' => $topic,
'article_type' => 'guide',
'suggested_title' => 'Guide to ' . $topic,
'summary' => '',
'score' => 0.9,
'analysis_id' => 1,
'source_ids' => array(),
'key_points' => array(),
'target_keywords' => array(),
);
}
// __ sync-mode guard: on_orchestrate_tick() ______________________________________________
/**
* sync_mode=true + >=1 idea returned: on_orchestrate_tick() must not schedule
* GENERATE_ACTION or call spawn_cron (VPS loop drives generate ticks directly).
*
* NON-VACUOUS: orchestrate_only() returns 1 idea so execution reaches the sync
* guard at line ~181, not the empty-ideas early return at ~148.
*/
public function test_orchestrate_tick_in_sync_mode_does_not_schedule_background_event(): void {
$this->wire_wpdb_for_lock();
// Provide Pipeline_Runner stub that returns 1 idea -- forces execution past
// the empty-ideas early return so the sync guard at line ~181 is exercised.
if ( ! class_exists( 'PRAutoBlogger_Pipeline_Runner', false ) ) {
// phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired
eval( 'class PRAutoBlogger_Pipeline_Runner {
public function set_skip_dedup($v){ return $this; }
public function orchestrate_only($ct){
$idea = new PRAutoBlogger_Article_Idea([
"topic"=>"Sync Topic","article_type"=>"guide",
"suggested_title"=>"Guide","summary"=>"","score"=>0.9,
"analysis_id"=>1,"source_ids"=>[],"key_points"=>[],
"target_keywords"=>[]
]);
return [$idea];
}
}' );
}
\PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( true );
\PRAutoBlogger_Generation_Checkpoint_Runner::on_orchestrate_tick();
$this->assertNotContains(
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
$this->scheduled,
'sync mode: on_orchestrate_tick must not schedule GENERATE_ACTION'
);
$this->assertFalse(
$this->spawn_called,
'sync mode: on_orchestrate_tick must not call spawn_cron'
);
}
// __ sync-mode guard: on_generate_tick() _________________________________________________
/**
* sync_mode=true + 2 ideas in queue: on_generate_tick() must not reschedule
* GENERATE_ACTION or call spawn_cron (VPS loop is the sole driver of next tick).
*
* Uses 2 ideas so the code stays in the "more ideas remain" branch -- this
* path calls Article_Worker->generate() and then the sync guard, never
* Post_Assembler::amortize_research_costs() (that is in the last-idea branch).
* Article_Worker is stubbed; Post_Assembler is not needed and must NOT be
* redefined here to avoid clobbering the real class for downstream tests.
*/
public function test_generate_tick_sync_mode_suppresses_reschedule(): void {
$run_id = 'sync-gen-tick-uuid';
$this->options['prautoblogger_article_queue'] = array(
'run_id' => $run_id,
'ideas' => array( $this->make_idea( 'A' ), $this->make_idea( 'B' ) ),
'results' => array( 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ),
);
$wpdb = $this->create_mock_wpdb();
$wpdb->options = 'wp_options';
$GLOBALS['wpdb'] = $wpdb;
$wpdb->method( 'get_row' )->willReturn( array( 'status' => 'running', 'ceiling_usd' => '0' ) );
$wpdb->method( 'query' )->willReturn( 1 );
$wpdb->method( 'get_var' )->willReturn( null );
// Stub Article_Worker only -- Post_Assembler is NOT stubbed here because
// amortize_research_costs() is not called on the "more ideas remain" path,
// and redefining it with an incomplete stub breaks Publisher tests that run
// after this file (alphabetical: SyncModeTest < Test).
if ( ! class_exists( 'PRAutoBlogger_Article_Worker', false ) ) {
// phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired
eval( 'class PRAutoBlogger_Article_Worker {
public function __construct($ct){}
public function generate($idea){ return ["generated"=>1,"published"=>1,"rejected"=>0,"cost"=>0.01]; }
}' );
}
\PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( true );
\PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
$this->assertNotContains(
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
$this->scheduled,
'sync mode: on_generate_tick must not schedule GENERATE_ACTION'
);
$this->assertFalse(
$this->spawn_called,
'sync mode: on_generate_tick must not call spawn_cron'
);
}
// __ async regression guard ______________________________________________________________
/**
* sync_mode=false: on_generate_tick() with ideas remaining DOES schedule
* GENERATE_ACTION (regression guard -- async path must be unaffected by sync guard).
*/
public function test_generate_tick_async_mode_schedules_generate_action(): void {
$run_id = 'async-regression-uuid';
$this->options['prautoblogger_article_queue'] = array(
'run_id' => $run_id,
'ideas' => array( $this->make_idea( 'A' ), $this->make_idea( 'B' ) ),
'results' => array( 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ),
);
$wpdb = $this->create_mock_wpdb();
$wpdb->options = 'wp_options';
$GLOBALS['wpdb'] = $wpdb;
$wpdb->method( 'get_row' )->willReturn( array( 'status' => 'running', 'ceiling_usd' => '0' ) );
$wpdb->method( 'query' )->willReturn( 1 );
$wpdb->method( 'get_var' )->willReturn( null );
// Article_Worker stub only -- see note in test_generate_tick_sync_mode_suppresses_reschedule.
if ( ! class_exists( 'PRAutoBlogger_Article_Worker', false ) ) {
// phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired
eval( 'class PRAutoBlogger_Article_Worker {
public function __construct($ct){}
public function generate($idea){ return ["generated"=>1,"published"=>1,"rejected"=>0,"cost"=>0.01]; }
}' );
}
\PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( false );
\PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
$this->assertContains(
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
$this->scheduled,
'async mode: on_generate_tick must schedule GENERATE_ACTION when ideas remain'
);
}
}

View File

@@ -6,6 +6,9 @@
* kick_off() -- schedules cron + writes initial transient, no pipeline call.
* on_generate_tick() -- pops ONE idea, reschedules or finalizes; halted run aborts early.
*
* Sync-mode guard tests live in GenerationCheckpointRunnerSyncModeTest.php
* (split to comply with the 300-line rule).
*
* @package PRAutoBlogger\Tests\Core
*/
@@ -104,7 +107,7 @@ class GenerationCheckpointRunnerTest extends BaseTestCase {
);
}
// ── kick_off() ────────────────────────────────────────────────────────
// __ kick_off() __________________________________________________________________________
/**
* kick_off() schedules exactly the ORCHESTRATE cron action and writes
@@ -129,7 +132,7 @@ class GenerationCheckpointRunnerTest extends BaseTestCase {
$this->assertTrue( $found_running_transient, 'kick_off() must write a running status transient' );
}
// ── on_generate_tick() ────────────────────────────────────────────────
// __ on_generate_tick() __________________________________________________________________
/**
* An empty queue causes generate tick to finalize without scheduling

View File

@@ -46,6 +46,13 @@ class RunReaperTest extends BaseTestCase {
}
);
Functions\when( 'apply_filters' )->returnArg( 2 );
// wp_date() is called by the reaper's stuck-sweep cutoff (v0.22.1 TZ fix).
// Alias to gmdate() so tests remain timezone-agnostic.
Functions\when( 'wp_date' )->alias(
static function ( string $format, ?int $timestamp = null ): string {
return gmdate( $format, null !== $timestamp ? $timestamp : time() );
}
);
\PRAutoBlogger_Run_State::flush_cache();
\PRAutoBlogger_Run_Stage_State::flush_cache();
}