Compare commits

..

9 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
peptiderepo
a2af04f2c4 docs: add app-local ADR log + ADR-0001 editorial illustration image style (#169)
Adds docs/adr/ scaffold (README index) and ADR-0001 documenting the v0.16.0 pivot to text-free editorial illustration with caption-as-HTML.

Agent-Session: adr-scaffold-20260617
2026-06-19 16:25:53 +07:00
peptiderepo
2871761665 ci: fix peptide-e2e org ref in deploy.yml and ci.yml (#168)
Fix stale peptiderepo/peptide-e2e refs in both deploy.yml and ci.yml.

Agent-Session: fix-e2e-ref-20260617
2026-06-17 11:38:28 +07:00
13 changed files with 803 additions and 41 deletions

View File

@@ -2,6 +2,8 @@
> **Cross-app context:** decisions that affect multiple plugins (Cloudflare AI Gateway routing, OpenRouter account sharing, the interface pattern, image-generation stack, social distributor choice) are recorded in `Peptide Repo CTO/docs/engineering/decisions/`. The incident runbook for cross-app failure modes is at `Peptide Repo CTO/docs/engineering/INCIDENT-RUNBOOK.md`. Engineer PRAutoBlogger should read both before making decisions that cross plugin boundaries.
> **App-local decisions:** choices that affect only this plugin are recorded in [`docs/adr/`](docs/adr/README.md) — a per-repo numbered log (0001, 0002, …). Read `docs/adr/README.md` for the index. The routing rule and required template live centrally in `Peptide Repo CTO/docs/engineering/ADR-PROCESS.md`.
PRAutoBlogger is a WordPress plugin that monitors social media (starting with Reddit) for recurring questions, complaints, and comparisons in a configured niche, uses LLM agents (via OpenRouter) to generate high-quality blog articles from those insights, runs them through a chief editor agent for quality review, and auto-publishes them on a configurable daily schedule. All collected data and generation metrics are stored for a self-improvement feedback loop.
---
@@ -1115,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

@@ -8,8 +8,52 @@ and this project uses [Semantic Versioning](https://semver.org/).
## [Unreleased - Internal]
### Internal
- 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
@@ -477,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
@@ -577,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

@@ -0,0 +1,163 @@
# ADR-0001: Editorial illustration image style — pivot from comic to text-free illustration with caption-as-HTML
- **Status:** Accepted
- **Date:** 2026-05-29
- **Deciders:** CTO, Engineer PRAutoBlogger
- **Supersedes:** —
- **Superseded by:** —
- **Related:** ARCHITECTURE.md §#20; CHANGELOG.md [0.16.0]; PR #146 (d00e746);
`docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md`;
ARCHITECTURE.md §#21 (v0.17.0 deterministic composer, the follow-on commit)
---
## Context
PRAutoBlogger generates two AI images (A/B pair) for each article and publishes
them as featured images. From the initial comic-strip launch (commit b17ed47,
`feat: switch to single-panel newspaper comic style for article images`) through
v0.15.x, the image style was a **single-panel newspaper comic** — the rewriter
LLM was asked to produce a comic gag or visual pun, and the result was appended
to a style suffix and sent to the diffusion model.
By April 2026 two problems had accumulated:
1. **Text legibility.** Small diffusion models (FLUX.1 schnell is the default
since v0.9.0 at ~$0.0006/image) cannot reliably render readable text inside
an image. Comic panels depend on legible speech bubbles or captions burned
into the image; both were routinely garbled (confirmed in the 2026-04-21 A/B
finding documented in ARCHITECTURE.md §#21).
2. **Caption path already existed.** A separate HTML caption below the image had
been extracted as early as commit 214f57b (`Separate comic captions from
generated images`, 2026-04-18). The scene/caption split in
`Image_Scene_Parser` was already live, meaning there was an established path
for readable text that did not depend on the diffusion model rendering it.
Together these created pressure to stop asking the diffusion model to render
text at all, and instead shift all readable copy to the HTML caption path that
was already in place.
## Decision drivers
1. **Image quality on default model.** FLUX.1 schnell (the default since v0.9.0)
produces garbled text in generated images. A style that depends on readable
in-image text degrades silently on the model we actually ship.
2. **Editorial trust signal.** Peptide Repo's editorial direction is
science-informational; comic gags undercut the authority the site needs for
health-adjacent content.
3. **Reuse the existing caption path.** The scene/caption split and HTML caption
path were already live; the change is a prompt rewrite + template substitution
with no new infrastructure.
4. **Admin configurability.** The style suffix was a single string appended to
every prompt; a template with a named substitution token (`{{ topic_summary
}}`) is more maintainable and admin-overridable without a code deploy.
5. **Cost and provider neutrality.** The change is prompt-only; provider, model,
and pricing are unchanged.
## Options considered
### Option A — Keep the comic style, fix text rendering per-model
Ask the rewriter LLM to omit text from comic panels (no speech bubbles, no
caption burns), and describe a visual scene instead. Keep the style suffix
pattern.
**Pros:**
- Minimal code change — just a prompt edit.
- Comic aesthetic is recognisable and differentiating.
**Cons:**
- Does not solve the underlying mismatch: the style suffix is a static string
that gives no per-article context to the diffusion model. The rewriter scene
still has no slot to insert topic-specific imagery.
- Comic gags require the LLM to invent a joke per peptide, which is inconsistent
in tone.
- Comic aesthetic conflicts with the editorial trust direction for health content.
### Option B — Pivot to text-free editorial scientific illustration + template substitution (chosen)
Change the rewriter system prompt so the LLM emits a concise 1-2 sentence
topic/mechanism summary as the SCENE — one concrete centered focal subject, no
text, no people-as-gag, no logos. Replace the style suffix with an editable
template (`prautoblogger_image_style_template`) that contains a single
`{{ topic_summary }}` token filled per-article by
`PRAutoBlogger_Image_Template_Filler`. The CAPTION line from the scene/caption
split continues to flow to the HTML-caption-below-image path unchanged.
**Pros:**
- Diffusion model is never asked to render text; garbled-text failures are
eliminated.
- The editorial scientific illustration style is consistent with the site's
authority positioning.
- Template substitution gives admins a single editable field to control the
entire visual style without touching the per-article rewriter logic.
- Degradation is explicit: missing token → append + warning; empty summary →
style-only prompt; a blank prompt is never sent to the provider.
- Provider, model, and cost are unchanged.
**Cons:**
- Breaks the comic gag aesthetic for any site running PRAutoBlogger in a
context where that was intentional (low risk for this deployment).
- The old `prautoblogger_image_style_suffix` option must be migrated
(handled via one-time migration `prautoblogger_migrated_style_template_v0160`
and mirroring to `_deprecated` for one cycle).
### Option C — Generate text overlays in PHP (deterministic composer only)
Skip the style change; solve legibility by compositing text onto images in PHP
after the diffusion model runs.
**Pros:**
- Diffusion model input is unchanged (comic style preserved).
- Composited text is always readable regardless of model.
**Cons:**
- Compositing a comic-style gag is much harder than compositing a caption band
(arbitrary layout vs. a fixed panel at the bottom).
- Does not address the editorial direction mismatch.
- The deterministic PHP composer (ARCHITECTURE.md §#21) was already planned as
a follow-on commit specifically for OG/social variants — pursuing it as the
sole fix conflates two separate problems.
## Decision
**Option B** — pivot to text-free editorial scientific illustration with
caption-as-HTML and template substitution.
This was shipped as v0.16.0 (2026-05-29, PR #146, commit d00e746). The changes
are entirely in the prompt layer:
- `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` now asks for a concise
topic/mechanism SCENE and keeps the CAPTION line for the HTML path.
- `PRAutoBlogger_Image_Template_Filler::fill()` replaces the old
`trim($scene . ' ' . $style_suffix)` concat at all three entry points
(`build_article_prompt`, `build_source_prompt`, `build_fallback_prompt`).
- New admin setting `prautoblogger_image_style_template` with
single-token validation replaces the Style Suffix field.
- One-time migration preserves the old suffix value for one cycle.
The deterministic PHP composer for OG/social branding (ARCHITECTURE.md §#21)
was deliberately deferred to a follow-on commit (v0.17.0, #152) so that the
prompt-only change could ship and be verified first.
## Consequences
**Accepted benefits:**
- Garbled-text image failures are eliminated on the default FLUX.1 schnell model.
- The editorial scientific illustration style is consistent with the site's
health-content positioning and sustainable across future model changes.
- The template + token pattern makes style updates admin-configurable without
code changes.
**Accepted costs:**
- The comic aesthetic is gone and cannot be restored by toggling a setting;
switching back requires a prompt and template change.
- The `prautoblogger_image_style_suffix` option is deprecated; the migration
runs once on upgrade and the old option is not deleted until a future cycle.
**Follow-up created:**
- ARCHITECTURE.md §#21 (v0.17.0): deterministic PHP image composer to composite
branded OG and square card variants — necessary because the social/OG image
travels without the HTML caption and therefore needs text burned in by PHP,
not by the diffusion model.

26
docs/adr/README.md Normal file
View File

@@ -0,0 +1,26 @@
# PRAutoBlogger — App-Local ADR Log
This folder is the per-repo decision log for the **PRAutoBlogger** plugin.
It records architectural choices that affect this codebase alone.
**Cross-cutting decisions** (spanning multiple plugins, shared infra, or
CI/deploy standards) live centrally in
`Peptide Repo CTO/docs/engineering/decisions/`.
For the routing rule, required sections, and status lifecycle, read the
canonical process document:
`Peptide Repo CTO/docs/engineering/ADR-PROCESS.md`
The canonical template for new ADRs is:
`Peptide Repo CTO/docs/engineering/adr-template.md`
## Numbering
ADRs in this folder use a per-repo zero-padded sequence starting at `0001`.
A new ADR always gets the next unused number; numbers are never reused.
## Index
| Number | Title | Status | Date |
|--------|-------|--------|------|
| [0001](0001-editorial-illustration-image-style.md) | Editorial illustration image style — pivot from comic to text-free illustration with caption-as-HTML | Accepted | 2026-05-29 |

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();
}