Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/src/content/docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ The rest of this page covers only the cross-cutting rules that involve both file
## Precedence

- Repo config overrides global config field by field: repo `agent` replaces the global `agent` (including a full ordered fallback list), while `auto_fix`, `ci`, `commit`, `intent`, and the repository-scoped `test.evidence` fields overlay individual fields and fall through to the global default for anything unset (`intent.disabled_readers` adds to the globally disabled readers instead of replacing them). Local evidence location and retention are machine-wide and remain global-only; the [Global Config Reference](/no-mistakes/reference/global-config/#testevidence) owns the exact boundary.
- `agent_path_override`, `agent_config`, `agent_args_override`, `acpx_path`, `acp_registry_overrides`, `ci_timeout`, `daemon_connect_timeout`, `branch_sync_remote_timeout`, `step_quiet_warning`, `agent_timeout`, `review_agent_timeout`, `test_agent_timeout`, `log_level`, and `session_reuse` are global-only fields.
- `agent_path_override`, `agent_config`, `agent_args_override`, `acpx_path`, `acp_registry_overrides`, `ci_timeout`, `daemon_connect_timeout`, `branch_sync_remote_timeout`, `gate_reconcile_interval`, `gate_reconcile_timeout`, `step_quiet_warning`, `agent_timeout`, `review_agent_timeout`, `test_agent_timeout`, `log_level`, and `session_reuse` are global-only fields.
- `commands`, `ignore_patterns`, `document.instructions`, `review.path_instructions`, `allow_repo_commands`, and `disable_project_settings` are repo-only fields. By default, `commands` and `agent` are read from the trusted default branch; a trusted `allow_repo_commands: true` opt-in instead honors their pushed-branch values. The other gate-control fields, including `review.path_instructions` and the repo `ci` overlay, always come from the trusted default branch. See the [Repo Config Reference](/no-mistakes/reference/repo-config/) security note.
- no-mistakes reloads global config while setting up each run, so edits made before starting a run apply to it. For repeatable profiles (for example fast versus deep Codex settings), use separately initialized `NM_HOME` roots; `NM_HOME` moves all no-mistakes state, not just config.

Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/guides/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,8 @@ Symptom: pipeline completes but the PR step shows `skipped`.

Check the [Provider Integration](/no-mistakes/guides/provider-integration/) requirements. Most common causes:

- `gh`, `glab`, `forgejo-axi`, or `tea` not installed
- The provider CLI reports that it is not authenticated
- `gh`, `glab`, `forgejo-axi`, or `tea` not installed (or, for GitHub, not on `PATH`)
- The provider CLI reports that it is not authenticated; on GitHub, a timed-out or interrupted `gh auth status` is reported separately from auth failure
- Bitbucket env vars not set in the daemon's environment
- Upstream is not one of the hosts listed in Provider Integration
- Self-hosted GitHub Enterprise on a hostname that is not `github.com` isn't detected because `gh` isn't configured for the host; run `gh auth login --hostname your-ghe.example.com` so detection finds it. Once detection succeeds, the availability check is host-scoped (`gh auth status --hostname your-ghe.example.com`), so a stale token on `github.com` or any other configured gh host can no longer falsely mark the GHE repo as unauthenticated.
Expand Down
26 changes: 26 additions & 0 deletions docs/src/content/docs/reference/global-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ daemon_connect_timeout: "3s"

branch_sync_remote_timeout: "60s"

gate_reconcile_interval: "2m"

gate_reconcile_timeout: "30s"

log_level: info

session_reuse: true
Expand Down Expand Up @@ -461,6 +465,28 @@ Accepts any positive Go `time.ParseDuration` string.

Raise this if your environment's Git credential helper (for example `gh auth git-credential`, invoked by Git as a child process against a private remote) legitimately takes longer than the default - this is a real, non-outage latency characteristic that has been observed taking 19-22s in some environments, not a hang. It is a machine/environment setting, not a per-repository one: it is read only from global config and has no matching field in a repository's `.no-mistakes.yaml`, so a pushed branch cannot widen or narrow how long the local service waits before failing closed. It never changes the fail-closed guarantee itself - a timeout or unknown remote state still always refuses synchronization without changing files or refs, whatever this value is set to.

### gate_reconcile_interval

How often the daemon rechecks a parked approval gate while waiting for user approval. Today this applies to the CI step's parked gate, which re-probes provider availability (including `gh auth status`) and clears the gate when the PR was merged or closed.

| | |
| ------- | ---------------------- |
| Type | `string` (Go duration) |
| Default | `2m` |

Accepts any positive Go `time.ParseDuration` string. Global-only: there is no matching field in a repository's `.no-mistakes.yaml`.

### gate_reconcile_timeout

Maximum wall time one parked approval-gate reconcile attempt may spend before the attempt stops, the gate stays parked, and the next interval wait begins. Covers host probes such as `gh auth status` that can hang without returning.

| | |
| ------- | ---------------------- |
| Type | `string` (Go duration) |
| Default | `30s` |

Accepts any positive Go `time.ParseDuration` string. Global-only: there is no matching field in a repository's `.no-mistakes.yaml`. Raise this if a legitimate credential helper or network path routinely needs longer than the default for auth probes during reconcile. Timeout and interruption are reported distinctly from authentication failure; that distinction does not require raising this value.

### log_level

Daemon log verbosity.
Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/reference/pipeline-steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,8 @@ Creates or updates a pull request.
**Skipped when:**
- The branch is the [PR base branch](/no-mistakes/reference/repo-config/#prbase_branch) (the repository's forge default branch, or the trusted `pr.base_branch` when configured)
- The upstream host is not GitHub, GitLab, Forgejo, Bitbucket Cloud (`bitbucket.org`), Azure DevOps (`dev.azure.com` / `*.visualstudio.com`), or Gitea
- The provider CLI (`gh`, `glab`, `forgejo-axi`, or `tea`) is not installed for GitHub, GitLab, Forgejo, or Gitea
- The provider CLI is not authenticated for GitHub, GitLab, Forgejo, or Gitea
- The provider CLI (`gh`, `glab`, `forgejo-axi`, or `tea`) is not installed for GitHub, GitLab, Forgejo, or Gitea (GitHub also skips when `gh` is missing from `PATH`)
- The provider CLI is not authenticated for GitHub, GitLab, Forgejo, or Gitea (GitHub reports a timed-out or interrupted `gh auth status` separately from auth failure; either still skips)
- Bitbucket Cloud credentials are missing (`NO_MISTAKES_BITBUCKET_EMAIL` or `NO_MISTAKES_BITBUCKET_API_TOKEN`)
- The `az` CLI with the `azure-devops` extension is not installed or not authenticated for Azure DevOps
- A legacy or manually edited non-GitHub repo record has `fork_url` set, because fork MR/PR routing is currently GitHub-only
Expand Down
74 changes: 58 additions & 16 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ const (
DefaultDaemonConnectTimeout = 3 * time.Second
// DefaultBranchSyncRemoteTimeout bounds each remote Git operation (ls-remote, fetch) in internal/branchsync. Global-config-only; a pushed branch cannot change it. Timeout still fails closed.
DefaultBranchSyncRemoteTimeout = 60 * time.Second
// DefaultGateReconcileInterval is how often a parked approval gate is
// rechecked. Global-config-only; a pushed branch cannot change it.
DefaultGateReconcileInterval = 2 * time.Minute
// DefaultGateReconcileTimeout is the deadline for one approval-gate
// reconciliation check (including host.Available / gh auth status).
// Global-config-only; a pushed branch cannot change it.
DefaultGateReconcileTimeout = 30 * time.Second
// CITimeoutUnlimited is the sentinel meaning "monitor until the PR is
// merged, closed, or the run is aborted - never self-terminate".
// Any non-positive ci_timeout, or the keywords "unlimited", "none",
Expand Down Expand Up @@ -133,7 +140,13 @@ type GlobalConfig struct {
TestAgentTimeout time.Duration `yaml:"-"`
DaemonConnectTimeout time.Duration `yaml:"-"`
BranchSyncRemoteTimeout time.Duration `yaml:"-"`
LogLevel string `yaml:"log_level"`
// GateReconcileInterval / GateReconcileTimeout bound how often and how
// long a parked approval gate is rechecked. They are machine-local
// operator knobs (slow hosts, contended gh auth) and global-only so a
// pushed branch cannot widen or shrink the reconcile budget.
GateReconcileInterval time.Duration `yaml:"-"`
GateReconcileTimeout time.Duration `yaml:"-"`
LogLevel string `yaml:"log_level"`
// SessionReuse controls per-run agent session reuse in the review loop:
// one durable fixer session across review-fix turns. Review turns always
// run session-free so the rereview never resumes the session whose
Expand Down Expand Up @@ -170,6 +183,8 @@ type globalConfigRaw struct {
CITimeout string `yaml:"ci_timeout"`
DaemonConnectTimeout string `yaml:"daemon_connect_timeout"`
BranchSyncRemoteTimeout string `yaml:"branch_sync_remote_timeout"`
GateReconcileInterval string `yaml:"gate_reconcile_interval"`
GateReconcileTimeout string `yaml:"gate_reconcile_timeout"`
BabysitTimeout string `yaml:"babysit_timeout"`
StepQuietWarning string `yaml:"step_quiet_warning"`
AgentTimeout string `yaml:"agent_timeout"`
Expand Down Expand Up @@ -496,6 +511,8 @@ type Config struct {
AgentTimeout time.Duration
ReviewAgentTimeout time.Duration
TestAgentTimeout time.Duration
GateReconcileInterval time.Duration
GateReconcileTimeout time.Duration
LogLevel string
SessionReuse bool
Eval Eval
Expand Down Expand Up @@ -779,6 +796,13 @@ daemon_connect_timeout: "3s"
# (ls-remote or fetch) before treating the target as offline. Global-only.
branch_sync_remote_timeout: "60s"

# How often a parked approval gate is rechecked, and the deadline for each
# check (including gh auth status). Raise gate_reconcile_timeout on a slow or
# contended machine so a transient auth-status delay is not cancelled mid-call.
# Global-only.
gate_reconcile_interval: "2m"
gate_reconcile_timeout: "30s"

# Reuse one durable fixer session per run across review-fix turns. Review turns
# always run session-free so a rereview never resumes the session that prescribed
# its fixes. Supported for claude, codex, grok, and pi; other agents run cold.
Expand Down Expand Up @@ -1566,6 +1590,8 @@ func DefaultGlobalConfig() *GlobalConfig {
TestAgentTimeout: DefaultTestAgentTimeout,
DaemonConnectTimeout: DefaultDaemonConnectTimeout,
BranchSyncRemoteTimeout: DefaultBranchSyncRemoteTimeout,
GateReconcileInterval: DefaultGateReconcileInterval,
GateReconcileTimeout: DefaultGateReconcileTimeout,
LogLevel: "info",
SessionReuse: true,
Eval: evalDefaults(),
Expand Down Expand Up @@ -1829,6 +1855,20 @@ func LoadGlobalFromBytes(data []byte) (*GlobalConfig, error) {
}
cfg.BranchSyncRemoteTimeout = d
}
if raw.GateReconcileInterval != "" {
d, err := parsePositiveDuration("gate_reconcile_interval", raw.GateReconcileInterval)
if err != nil {
return nil, err
}
cfg.GateReconcileInterval = d
}
if raw.GateReconcileTimeout != "" {
d, err := parsePositiveDuration("gate_reconcile_timeout", raw.GateReconcileTimeout)
if err != nil {
return nil, err
}
cfg.GateReconcileTimeout = d
}
if raw.LogLevel != "" {
cfg.LogLevel = raw.LogLevel
}
Expand Down Expand Up @@ -2481,21 +2521,23 @@ func Merge(global *GlobalConfig, repo *RepoConfig) *Config {
}

cfg := &Config{
Agent: global.Agent,
Agents: copyAgents(global.Agents),
ACPXPath: global.ACPXPath,
ForgejoAXIPath: global.ForgejoAXIPath,
ACPRegistryOverrides: global.ACPRegistryOverrides,
AgentPathOverride: global.AgentPathOverride,
AgentArgsOverride: global.AgentArgsOverride,
AgentConfig: global.AgentConfig,
CITimeout: global.CITimeout,
StepQuietWarning: global.StepQuietWarning,
AgentTimeout: global.AgentTimeout,
ReviewAgentTimeout: global.ReviewAgentTimeout,
TestAgentTimeout: global.TestAgentTimeout,
LogLevel: global.LogLevel,
SessionReuse: global.SessionReuse,
Agent: global.Agent,
Agents: copyAgents(global.Agents),
ACPXPath: global.ACPXPath,
ForgejoAXIPath: global.ForgejoAXIPath,
ACPRegistryOverrides: global.ACPRegistryOverrides,
AgentPathOverride: global.AgentPathOverride,
AgentArgsOverride: global.AgentArgsOverride,
AgentConfig: global.AgentConfig,
CITimeout: global.CITimeout,
StepQuietWarning: global.StepQuietWarning,
AgentTimeout: global.AgentTimeout,
ReviewAgentTimeout: global.ReviewAgentTimeout,
TestAgentTimeout: global.TestAgentTimeout,
GateReconcileInterval: global.GateReconcileInterval,
GateReconcileTimeout: global.GateReconcileTimeout,
LogLevel: global.LogLevel,
SessionReuse: global.SessionReuse,
// Eval is global-only by design (see GlobalConfig.Eval), so it is
// copied straight through with no repository override step.
Eval: global.Eval,
Expand Down
93 changes: 93 additions & 0 deletions internal/config/config_global_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ func TestLoadGlobal_Defaults(t *testing.T) {
if cfg.BranchSyncRemoteTimeout != DefaultBranchSyncRemoteTimeout {
t.Errorf("branch_sync_remote_timeout = %v, want %v", cfg.BranchSyncRemoteTimeout, DefaultBranchSyncRemoteTimeout)
}
if cfg.GateReconcileInterval != DefaultGateReconcileInterval {
t.Errorf("gate_reconcile_interval = %v, want %v", cfg.GateReconcileInterval, DefaultGateReconcileInterval)
}
if cfg.GateReconcileTimeout != DefaultGateReconcileTimeout {
t.Errorf("gate_reconcile_timeout = %v, want %v", cfg.GateReconcileTimeout, DefaultGateReconcileTimeout)
}
if cfg.LogLevel != "info" {
t.Errorf("log_level = %q, want %q", cfg.LogLevel, "info")
}
Expand Down Expand Up @@ -117,6 +123,12 @@ func TestEnsureDefaultGlobalConfig_CreatedConfigIsLoadable(t *testing.T) {
if cfg.BranchSyncRemoteTimeout != DefaultBranchSyncRemoteTimeout {
t.Errorf("branch_sync_remote_timeout = %v, want %v", cfg.BranchSyncRemoteTimeout, DefaultBranchSyncRemoteTimeout)
}
if cfg.GateReconcileInterval != DefaultGateReconcileInterval {
t.Errorf("gate_reconcile_interval = %v, want %v", cfg.GateReconcileInterval, DefaultGateReconcileInterval)
}
if cfg.GateReconcileTimeout != DefaultGateReconcileTimeout {
t.Errorf("gate_reconcile_timeout = %v, want %v", cfg.GateReconcileTimeout, DefaultGateReconcileTimeout)
}
if cfg.LogLevel != "info" {
t.Errorf("log_level = %q, want %q", cfg.LogLevel, "info")
}
Expand Down Expand Up @@ -209,6 +221,73 @@ func TestLoadGlobal_TestAgentTimeout(t *testing.T) {
}
}

func TestLoadGlobal_GateReconcileTimings(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte("gate_reconcile_interval: 45s\ngate_reconcile_timeout: 90s\n"), 0o644); err != nil {
t.Fatal(err)
}

cfg, err := LoadGlobal(path)
if err != nil {
t.Fatalf("LoadGlobal: %v", err)
}
if cfg.GateReconcileInterval != 45*time.Second {
t.Fatalf("gate_reconcile_interval = %v, want 45s", cfg.GateReconcileInterval)
}
if cfg.GateReconcileTimeout != 90*time.Second {
t.Fatalf("gate_reconcile_timeout = %v, want 90s", cfg.GateReconcileTimeout)
}
}

// TestLoadGlobal_GateReconcileTimings_OperatorSlowAuthBudget is the documented
// operator path: raise interval/timeout in global config.yaml so slow gh auth
// probes fit the parked-gate reconcile budget (defaults remain 2m / 30s).
func TestLoadGlobal_GateReconcileTimings_OperatorSlowAuthBudget(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
body := "gate_reconcile_interval: \"5m\"\ngate_reconcile_timeout: \"2m\"\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}

cfg, err := LoadGlobal(path)
if err != nil {
t.Fatalf("LoadGlobal: %v", err)
}
if cfg.GateReconcileInterval != 5*time.Minute {
t.Fatalf("gate_reconcile_interval = %v, want 5m", cfg.GateReconcileInterval)
}
if cfg.GateReconcileTimeout != 2*time.Minute {
t.Fatalf("gate_reconcile_timeout = %v, want 2m", cfg.GateReconcileTimeout)
}
merged := Merge(cfg, &RepoConfig{})
if merged.GateReconcileInterval != 5*time.Minute || merged.GateReconcileTimeout != 2*time.Minute {
t.Fatalf("Merge did not preserve global timings: interval=%v timeout=%v",
merged.GateReconcileInterval, merged.GateReconcileTimeout)
}
t.Logf("operator config.yaml loaded: gate_reconcile_interval=%v gate_reconcile_timeout=%v",
merged.GateReconcileInterval, merged.GateReconcileTimeout)
}

func TestLoadGlobal_InvalidGateReconcileTimings(t *testing.T) {
dir := t.TempDir()
for _, body := range []string{
`gate_reconcile_timeout: "not-a-duration"`,
`gate_reconcile_timeout: "0s"`,
`gate_reconcile_timeout: "-1s"`,
`gate_reconcile_interval: "0s"`,
} {
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(body+"\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadGlobal(path); err == nil {
t.Fatalf("LoadGlobal(%q) error = nil, want error", body)
}
}
}

func TestLoadGlobal_InvalidAgentTimeout(t *testing.T) {
cases := []string{
`agent_timeout: "not-a-duration"`,
Expand Down Expand Up @@ -604,6 +683,20 @@ func TestDefaultConfigYAML_MatchesGoDefaults(t *testing.T) {
if d != DefaultBranchSyncRemoteTimeout {
t.Errorf("YAML branch_sync_remote_timeout = %v, Go default = %v", d, DefaultBranchSyncRemoteTimeout)
}
d, err = time.ParseDuration(raw.GateReconcileInterval)
if err != nil {
t.Fatalf("YAML gate_reconcile_interval %q is not a valid duration: %v", raw.GateReconcileInterval, err)
}
if d != DefaultGateReconcileInterval {
t.Errorf("YAML gate_reconcile_interval = %v, Go default = %v", d, DefaultGateReconcileInterval)
}
d, err = time.ParseDuration(raw.GateReconcileTimeout)
if err != nil {
t.Fatalf("YAML gate_reconcile_timeout %q is not a valid duration: %v", raw.GateReconcileTimeout, err)
}
if d != DefaultGateReconcileTimeout {
t.Errorf("YAML gate_reconcile_timeout = %v, Go default = %v", d, DefaultGateReconcileTimeout)
}
d, err = time.ParseDuration(raw.ReviewAgentTimeout)
if err != nil {
t.Fatalf("YAML review_agent_timeout %q is not a valid duration: %v", raw.ReviewAgentTimeout, err)
Expand Down
22 changes: 22 additions & 0 deletions internal/config/config_repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,28 @@ func TestLoadRepo_TestAgentTimeoutIsNotARepoSetting(t *testing.T) {
}
}

// TestLoadRepo_GateReconcileTimingsAreNotRepoSettings proves
// gate_reconcile_timeout / gate_reconcile_interval are inert in
// .no-mistakes.yaml: RepoConfig has no matching fields, so a pushed branch
// cannot widen the approval-gate reconcile budget. They are global-only
// operator machine settings.
func TestLoadRepo_GateReconcileTimingsAreNotRepoSettings(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ".no-mistakes.yaml")
data := "gate_reconcile_timeout: \"999s\"\ngate_reconcile_interval: \"999s\"\n"
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
t.Fatal(err)
}

cfg, err := LoadRepo(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Agent != "" || cfg.Commands.Test != "" || cfg.Commands.Lint != "" || cfg.Commands.Format != "" {
t.Fatalf("unrelated repo config fields changed: %#v", cfg)
}
}

func TestLoadRepo_AgentAcceptsList(t *testing.T) {
dir := t.TempDir()
data := `agent: [codex, claude]
Expand Down
Loading
Loading