Compare commits
7 Commits
claude/rem
...
claude/pra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0689989318 | ||
|
|
d5fb1299ac | ||
|
|
404b3b4ce2 | ||
|
|
6229601df4 | ||
|
|
9190361865 | ||
|
|
7b540a7ce1 | ||
|
|
4ca66bcdc1 |
@@ -1117,7 +1117,8 @@ If the host kills any tick:
|
|||||||
| Caller | Path |
|
| 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()` |
|
| 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()` |
|
| Old cron hook (retained) | `prautoblogger_manual_generation` → `Executor::on_manual_generation()` → `kick_off()` |
|
||||||
|
|
||||||
### SSH Workaround Retirement
|
### SSH Workaround Retirement
|
||||||
|
|||||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -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.
|
- 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.
|
- 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
|
## [0.22.0] - 2026-06-16
|
||||||
|
|
||||||
### Changed
|
### 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
|
the catalog + OpenRouter static list. Breaking change: callers that expect a
|
||||||
specific static list should note the new dynamic Runware side.
|
specific static list should note the new dynamic Runware side.
|
||||||
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Runware image generation now correctly uses the admin-selected model instead
|
- 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
|
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
|
### Fixed
|
||||||
- Image prompt builder now properly delegates to static parser methods
|
- Image prompt builder now properly delegates to static parser methods
|
||||||
|
|
||||||
|
|
||||||
### Fixed
|
### 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.
|
- **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
77
CONTEXT.md
Normal 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).*
|
||||||
@@ -8,6 +8,14 @@ declare(strict_types=1);
|
|||||||
*/
|
*/
|
||||||
class PRAutoBlogger_WP_CLI_Commands {
|
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.
|
* Register WP-CLI commands.
|
||||||
*
|
*
|
||||||
@@ -18,12 +26,19 @@ class PRAutoBlogger_WP_CLI_Commands {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command: wp prautoblogger opik:eval
|
|
||||||
\WP_CLI::add_command(
|
\WP_CLI::add_command(
|
||||||
'prautoblogger generate',
|
'prautoblogger generate',
|
||||||
array( self::class, 'generate_command' ),
|
array( self::class, 'generate_command' ),
|
||||||
array(
|
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.
|
* Run eval on the frozen dataset.
|
||||||
*
|
*
|
||||||
@@ -77,27 +234,4 @@ class PRAutoBlogger_WP_CLI_Commands {
|
|||||||
|
|
||||||
exit( $result['items_run'] > 0 ? 0 : 1 );
|
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.' );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,5 +125,32 @@ class PRAutoBlogger_DB_Migrations {
|
|||||||
}
|
}
|
||||||
update_option( 'prautoblogger_migrated_enc_prefix', '1' );
|
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' );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,26 @@ class PRAutoBlogger_Generation_Checkpoint_Runner {
|
|||||||
/** WP-Cron action for each article-generation tick. */
|
/** WP-Cron action for each article-generation tick. */
|
||||||
public const GENERATE_ACTION = 'prautoblogger_gen_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.
|
* Schedule the orchestration tick and write the initial status transient.
|
||||||
* Called by on_ajax_generate_now() (via Executor) and by the WP-CLI command.
|
* Called by on_ajax_generate_now() (via Executor) and by the WP-CLI command.
|
||||||
@@ -155,11 +175,15 @@ class PRAutoBlogger_Generation_Checkpoint_Runner {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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 ) ) {
|
if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) {
|
||||||
wp_schedule_single_event( time(), 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 ) {
|
} catch ( \Throwable $e ) {
|
||||||
PRAutoBlogger_Logger::instance()->error(
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
sprintf( 'Orchestrate tick %s: %s', get_class( $e ), $e->getMessage() ),
|
sprintf( 'Orchestrate tick %s: %s', get_class( $e ), $e->getMessage() ),
|
||||||
@@ -245,10 +269,14 @@ class PRAutoBlogger_Generation_Checkpoint_Runner {
|
|||||||
if ( ! empty( $queue['ideas'] ) ) {
|
if ( ! empty( $queue['ideas'] ) ) {
|
||||||
update_option( self::QUEUE_KEY, $queue, false );
|
update_option( self::QUEUE_KEY, $queue, false );
|
||||||
PRAutoBlogger_Pipeline_Status::update_queue_progress( $queue );
|
PRAutoBlogger_Pipeline_Status::update_queue_progress( $queue );
|
||||||
|
// 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 ) ) {
|
if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) {
|
||||||
wp_schedule_single_event( time(), 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 {
|
} else {
|
||||||
PRAutoBlogger_Post_Assembler::amortize_research_costs( $run_id );
|
PRAutoBlogger_Post_Assembler::amortize_research_costs( $run_id );
|
||||||
PRAutoBlogger_Generation_Checkpoint_Helpers::finalize( $queue['results'], $run_id );
|
PRAutoBlogger_Generation_Checkpoint_Helpers::finalize( $queue['results'], $run_id );
|
||||||
|
|||||||
@@ -133,7 +133,9 @@ class PRAutoBlogger_Run_Reaper {
|
|||||||
* @param int $seconds Expected seconds (default 300).
|
* @param int $seconds Expected seconds (default 300).
|
||||||
*/
|
*/
|
||||||
$expected = (int) apply_filters( 'prautoblogger_filter_stage_expected_seconds', self::EXPECTED_STAGE_SECONDS );
|
$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' );
|
$now = current_time( 'mysql' );
|
||||||
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
@@ -169,7 +171,8 @@ class PRAutoBlogger_Run_Reaper {
|
|||||||
* @param int $seconds Expected seconds (default 1800).
|
* @param int $seconds Expected seconds (default 1800).
|
||||||
*/
|
*/
|
||||||
$expected = (int) apply_filters( 'prautoblogger_filter_expected_run_seconds', self::EXPECTED_RUN_SECONDS );
|
$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
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
$stuck = $wpdb->get_col(
|
$stuck = $wpdb->get_col(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* Plugin Name: PRAutoBlogger
|
* Plugin Name: PRAutoBlogger
|
||||||
* Plugin URI: https://peptiderepo.com/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.
|
* 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 at least: 6.0
|
||||||
* Requires PHP: 7.4
|
* Requires PHP: 7.4
|
||||||
* Author: PeptideRepo
|
* Author: PeptideRepo
|
||||||
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
| Defined here so every file in the plugin can reference paths, versions,
|
| Defined here so every file in the plugin can reference paths, versions,
|
||||||
| and limits without magic strings.
|
| 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_DB_VERSION', '1.4.0' );
|
||||||
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
|
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
|
||||||
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
|
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
|
||||||
|
|||||||
249
tests/unit/Core/GenerationCheckpointRunnerSyncModeTest.php
Normal file
249
tests/unit/Core/GenerationCheckpointRunnerSyncModeTest.php
Normal 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@
|
|||||||
* kick_off() -- schedules cron + writes initial transient, no pipeline call.
|
* kick_off() -- schedules cron + writes initial transient, no pipeline call.
|
||||||
* on_generate_tick() -- pops ONE idea, reschedules or finalizes; halted run aborts early.
|
* 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
|
* @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
|
* 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' );
|
$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
|
* An empty queue causes generate tick to finalize without scheduling
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ class RunReaperTest extends BaseTestCase {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
Functions\when( 'apply_filters' )->returnArg( 2 );
|
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_State::flush_cache();
|
||||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user