Back to feed

sledtools/pika branch #58

pika-git-runtime-2

Extract forge runtime from web

Target branch: master

Merge Commit: 0d2343896f0f1f95dedc4ec4b5e2b6179a690c7d

branch: merged tutorial: ready ci: success
Open CI Details

Continuous Integration

CI: success

Compact status on the review page, with full logs on the CI page.

Open CI Details

Latest run #75 success

2 passed

head 07c99e58c4be30fa6ff117b7b74f3d7e6567771a · queued 2026-03-25 20:13:46 · 2 lane(s)

queued 8s · ran 23s

check-notifications · success check-agent-contracts · success

Summary

This branch extracts the forge runtime subsystem from the monolithic web module (web.rs) into a dedicated forge_runtime.rs module in the pika-news crate. Previously, all background scheduling logic—polling, CI pass orchestration, mirror synchronization, health tracking, and wake/notify signaling—lived inline within web.rs alongside HTTP handler code. The refactor moves ~780 lines of runtime orchestration into ForgeRuntime, a self-contained struct with Arc-based shared state, a Notify-driven wake system with typed wake reasons, and clean public methods (wake_ci, wake_webhook, request_mirror, start_background, run_manual_mirror_pass). The web layer is simplified to hold an Arc<ForgeRuntime> and delegate all scheduling concerns through its public API, removing six fields from AppState and eliminating all direct atomic/mutex manipulation from handler code.

Tutorial Steps

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:

MethodReplaces
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:

  1. Scheduled — called from the background loop, checks mirror_requested flag to decide between forced and background mirror passes
  2. 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.

Diff