Create the forge_runtime module with core types and state
Intent: Establish a new module that owns all forge runtime orchestration state—atomic flags for mirror and CI coordination, a Notify-based wake channel with typed wake reasons, and the full health tracking subsystem (ForgeHealthState, ForgeSubsystemTracker, and snapshot types).
Affected files: crates/pika-news/src/forge_runtime.rs
Evidence
@@ -0,0 +1,782 @@
pub(crate) struct ForgeRuntime {
enabled: bool,
wake_notify: Arc<Notify>,
last_wake_reason: Arc<Mutex<Option<WakeReason>>>,
mirror_requested: Arc<AtomicBool>,
mirror_running: Arc<AtomicBool>,
ci_running: Arc<AtomicBool>,
forge_health: Arc<Mutex<ForgeHealthState>>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum WakeReason {
Ci,
CiFollowUp,
ManualMirrorComplete,
MirrorRequested,
Webhook,
}
The new forge_runtime.rs file defines ForgeRuntime as the central orchestration struct. It consolidates six pieces of shared state that were previously scattered across AppState fields:
wake_notify: Arc<Notify> — replaces the bare poll_notify that web handlers poked directly
last_wake_reason: Arc<Mutex<Option<WakeReason>>> — new typed enum that distinguishes why the runtime was woken (CI, webhook, mirror request, etc.), enabling selective logging
mirror_requested: Arc<AtomicBool> and mirror_running: Arc<AtomicBool> — moved from AppState
ci_running: Arc<AtomicBool> — previously a local variable in the spawned background task
forge_health: Arc<Mutex<ForgeHealthState>> — moved from AppState
The WakeReason enum is a key improvement over the old design. Previously every notify_one() call was indistinguishable, and the log always printed "poll: woken by webhook" regardless of the actual trigger. Now each wake site tags its reason, and log_label controls which reasons are worth logging:
impl WakeReason {
fn log_label(self) -> Option<&'static str> {
match self {
Self::Webhook => Some("webhook"),
Self::Ci | Self::CiFollowUp | Self::ManualMirrorComplete
| Self::MirrorRequested => None,
}
}
}
All health-related types (ForgeHealthIssue, ForgeSubsystemStatus, ForgeMirrorHealthStatus, ForgeHealthSnapshot, ForgeHealthState, ForgeSubsystemTracker) are moved verbatim but with visibility widened to pub(crate) so the web layer can use the snapshot types in its templates and JSON responses.
Implement the ForgeRuntime public API
Intent: Provide a clean interface for the web layer to interact with the runtime—constructing it, waking subsystems, requesting mirrors, and querying health—without exposing any internal atomics or mutexes.
Affected files: crates/pika-news/src/forge_runtime.rs
Evidence
pub(crate) fn new(config: &Config, webhook_secret: Option<&str>) -> Self {
pub(crate) fn wake_ci(&self) {
self.notify_with_reason(WakeReason::Ci);
}
pub(crate) fn wake_webhook(&self) {
self.notify_with_reason(WakeReason::Webhook);
}
pub(crate) fn request_mirror(&self) {
self.mirror_requested.store(true, Ordering::Release);
self.notify_with_reason(WakeReason::MirrorRequested);
}
pub(crate) fn health_snapshot(
&self,
config: &Config,
mirror_status: Option<&MirrorStatusRecord>,
) -> ForgeHealthSnapshot {
The public API surface is deliberately small and intention-revealing:
| Method | Replaces |
ForgeRuntime::new(config, webhook_secret) | Manual construction of 6 Arc fields + inline replace_issues call |
wake_ci() | state.poll_notify.notify_one() (ambiguous) |
wake_webhook() | state.poll_notify.notify_one() (ambiguous) |
request_mirror() | state.mirror_requested.store(true, ...) + state.poll_notify.notify_one() (two-step) |
issues() | state.forge_health.lock().unwrap().issues.clone() |
health_snapshot(config, mirror_status) | state.forge_health.lock().unwrap().snapshot(config, mirror_status) |
The request_mirror() method is a good example of encapsulation: it atomically sets the flag and wakes the loop in one call, whereas the old code required callers to remember both steps:
// Old (web.rs) - two separate operations the caller had to coordinate
state.mirror_requested.store(true, Ordering::Release);
state.poll_notify.notify_one();
// New - single call
state.forge_runtime.request_mirror();
blank(enabled) is also exposed for cases where a runtime is needed without running startup issue detection (e.g., tests).
Move the background event loop into ForgeRuntime::start_background
Intent: Relocate the ~80-line tokio::spawn background loop from web.rs into a method on ForgeRuntime, with a ForgeRuntimeContext carrying the dependencies it needs.
Affected files: crates/pika-news/src/forge_runtime.rs
Evidence
pub(crate) struct ForgeRuntimeContext {
pub(crate) store: Store,
pub(crate) config: Config,
pub(crate) max_prs: usize,
pub(crate) live_updates: CiLiveUpdates,
pub(crate) webhook_secret: Option<String>,
}
pub(crate) fn start_background(self: &Arc<Self>, context: ForgeRuntimeContext) {
runtime.handle_poll_result(poll_result);
runtime.handle_worker_result(worker_result);
runtime.handle_mirror_result(mirror_result);
ForgeRuntimeContext is a plain Clone struct that bundles everything the background loop needs from the application layer: the store, config, max PR limit, live-update broadcaster, and webhook secret. This avoids passing a full AppState (which includes the auth system, router state, etc.) into the runtime.
The start_background method takes self: &Arc<Self> and spawns the main loop. The loop body has the same structure as before—spawn_blocking for the synchronous poll/worker/mirror passes—but the result handling is factored into private methods:
handle_poll_result — logs, updates health tracker, and calls wake_ci() if new CI runs were queued
handle_worker_result — logs and updates health tracker
handle_mirror_result — logs non-success mirror outcomes
Each of these replaces 15-30 lines of inline match arms that were previously nested inside the background tokio::spawn closure in web.rs.
The sleep/wake select is also improved with wait_for_next_wake, which consumes and optionally logs the typed wake reason:
async fn wait_for_next_wake(&self, poll_interval: Duration) {
tokio::select! {
_ = tokio::time::sleep(poll_interval) => {}
_ = self.wake_notify.notified() => {
if let Some(label) = self.take_wake_reason()
.and_then(WakeReason::log_label) {
eprintln!("poll: woken by {label}");
}
}
}
}
Move CI pass orchestration into ForgeRuntime
Intent: Relocate the CI scheduling spawn, follow-up wake logic, and the ci_pass_needs_follow_up_wake predicate into the runtime module so CI coordination is self-contained.
Affected files: crates/pika-news/src/forge_runtime.rs
Evidence
fn maybe_start_background_ci_pass(self: &Arc<Self>, context: ForgeRuntimeContext) {
if should_wake_follow_up {
runtime.notify_with_reason(WakeReason::CiFollowUp);
}
pub(crate) fn ci_pass_needs_follow_up_wake(ci: &ci::CiPassResult) -> bool {
The maybe_start_background_ci_pass method was previously a free function in web.rs that took Arc<AppState>, Arc<Notify>, and Arc<AtomicBool> as separate parameters. Now it's a method on ForgeRuntime that uses its own ci_running atomic and wake_notify.
A subtle behavioral improvement: when the CI pass determines it needs a follow-up wake (because it claimed/completed work and more may be pending), the old code called notify.notify_one() with no way to distinguish this from a webhook wake. The new code uses WakeReason::CiFollowUp, which has log_label() -> None, so these frequent internal wakes don't spam the log.
The ci_pass_needs_follow_up_wake function is kept pub(crate) since it encodes domain logic that could be useful to tests or other modules.
Move mirror pass execution and manual mirror handling into ForgeRuntime
Intent: Consolidate both scheduled (background) and manual (API-triggered) mirror pass execution into ForgeRuntime methods, including the finish/wake coordination logic.
Affected files: crates/pika-news/src/forge_runtime.rs
Evidence
fn run_scheduled_mirror_pass(
&self,
store: &Store,
config: &Config,
) -> anyhow::Result<mirror::MirrorPassResult> {
pub(crate) async fn run_manual_mirror_pass(
&self,
store: Store,
config: Config,
) -> anyhow::Result<ManualMirrorPassStatus> {
pub(crate) enum ManualMirrorPassStatus {
AlreadyRunning,
Attempted(mirror::MirrorPassResult),
Unavailable,
}
fn finish_manual_mirror_pass(&self, attempted: bool) {
Mirror synchronization has two entry points:
- Scheduled — called from the background loop, checks
mirror_requested flag to decide between forced and background mirror passes
- Manual — called from the web API endpoint, uses
compare_exchange on mirror_running to prevent concurrent runs
Both were previously in web.rs with the scheduled pass as a free function and the manual pass inlined in a handler. Now they're methods on ForgeRuntime.
The ManualMirrorPassStatus enum is new and replaces what was previously ad-hoc result interpretation in the handler. It provides three clear outcomes:
AlreadyRunning — another mirror pass holds the lock
Attempted(result) — the pass ran and here's the result
Unavailable — the pass ran but didn't attempt work (e.g., mirror not configured)
finish_manual_mirror_pass handles the post-mirror coordination: releasing the mirror_running flag and, if the mirror was actually attempted, waking the background loop with ManualMirrorComplete so it can pick up any post-mirror work (like refreshing branch state).
Move health issue collection and startup diagnostics into forge_runtime
Intent: Relocate the forge startup issue collection logic (webhook secret checks, canonical repo validation, mirror remote verification) from web.rs into the runtime module where it's evaluated on each background iteration.
Affected files: crates/pika-news/src/forge_runtime.rs
Evidence
pub(crate) fn collect_forge_startup_issues(
config: &Config,
forge_repo: &ForgeRepoConfig,
webhook_secret: Option<&str>,
) -> Vec<ForgeHealthIssue> {
pub(crate) fn current_forge_runtime_issues(
config: &Config,
webhook_secret: Option<&str>,
) -> Vec<ForgeHealthIssue> {
pub(crate) fn build_mirror_health_status(
runtime: &mirror::MirrorRuntimeStatus,
status: Option<&MirrorStatusRecord>,
) -> ForgeMirrorHealthStatus {
Three functions that were private to web.rs are moved to forge_runtime.rs and made pub(crate):
collect_forge_startup_issues — checks webhook secret presence, canonical repo usability, mirror remote validity, and GitHub token availability. Produces ForgeHealthIssue items.
current_forge_runtime_issues — wraps collect_forge_startup_issues with config-level forge repo resolution.
build_mirror_health_status — synthesizes a ForgeMirrorHealthStatus from the mirror runtime status and stored mirror status record.
These are called on every background loop iteration (via current_forge_runtime_issues inside start_background) and at startup (via ForgeRuntime::new), making the runtime self-diagnosing. The helper summary functions (poller_summary, worker_summary, ci_summary) and the forge_issue constructor are also moved.
All of these are pure functions with no web dependencies, which is why they belong in the runtime module rather than the HTTP layer.
Register the forge_runtime module in main.rs
Intent: Add the new module declaration to the crate root so the compiler discovers forge_runtime.rs.
Affected files: crates/pika-news/src/main.rs
Evidence
@@ -6,6 +6,7 @@ mod ci_state;
mod cli;
mod config;
mod forge;
+mod forge_runtime;
A single line addition in main.rs registers the new module in alphabetical order among the existing module declarations. This is the only change needed in main.rs.
Simplify AppState and web.rs by delegating to ForgeRuntime
Intent: Remove all runtime orchestration state and logic from the web layer, replacing it with a single Arc<ForgeRuntime> field and calls to its public API.
Affected files: crates/pika-news/src/web.rs
Evidence
@@ -1,8 +1,7 @@
use std::collections::BTreeSet;
use std::convert::Infallible;
use std::env;
-use std::sync::atomic::{AtomicBool, Ordering};
-use std::sync::{Arc, Mutex};
+use std::sync::Arc;
-use tokio::sync::{broadcast::error::RecvError, Notify};
+use tokio::sync::broadcast::error::RecvError;
+use crate::forge_runtime::{ForgeRuntime, ForgeRuntimeContext, ManualMirrorPassStatus};
-use crate::poller;
-use crate::worker;
struct AppState {
store: Store,
config: Config,
auth: Arc<AuthState>,
live_updates: CiLiveUpdates,
webhook_secret: Option<String>,
forge_runtime: Arc<ForgeRuntime>,
}
state.forge_runtime.request_mirror();
state.forge_runtime.wake_ci();
state.forge_runtime.wake_webhook();
The web module undergoes a large net deletion. The key changes:
Removed from AppState (6 fields → 1):
max_prs: usize
poll_notify: Arc<Notify>
mirror_requested: Arc<AtomicBool>
mirror_running: Arc<AtomicBool>
forge_health: Arc<Mutex<ForgeHealthState>>
- (implicit)
background_ci_running: Arc<AtomicBool>
Added to AppState (1 field):
forge_runtime: Arc<ForgeRuntime>
Removed from web.rs (~480 lines):
- All type definitions:
ForgeHealthIssue, ForgeSubsystemStatus, ForgeMirrorHealthStatus, ForgeHealthSnapshot, ForgeSubsystemTracker, ForgeHealthState
- All free functions:
maybe_start_background_ci_pass, run_scheduled_mirror_pass, ci_pass_needs_follow_up_wake, now_string, forge_issue, poller_summary, worker_summary, ci_summary, build_mirror_health_status, collect_forge_startup_issues, current_forge_runtime_issues
- The entire inline
tokio::spawn background loop (~100 lines)
Removed imports: AtomicBool, Ordering, Mutex, Notify, SecondsFormat, MirrorStatusRecord (from branch_store), poller, worker
Handler changes — every handler that previously did multi-step atomic manipulation now makes a single method call:
// merge_handler, close_handler:
state.forge_runtime.request_mirror();
// rerun, fail, requeue, recover handlers + wake_ci_handler:
state.forge_runtime.wake_ci();
// webhook_handler:
state.forge_runtime.wake_webhook();
The branch_page_notices and nightly_page_notices functions previously locked the health mutex directly; they now call state.forge_runtime.health_snapshot(&state.config, None) which returns an owned snapshot, eliminating the lock().unwrap() pattern from template-rendering code paths.
Wire up ForgeRuntime construction and background startup in serve()
Intent: Replace the manual construction of shared state and the inline background spawn in the serve() function with ForgeRuntime::new() and start_background().
Affected files: crates/pika-news/src/web.rs
Evidence
let forge_runtime = Arc::new(ForgeRuntime::new(&config, webhook_secret.as_deref()));
let startup_issues = forge_runtime.issues();
forge_runtime.start_background(ForgeRuntimeContext {
store: state.store.clone(),
config: state.config.clone(),
max_prs,
live_updates: state.live_updates.clone(),
webhook_secret: state.webhook_secret.clone(),
});
The serve() function setup shrinks significantly:
Before (14 lines of setup + ~100 lines of inline spawn):
let poll_notify = Arc::new(Notify::new());
let mirror_requested = Arc::new(AtomicBool::new(false));
let mirror_running = Arc::new(AtomicBool::new(false));
let forge_health = Arc::new(Mutex::new(ForgeHealthState::new(forge_mode)));
// ... issue collection, health.replace_issues ...
// ... 100-line tokio::spawn background loop ...
After (8 lines):
let forge_runtime = Arc::new(ForgeRuntime::new(&config, webhook_secret.as_deref()));
// ... startup issue logging (using forge_runtime.issues()) ...
forge_runtime.start_background(ForgeRuntimeContext {
store: state.store.clone(),
config: state.config.clone(),
max_prs,
live_updates: state.live_updates.clone(),
webhook_secret: state.webhook_secret.clone(),
});
ForgeRuntime::new handles issue detection internally, so the explicit current_forge_runtime_issues + health.lock().replace_issues() dance is replaced by a single constructor call. The startup issue log loop still works by calling forge_runtime.issues() to retrieve the cached issues for display.
The ForgeRuntimeContext struct makes it explicit what the background loop depends on, and since it's Clone, the runtime can cheaply share it across loop iterations and spawned CI tasks.