How the no-mistakes CI Monitor Handles Idle Timeouts and Re-Arming

The no-mistakes CI monitor tracks idle timeouts relative to a re-armable anchor that resets to the current time whenever the upstream default branch advances, preventing false timeouts on active PRs while ensuring stale requests eventually terminate.

The CI monitor is implemented by the CIStep type in internal/pipeline/steps/ci.go and serves as the core mechanism for watching pull request CI status without letting jobs run indefinitely. Understanding how it balances idle timeout enforcement with re-arming logic is essential for configuring reliable CI pipelines in the no-mistakes repository.

Idle Timeout Configuration and Semantics

The monitor evaluates three termination conditions during each polling cycle: the PR is merged or closed, the monitor receives a cancellation signal, or the configured idle timeout elapses. The timeout value originates from config.CITimeout and supports three distinct behaviors based on its numeric value:

  • Negative values (< 0): Disable the timeout entirely, allowing the monitor to run indefinitely
  • Zero: Use the system default defined in config.DefaultCITimeout
  • Positive values: Set a finite idle timeout duration

This logic appears around lines 101-108 of internal/pipeline/steps/ci.go, where the step initializes its timeout boundaries based on the global configuration.

Timeout Measurement with Re-Arming Anchors

Rather than measuring against a fixed start time, the monitor employs a dual-timestamp system to enable intelligent re-arming. The started field records when the CI step begins and remains fixed for the entire run, while timeoutAnchor serves as the mutable reference point for timeout calculations.

The idle timeout is computed as now() - timeoutAnchor. Initially, timeoutAnchor equals started (lines 125-130), meaning the timeout begins counting from step initiation. However, unlike a static deadline, timeoutAnchor advances forward when specific external conditions change, effectively re-arming the timeout mechanism.

Re-Arming Logic on Base Branch Advancement

The monitor re-arms—resetting the timeout countdown—whenever the upstream default branch demonstrates forward progress. After each poll, the step resolves the current tip SHA of the base branch (baseBranchTip) and compares it against the previously observed tip (lastBaseTip). If the values differ, indicating the base branch has advanced, the monitor executes the re-arming block between lines 156-176:

// Conceptual flow derived from internal/pipeline/steps/ci.go
if currentTip != lastBaseTip {
    timeoutAnchor = now()  // Re-arm: reset the timeout window
    lastBaseTip = currentTip
}

This design ensures that PRs against actively developing base branches remain monitored without timing out, while still protecting against PRs that stagnate without CI activity. The resolution operation respects a defaultBaseBranchTipResolveWindow of 30 seconds, trimmed automatically if the remaining timeout is shorter.

Polling Loop and Timeout Enforcement

Each iteration of the monitoring loop follows a strict sequence. First, the step checks whether now() - timeoutAnchor exceeds the configured timeout (lines 152-154). If exceeded, timeoutOutcome() constructs the terminal state—whether due to merge, closure, CI failure, or pure timeout.

The polling cycle proceeds only if the timeout has not elapsed, fetching updated check statuses and PR states before evaluating the re-arming conditions. This creates a deterministic flow where timeout checks always precede state updates, preventing race conditions between re-arming and termination.

Grace Period for Empty CI Checks

Before declaring a timeout on PRs with no reported checks, the monitor enforces a checksGracePeriod (default 60 seconds) defined around lines 141-149. This buffer accommodates CI providers that register checks asynchronously, preventing the monitor from terminating valid PRs simply because the CI system hasn't reported status yet.

Code Examples

Configuring a CI Step with Custom Timeout Behavior

When constructing a CIStep manually or in tests, you interact with the timeout configuration through the struct fields and configuration objects:

ciStep := &steps.CIStep{
    // The timeout is typically injected via config.CITimeout
    // Negative value: unlimited timeout
    // Zero: uses config.DefaultCITimeout  
    // Positive: specific duration
    
    // Custom base branch resolver for re-arming detection
    baseBranchTip: func(ctx context.Context) (string, bool) {
        tip, err := git.GetRemoteTip(ctx, repoURL, "origin/main")
        return tip, err == nil
    },
}

Testing Re-Arming Behavior

The following test pattern verifies that timeoutAnchor advances when the base branch tip changes:

func TestCIMonitorRearmsOnBaseBranchAdvance(t *testing.T) {
    step := &steps.CIStep{
        now: func() time.Time { return time.Unix(0, 0) },
    }

    // Mock advancing base branch tip
    call := 0
    step.baseBranchTip = func(ctx context.Context) (string, bool) {
        if call == 0 {
            call++
            return "sha-original", true
        }
        return "sha-advanced", true  // Triggers re-arm
    }

    // Execute step logic...
    // Verify that timeoutAnchor moved forward after second poll
}

Summary

  • File location: Core logic resides in internal/pipeline/steps/ci.go within the CIStep type
  • Timeout values: Negative disables timeouts, zero uses config.DefaultCITimeout, positive sets finite limits
  • Re-arming trigger: Advances in the upstream default branch tip reset timeoutAnchor to now(), extending the monitoring window
  • Dual timestamps: started remains fixed while timeoutAnchor moves to enable re-arming without losing step history
  • Protection mechanisms: 30-second resolution window for tip detection and 60-second grace period for empty CI checks prevent premature termination

Frequently Asked Questions

What triggers a re-arm in the no-mistakes CI monitor?

A re-arm occurs when the monitor detects that the upstream default branch tip SHA has changed since the last poll. Specifically, when baseBranchTip returns a value different from lastBaseTip, the step updates timeoutAnchor to the current time (lines 156-176 in internal/pipeline/steps/ci.go), effectively resetting the idle timeout countdown.

How does the CI monitor handle negative or zero timeout configurations?

When config.CITimeout is negative, the monitor runs without any idle timeout (unlimited duration). When set to zero, the monitor falls back to config.DefaultCITimeout. Only positive values create a finite timeout window measured against the re-armable anchor.

What is the purpose of the checks grace period?

The checksGracePeriod (default 60 seconds) prevents the monitor from timing out PRs that have no CI checks reported yet. This accommodates latency in CI provider APIs, ensuring the monitor doesn't terminate legitimate PRs simply because the CI system hasn't registered status updates.

How does the monitor differentiate between a stale PR and an active base branch?

The monitor uses timeoutAnchor rather than a fixed start time for timeout calculations. If the base branch advances (indicating ongoing development), the re-arming mechanism pushes timeoutAnchor forward, preventing timeout. If the base branch remains static and no CI activity occurs, timeoutAnchor stays fixed and the timeout eventually elapses, identifying the PR as stale.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →