From b226a0f5267d5656ebdf2a7783b90956b45d6b48 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sun, 26 Jul 2026 18:28:42 +0200 Subject: [PATCH 01/37] Choose which bots review which project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reviewers were a property of the process: one CRQ_COBOTS for every repository the fleet touches. A repo that only wants Codex, or one where Bugbot should gate, had no way to say so — and the answer to "which bots do you want here" was to edit a global env file and restart a daemon. crq reviewers now reports who runs there and what each one costs; crq reviewers set chooses them; crq reviewers clear goes back to the fleet default. Each reviewer reports its budget, which is the only property the queue cares about: account is serialized against the shared CodeRabbit allowance, none runs immediately and waits for nobody. The configuration lives in the shared state ref rather than a .crq.yaml the repository carries. The daemon has no checkout of the repositories it reviews, so an in-repo file would be invisible to it or cost a REST fetch per PR — and a daemon and an agent reading different configurations while writing one state ref is a class of bug worth not having. The override reaches the derived views, not just the stored list: after applying it, Reviewers, RequiredBots and FeedbackBots are rebuilt exactly as LoadConfig builds them, so crq cannot gate on or surface findings from a bot the repository excluded. "Chosen to be none" is kept distinct from "not chosen". The primary stays fleet-wide. Its markers and command are compiled into the dialect classifiers when the Service is built, so a per-repo primary means per-repo classifiers. --- README.md | 3 + cmd/crq/main.go | 117 ++++++++++++++++++++ internal/crq/codex_replay_test.go | 4 +- internal/crq/feedback.go | 13 +-- internal/crq/repoconfig.go | 173 ++++++++++++++++++++++++++++++ internal/crq/repoconfig_test.go | 65 +++++++++++ internal/crq/reviewers.go | 51 +++++++++ internal/crq/reviewers_test.go | 51 +++++++++ internal/crq/service.go | 82 +++++++------- internal/crq/service_test.go | 6 +- internal/crq/state.go | 2 + internal/state/state.go | 56 ++++++++++ llms.txt | 12 +++ skills/coderabbit-queue/SKILL.md | 14 +++ 14 files changed, 599 insertions(+), 50 deletions(-) create mode 100644 internal/crq/repoconfig.go create mode 100644 internal/crq/repoconfig_test.go diff --git a/README.md b/README.md index f9d091a9..5f3c96bd 100644 --- a/README.md +++ b/README.md @@ -385,6 +385,9 @@ crq loop # blocking one-shot round: fire + wait + emit JSON fin crq feedback # current normalized findings as JSON, WITHOUT triggering a review crq resolve [...] # resolve addressed review threads crq decline [...] --reason "" [--resolve] # record why a finding is declined +crq reviewers # which bots review this project, and what each costs +crq reviewers set --bots [--required ] # choose them +crq reviewers clear # back to the fleet default crq autoreview # ⭐ review ALL open PRs automatically, rate-coordinated # (--no-incremental = first review only; --once = single pass for cron) crq status # show the dashboard: queue, in-flight, quota, next slot diff --git a/cmd/crq/main.go b/cmd/crq/main.go index b1cd9dbd..6f808089 100644 --- a/cmd/crq/main.go +++ b/cmd/crq/main.go @@ -211,6 +211,12 @@ func run(ctx context.Context, args []string) int { } printJSON(result) return 0 + case "reviewers": + if err := cfg.RequireState(); err != nil { + fatal(err) + return 1 + } + return runReviewers(ctx, service, args[1:]) case "decline": threads, reason, resolve, ok := parseDeclineArgs(args[1:]) if !ok || len(threads) == 0 || strings.TrimSpace(reason) == "" { @@ -356,6 +362,10 @@ USAGE crq decline [...] --reason "" [--keep-open] reply on a thread to record why a finding is declined (resolves it; --keep-open leaves it open) + crq reviewers which bots review this project (and what each costs) + crq reviewers set --bots [--required ] + choose this project's reviewers + crq reviewers clear go back to the fleet default crq autoreview [--once] [--no-incremental] keep open PRs reviewed, rate-coordinated crq preflight [--type all|committed|uncommitted] [--base ] @@ -509,6 +519,32 @@ replies contesting the decline, crq re-surfaces that reply as its own finding. Pass --keep-open to leave it unresolved anyway (an on-the-record disagreement you intend to keep working). Thread IDs come from .findings[].thread_id. +`) + case "reviewers": + fmt.Print(`crq reviewers +crq reviewers set --bots [--required ] +crq reviewers clear + +Which bots review one project, and what each of them costs. + +Without a subcommand it reports the reviewers that will actually run there — the +fleet default, or this repository's own choice if it has one. Each entry carries +its budget: "account" is serialized against the shared CodeRabbit allowance, +"none" runs immediately and waits for nobody. + + set --bots chooses the co-reviewers; --required chooses which reviewers + gate convergence. An empty value means none here, which is a different + answer from not setting it at all. + clear drops the override so the repository follows the fleet again. + +The configuration lives in the shared state ref, not in a file the repository +carries: the daemon has no checkout of the repos it reviews, and a daemon and an +agent reading different configurations while writing one state ref is a class of +bug worth not having. + +The primary reviewer is fleet-wide. Its markers and command are compiled into the +classifiers when crq starts, so a per-repo primary would mean per-repo +classifiers — a much larger change than choosing who else runs. `) case "autoreview", "auto": fmt.Print(`crq autoreview [--once] [--no-incremental] @@ -696,6 +732,87 @@ func codeRabbitOrg(ctx context.Context, binary string) string { return checkCodeRabbitAuth(ctx, tools).CurrentOrg } +// runReviewers handles `crq reviewers [set|clear] [flags]`. +func runReviewers(ctx context.Context, service *crq.Service, args []string) int { + action, rest := "show", args + if len(args) > 0 && (args[0] == "set" || args[0] == "clear") { + action, rest = args[0], args[1:] + } + var repo string + var bots, required *string + for i := 0; i < len(rest); i++ { + arg := rest[i] + switch { + case arg == "--bots", arg == "--required": + if i+1 >= len(rest) { + fatal(fmt.Errorf("%s needs a value", arg)) + return 1 + } + value := rest[i+1] + i++ + if arg == "--bots" { + bots = &value + } else { + required = &value + } + case strings.HasPrefix(arg, "--bots="): + value := strings.TrimPrefix(arg, "--bots=") + bots = &value + case strings.HasPrefix(arg, "--required="): + value := strings.TrimPrefix(arg, "--required=") + required = &value + case strings.HasPrefix(arg, "-"): + fatal(fmt.Errorf("unknown flag %s (usage: crq reviewers set --bots )", arg)) + return 1 + case repo == "": + repo = arg + default: + fatal(fmt.Errorf("unexpected argument %q", arg)) + return 1 + } + } + if repo == "" { + fatal(errors.New("usage: crq reviewers [set|clear] [--bots ] [--required ]")) + return 1 + } + + var view crq.ReviewerView + var err error + switch action { + case "clear": + view, err = service.ClearReviewers(ctx, repo) + case "set": + if bots == nil && required == nil { + fatal(errors.New("set needs --bots or --required (crq reviewers clear drops the override)")) + return 1 + } + view, err = service.SetReviewers(ctx, repo, splitList(bots), splitList(required)) + default: + view, err = service.Reviewers(ctx, repo) + } + if err != nil { + fatal(err) + return 1 + } + printJSON(view) + return 0 +} + +// splitList turns an unset flag into nil and an empty one into an empty +// non-nil slice: "not chosen" and "chosen to be none" are different answers. +func splitList(value *string) []string { + if value == nil { + return nil + } + out := []string{} + for _, part := range strings.Split(*value, ",") { + if part = strings.TrimSpace(part); part != "" { + out = append(out, part) + } + } + return out +} + func repoPR(args []string) (string, int, bool) { if len(args) != 2 { return "", 0, false diff --git a/internal/crq/codex_replay_test.go b/internal/crq/codex_replay_test.go index 5373d08d..041965ef 100644 --- a/internal/crq/codex_replay_test.go +++ b/internal/crq/codex_replay_test.go @@ -513,9 +513,9 @@ func TestSelfHealCodexClaimPreventsDoublePost(t *testing.T) { } obs.eng.Events = events before := f.codexPosted(repo, pr) - f.svc.selfHealCoReviewers(f.ctx, *round, obs.eng, f.clk.now()) + f.svc.selfHealCoReviewers(f.ctx, f.svc.cfg, *round, obs.eng, f.clk.now()) // Second sweeper with the SAME stale observation (still CodexCommandID==0). - f.svc.selfHealCoReviewers(f.ctx, *round, obs.eng, f.clk.now()) + f.svc.selfHealCoReviewers(f.ctx, f.svc.cfg, *round, obs.eng, f.clk.now()) if got := f.codexPosted(repo, pr) - before; got != 1 { t.Fatalf("concurrent self-heals must post exactly once, got %d extra posts", got) } diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index c2722bf4..bc1347cb 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -88,7 +88,8 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe // findings from the raw reviews/comments and derives convergence from // engine.Completion over the same snapshot — no second fetch path, and the // "is head reviewed?" rules live only in the engine. - obs, err := s.observe(ctx, s.cfg, repo, pr, round, now) + cfg := s.cfgFor(st, repo) + obs, err := s.observe(ctx, cfg, repo, pr, round, now) if err != nil { return FeedbackReport{}, err } @@ -123,13 +124,13 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe if anchorOK { anchorCutoff = completionRound.FiredAt.UTC() } - completion := engine.Completion(completionRound, obs.eng, s.cfg.policy()) + completion := engine.Completion(completionRound, obs.eng, cfg.policy()) report.ReviewedBy = completion.ReviewedBy // Completion does not always arrive as a review: a clean-summary comment, a // paired completion reply and a co-reviewer check run all satisfy it. Anchor // the settle window on the newest of ANY of them, or a round completed by a // comment would settle against a stale timestamp and converge instantly. - evidenceBots := s.cfg.evidenceBots() + evidenceBots := cfg.evidenceBots() noteEvidence := func(at time.Time) { if at.After(report.LastEvidenceAt) { report.LastEvidenceAt = at @@ -155,8 +156,8 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe // head commit instead of reporting stale state as current. verdictCutoff = obs.eng.HeadAt } - report.CoReviewers = coReviewerStatuses(s.cfg, obs.eng, verdictCutoff) - if why := engine.PrimaryUnavailableReason(obs.eng, s.cfg.policy(), head); why != "" { + report.CoReviewers = coReviewerStatuses(cfg, obs.eng, verdictCutoff) + if why := engine.PrimaryUnavailableReason(obs.eng, cfg.policy(), head); why != "" { report.PrimaryUnavailable = true report.PrimaryUnavailableReason = s.cfg.Bot + " " + why } @@ -166,7 +167,7 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe // hang convergence if it were) still has its findings reported instead of // dropped. It always includes the required bots: a bot crq waits for whose // findings it didn't surface would hang the loop forever. - extractBots := s.cfg.evidenceBots() + extractBots := cfg.evidenceBots() // Review-body findings — CodeRabbit's detailed and "Prompt for AI agents" // blocks — carry no per-finding resolution state, only the review's commit. diff --git a/internal/crq/repoconfig.go b/internal/crq/repoconfig.go new file mode 100644 index 00000000..22bae4b9 --- /dev/null +++ b/internal/crq/repoconfig.go @@ -0,0 +1,173 @@ +package crq + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" +) + +// ReviewerView is how one repository's reviewers read after its override is +// applied — the answer to "which bots run on this project". +type ReviewerView struct { + Repo string `json:"repo"` + // Overridden says whether this repository has its own configuration or is + // simply following the fleet default. + Overridden bool `json:"overridden"` + UpdatedAt string `json:"updated_at,omitempty"` + By string `json:"by,omitempty"` + Reviewers []ReviewerDetail `json:"reviewers"` +} + +// ReviewerDetail is one reviewer as it will actually be used. +type ReviewerDetail struct { + Login string `json:"login"` + // Budget is the only property the queue cares about: "account" is serialized + // against the shared allowance, "none" runs immediately. + Budget string `json:"budget"` + Required bool `json:"required"` + Trigger string `json:"trigger,omitempty"` +} + +// Reviewers reports the reviewers that will run on repo. +func (s *Service) Reviewers(ctx context.Context, repo string) (ReviewerView, error) { + st, _, err := s.store.Load(ctx) + if err != nil { + return ReviewerView{}, err + } + repo = NormalizeRepo(repo) + cfg := s.cfgFor(st, repo) + view := ReviewerView{Repo: repo, Reviewers: []ReviewerDetail{}} + if ov, ok := st.RepoOverride(repo); ok { + view.Overridden = true + view.By = ov.By + if ov.UpdatedAt != nil { + view.UpdatedAt = ov.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z") + } + } + for _, r := range cfg.Reviewers { + view.Reviewers = append(view.Reviewers, ReviewerDetail{ + Login: r.Login, + Budget: string(r.Budget), + Required: r.Required, + Trigger: string(r.Trigger), + }) + } + return view, nil +} + +// SetReviewers records which co-reviewers run on repo and which of them gate +// convergence. A nil list means "leave that half alone"; an empty non-nil list +// means "none here", which is a different thing and has to survive as one. +// +// The primary is not settable. Its markers and command are injected into the +// dialect classifiers when the Service is built, so a per-repo primary would +// mean per-repo classifiers. +func (s *Service) SetReviewers(ctx context.Context, repo string, coBots, required []string) (ReviewerView, error) { + repo = NormalizeRepo(repo) + if repo == "" || !strings.Contains(repo, "/") { + return ReviewerView{}, fmt.Errorf("repo must be owner/name, got %q", repo) + } + // Accept either spelling: the login (chatgpt-codex-connector[bot]) or the + // short config name (codex), which is what CRQ_COBOTS already takes. + known := map[string]string{} + for _, cb := range s.cfg.CoBots { + known[dialect.NormalizeBotName(cb.Login)] = cb.Login + if cb.Name != "" { + known[strings.ToLower(strings.TrimSpace(cb.Name))] = cb.Login + } + } + // Refuse a bot crq has no registry entry for: it could never be triggered or + // classified, so accepting it would record a configuration that silently + // does nothing. + resolve := func(known map[string]string, list []string, what string) ([]string, error) { + out := make([]string, 0, len(list)) + for _, name := range list { + name = strings.TrimSpace(name) + if name == "" { + continue + } + login, ok := known[dialect.NormalizeBotName(name)] + if !ok { + login, ok = known[strings.ToLower(strings.TrimSpace(name))] + } + if !ok { + return nil, fmt.Errorf("%s: unknown reviewer %q (known: %s)", what, name, strings.Join(knownLogins(known), ", ")) + } + out = append(out, login) + } + return out, nil + } + + ov := RepoReviewers{} + if coBots != nil { + resolved, err := resolve(known, coBots, "--bots") + if err != nil { + return ReviewerView{}, err + } + ov.CoBots, ov.SetCoBots = resolved, true + } + if required != nil { + // The primary may gate here even though it cannot be replaced here. + allowed := map[string]string{dialect.NormalizeBotName(s.cfg.Bot): s.cfg.Bot} + for k, v := range known { + allowed[k] = v + } + resolved, err := resolve(allowed, required, "--required") + if err != nil { + return ReviewerView{}, err + } + ov.Required, ov.SetRequired = resolved, true + } + now := s.clock().UTC() + ov.UpdatedAt = &now + ov.By = s.cfg.Host + + state, err := s.store.Update(ctx, func(st *State) error { + st.SetRepoOverride(repo, ov) + return nil + }) + if err != nil { + return ReviewerView{}, err + } + s.sync(ctx, state) + return s.Reviewers(ctx, repo) +} + +// ClearReviewers returns repo to the fleet default. +func (s *Service) ClearReviewers(ctx context.Context, repo string) (ReviewerView, error) { + repo = NormalizeRepo(repo) + state, err := s.store.Update(ctx, func(st *State) error { + if !st.ClearRepoOverride(repo) { + return ErrNoChange + } + return nil + }) + if err != nil && !errors.Is(err, ErrNoChange) { + return ReviewerView{}, err + } + if err == nil { + s.sync(ctx, state) + } + return s.Reviewers(ctx, repo) +} + +// knownLogins is the deduplicated set of accepted reviewers, for an error a +// caller can act on. The map holds each bot twice (login and short name), so it +// is the values that must be deduplicated, not the keys. +func knownLogins(known map[string]string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(known)) + for _, login := range known { + if seen[login] { + continue + } + seen[login] = true + out = append(out, login) + } + sort.Strings(out) + return out +} diff --git a/internal/crq/repoconfig_test.go b/internal/crq/repoconfig_test.go new file mode 100644 index 00000000..ac36cd1b --- /dev/null +++ b/internal/crq/repoconfig_test.go @@ -0,0 +1,65 @@ +package crq + +import ( + "context" + "testing" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" +) + +// The override has to reach a real decision, not just the config value: a +// per-repo setting that every caller reads and no decision honours is worse +// than none, because it reads as configured. +func TestRepoOverrideReachesTheDecision(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.RequiredBots = []string{"coderabbitai[bot]", dialect.CodexBotLogin} + cfg.CoBots = codexCoBots(cfg.RequiredBots) + cfg.FeedbackBots = cfg.RequiredBots + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + // Fleet default: Codex gates. + if got := svc.cfgFor(st, "o/plain"); !containsBot(got.RequiredBots, dialect.CodexBotLogin) { + t.Fatalf("fleet RequiredBots = %v, want codex gating", got.RequiredBots) + } + + now := time.Now().UTC() + if _, err := store.Update(ctx, func(st *State) error { + st.SetRepoOverride("o/quiet", RepoReviewers{ + CoBots: []string{}, SetCoBots: true, + Required: []string{"coderabbitai[bot]"}, SetRequired: true, + UpdatedAt: &now, + }) + return nil + }); err != nil { + t.Fatal(err) + } + st, _, err = store.Load(ctx) + if err != nil { + t.Fatal(err) + } + + quiet := svc.cfgFor(st, "o/quiet") + if containsBot(quiet.RequiredBots, dialect.CodexBotLogin) { + t.Errorf("RequiredBots = %v, want codex not gating this repo", quiet.RequiredBots) + } + // The policy the engine actually decides from must carry it. + for _, cp := range quiet.policy().CoReviewerPolicies() { + if dialect.IsCodexBot(cp.Login) { + t.Errorf("policy still drives codex on o/quiet: %+v", cp) + } + } + if !containsBot(quiet.policy().RequiredBots, "coderabbitai[bot]") { + t.Errorf("policy RequiredBots = %v, want the primary still gating", quiet.policy().RequiredBots) + } + // And the other repository is untouched by its neighbour's choice. + if !containsBot(svc.cfgFor(st, "o/plain").RequiredBots, dialect.CodexBotLogin) { + t.Error("one repository's override must not change another's") + } +} diff --git a/internal/crq/reviewers.go b/internal/crq/reviewers.go index bcfe7dfe..886bb990 100644 --- a/internal/crq/reviewers.go +++ b/internal/crq/reviewers.go @@ -175,3 +175,54 @@ func silenceTrigger(coBots []CoBotConfig, login string) []CoBotConfig { func (c Config) evidenceBots() map[string]struct{} { return dialect.BotSet(unionBots(c.FeedbackBots, c.RequiredBots)) } + +// ForRepo applies a repository's reviewer override to the fleet configuration. +// +// The primary is deliberately NOT overridable. Its markers and command are +// injected into the dialect classifiers when the Service is constructed, so a +// per-repo primary would mean per-repo classifiers — a much larger change than +// "which co-reviewers run here", which is what the request actually is. +func (c Config) ForRepo(ov RepoReviewers) Config { + if !ov.SetCoBots && !ov.SetRequired { + return c + } + out := c + if ov.SetCoBots { + keep := make([]CoBotConfig, 0, len(c.CoBots)) + for _, cb := range c.CoBots { + if containsBot(ov.CoBots, cb.Login) { + keep = append(keep, cb) + } + } + out.CoBots = keep + } + if ov.SetRequired { + out.RequiredBots = append([]string(nil), ov.Required...) + } + // Rebuild the derived views from the overridden lists, exactly as + // LoadConfig does, so no view can answer differently from another. + out.Reviewers = buildReviewers(out.Bot, out.ReviewCommand, out.RequiredBots, out.CoBots) + out.RequiredBots = out.reviewerLogins(func(r Reviewer) bool { return r.Required }) + out.FeedbackBots = out.reviewerLogins(func(r Reviewer) bool { return r.Required || !r.Metered() }) + return out +} + +func containsBot(logins []string, login string) bool { + key := dialect.NormalizeBotName(login) + for _, candidate := range logins { + if dialect.NormalizeBotName(candidate) == key { + return true + } + } + return false +} + +// cfgFor is the configuration crq should use for one repository: the fleet +// default with that repository's override applied. +func (s *Service) cfgFor(st State, repo string) Config { + ov, ok := st.RepoOverride(repo) + if !ok { + return s.cfg + } + return s.cfg.ForRepo(ov) +} diff --git a/internal/crq/reviewers_test.go b/internal/crq/reviewers_test.go index 2f3e4517..74b4173c 100644 --- a/internal/crq/reviewers_test.go +++ b/internal/crq/reviewers_test.go @@ -225,3 +225,54 @@ func TestDerivationLosesNothing(t *testing.T) { } }) } + +// Per-repo reviewers answer the request crq exists to make easy: which bots run +// on which project. The override has to reach the DERIVED views too, or crq +// would still gate on, and surface findings from, a bot this repository excluded. +func TestForRepoOverridesEveryDerivedView(t *testing.T) { + // isolatedConfig blanks CRQ_COBOTS, so ask for the shipped default explicitly. + fleet := isolatedConfig(t, map[string]string{"CRQ_COBOTS": "codex,bugbot,macroscope"}) + if len(fleet.CoBots) < 2 { + t.Fatalf("this test needs several co-reviewers by default, got %d", len(fleet.CoBots)) + } + + t.Run("no override changes nothing", func(t *testing.T) { + if got := fleet.ForRepo(RepoReviewers{}); len(got.Reviewers) != len(fleet.Reviewers) { + t.Errorf("reviewers = %d, want the fleet's %d", len(got.Reviewers), len(fleet.Reviewers)) + } + }) + + t.Run("only the chosen co-reviewer runs", func(t *testing.T) { + only := fleet.ForRepo(RepoReviewers{ + CoBots: []string{dialect.CodexBotLogin}, SetCoBots: true, + Required: []string{dialect.CodexBotLogin}, SetRequired: true, + }) + if len(only.CoBots) != 1 || dialect.NormalizeBotName(only.CoBots[0].Login) != dialect.NormalizeBotName(dialect.CodexBotLogin) { + t.Fatalf("CoBots = %+v, want only codex", only.CoBots) + } + // The derived views must agree, or crq gates on a bot this repo excluded. + if len(only.RequiredBots) != 1 || dialect.NormalizeBotName(only.RequiredBots[0]) != dialect.NormalizeBotName(dialect.CodexBotLogin) { + t.Errorf("RequiredBots = %v, want only codex", only.RequiredBots) + } + for _, login := range only.FeedbackBots { + if dialect.NormalizeBotName(login) == "cursor" { + t.Errorf("FeedbackBots = %v still surfaces an excluded bot", only.FeedbackBots) + } + } + // The primary is fleet-wide and still configured, just not gating here. + primary, ok := only.Primary() + if !ok || primary.Login != fleet.Bot { + t.Errorf("primary = %+v ok=%v, want it kept", primary, ok) + } + }) + + t.Run("chosen to be none is not the same as unset", func(t *testing.T) { + none := fleet.ForRepo(RepoReviewers{CoBots: []string{}, SetCoBots: true}) + if len(none.CoBots) != 0 { + t.Errorf("CoBots = %+v, want none — an empty choice must survive", none.CoBots) + } + if _, ok := none.Primary(); !ok { + t.Error("excluding every co-reviewer must not remove the primary") + } + }) +} diff --git a/internal/crq/service.go b/internal/crq/service.go index 4a6feac8..a8e4028e 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -298,13 +298,14 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { if next == nil { return PumpResult{Action: "idle"}, nil } - obs, err := s.observe(ctx, s.cfg, next.Repo, next.PR, next, now) + cfg := s.cfgFor(st, next.Repo) + obs, err := s.observe(ctx, cfg, next.Repo, next.PR, next, now) if err != nil { return PumpResult{}, err } global := s.global(st, now) - decision := engine.DecideFire(global, *next, obs.eng, now, s.cfg.policy()) - result, err := s.applyFire(ctx, *next, obs.eng, decision, now) + decision := engine.DecideFire(global, *next, obs.eng, now, cfg.policy()) + result, err := s.applyFire(ctx, cfg, *next, obs.eng, decision, now) if err != nil { return result, err } @@ -344,7 +345,6 @@ func (s *Service) sweepQuotaFree(ctx context.Context, st State, now time.Time, s if len(queued) == 0 { return PumpResult{}, false, nil } - policy := s.cfg.policy() global := s.global(st, now) scanned := 0 defer func() { s.scanOffset = (s.scanOffset + scanned + 1) % len(queued) }() @@ -363,15 +363,16 @@ func (s *Service) sweepQuotaFree(ctx context.Context, st State, now time.Time, s // left them behind the account block for hours. The budget above bounds // the cost instead. scanned++ - obs, err := s.observe(ctx, s.cfg, round.Repo, round.PR, &round, now) + cfg := s.cfgFor(st, round.Repo) + obs, err := s.observe(ctx, cfg, round.Repo, round.PR, &round, now) if err != nil { continue } - d := engine.DecideFire(global, round, obs.eng, now, policy) + d := engine.DecideFire(global, round, obs.eng, now, cfg.policy()) if !quotaFreeVerdict(d.Verdict) { continue } - res, err := s.applyFire(ctx, round, obs.eng, d, now) + res, err := s.applyFire(ctx, cfg, round, obs.eng, d, now) if err != nil { return PumpResult{}, false, err } @@ -399,15 +400,16 @@ func (s *Service) advanceQuotaFree(ctx context.Context, repo string, pr int) (Pu if round == nil || !round.FireEligible(now) { return PumpResult{}, false, nil } - obs, err := s.observe(ctx, s.cfg, repo, pr, round, now) + cfg := s.cfgFor(st, repo) + obs, err := s.observe(ctx, cfg, repo, pr, round, now) if err != nil { return PumpResult{}, false, err } - d := engine.DecideFire(s.global(st, now), *round, obs.eng, now, s.cfg.policy()) + d := engine.DecideFire(s.global(st, now), *round, obs.eng, now, cfg.policy()) if !quotaFreeVerdict(d.Verdict) { return PumpResult{}, false, nil } - res, err := s.applyFire(ctx, *round, obs.eng, d, now) + res, err := s.applyFire(ctx, cfg, *round, obs.eng, d, now) if err != nil { return PumpResult{}, false, err } @@ -487,12 +489,13 @@ func (s *Service) progressSlotRound(ctx context.Context, slot Round) (PumpResult if err != nil { return PumpResult{}, err } - obs, err := s.observe(ctx, s.cfg, slot.Repo, slot.PR, &slot, now) + cfg := s.cfgFor(st, slot.Repo) + obs, err := s.observe(ctx, cfg, slot.Repo, slot.PR, &slot, now) if err != nil { return PumpResult{}, err } - s.selfHealCoReviewers(ctx, slot, obs.eng, now) - tr := engine.Progress(slot, st.Account, obs.eng, now, s.cfg.policy()) + s.selfHealCoReviewers(ctx, cfg, slot, obs.eng, now) + tr := engine.Progress(slot, st.Account, obs.eng, now, cfg.policy()) if tr.Outcome == engine.KeepWaiting { return PumpResult{Action: "waiting", Repo: slot.Repo, PR: slot.PR, Reason: tr.Reason}, nil } @@ -626,15 +629,16 @@ func (s *Service) sweepReviewing(ctx context.Context, st State, now time.Time) ( if target == nil { return st, nil } - obs, err := s.observe(ctx, s.cfg, target.Repo, target.PR, target, now) + cfg := s.cfgFor(st, target.Repo) + obs, err := s.observe(ctx, cfg, target.Repo, target.PR, target, now) if err != nil { if s.log != nil { s.log.Printf("warning: reviewing-round sweep for %s#%d failed: %v", target.Repo, target.PR, err) } return st, nil } - s.selfHealCoReviewers(ctx, *target, obs.eng, now) - tr := engine.Progress(*target, st.Account, obs.eng, now, s.cfg.policy()) + s.selfHealCoReviewers(ctx, cfg, *target, obs.eng, now) + tr := engine.Progress(*target, st.Account, obs.eng, now, cfg.policy()) if tr.Outcome == engine.KeepWaiting { return st, nil } @@ -663,24 +667,24 @@ func firedOrEnqueuedAt(r Round) time.Time { } // applyFire executes a DecideFire verdict. -func (s *Service) applyFire(ctx context.Context, round Round, obs engine.Observation, d engine.FireDecision, now time.Time) (PumpResult, error) { +func (s *Service) applyFire(ctx context.Context, cfg Config, round Round, obs engine.Observation, d engine.FireDecision, now time.Time) (PumpResult, error) { switch d.Verdict { case engine.FireDrop: return s.abandonRound(ctx, round, "pr closed", "skipped") case engine.FireDedupe: return s.dedupeRound(ctx, round, now, d.Reason) case engine.FireCoOnly: - return s.fireCoOnly(ctx, round, d.PostCo, d.Reason, now) + return s.fireCoOnly(ctx, cfg, round, d.PostCo, d.Reason, now) case engine.FireCoDeferred: - return s.fireCoDeferred(ctx, round, d, now) + return s.fireCoDeferred(ctx, cfg, round, d, now) case engine.FireCoReviewWait: - return s.fireCoReviewWait(ctx, round, obs, d.Reason, now) + return s.fireCoReviewWait(ctx, cfg, round, obs, d.Reason, now) case engine.FireSupersede: return s.supersedeRound(ctx, round, obs.Head, now) case engine.FireAdopt: - return s.fireRound(ctx, round, obs, false, d.AdoptCommandID, d.AdoptAt, d.Reason, d.PostCo, now) + return s.fireRound(ctx, cfg, round, obs, false, d.AdoptCommandID, d.AdoptAt, d.Reason, d.PostCo, now) case engine.FirePost: - return s.fireRound(ctx, round, obs, true, 0, time.Time{}, "", d.PostCo, now) + return s.fireRound(ctx, cfg, round, obs, true, 0, time.Time{}, "", d.PostCo, now) default: // FireNo return PumpResult{Action: mapFireNo(d.Reason), Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: d.Reason}, nil } @@ -786,7 +790,7 @@ func (s *Service) supersedeRound(ctx context.Context, round Round, head string, // round, reserving the global slot under compare-and-swap. postCo lists the // co-reviewer logins whose trigger commands are posted alongside (non-fatal // on failure — the self-heal path retries). -func (s *Service) fireRound(ctx context.Context, round Round, obs engine.Observation, post bool, adoptID int64, adoptAt time.Time, reason string, postCo []string, now time.Time) (PumpResult, error) { +func (s *Service) fireRound(ctx context.Context, cfg Config, round Round, obs engine.Observation, post bool, adoptID int64, adoptAt time.Time, reason string, postCo []string, now time.Time) (PumpResult, error) { key := QueueKey(round.Repo, round.PR) if s.cfg.DryRun { return PumpResult{Action: "dry_run", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: reason}, nil @@ -834,7 +838,7 @@ func (s *Service) fireRound(ctx context.Context, round Round, obs engine.Observa // posting a duplicate; recording it here keeps the round "asked". Its // timestamp anchors that bot's cutoff too, or a SHA-less answer that // landed before this adopted fire would never bind to the round. - for _, cp := range s.cfg.policy().CoReviewerPolicies() { + for _, cp := range cfg.policy().CoReviewerPolicies() { if hasLogin(postCo, cp.Login) || r.Co(cp.Login).CommandID != 0 { continue } @@ -858,7 +862,7 @@ func (s *Service) fireRound(ctx context.Context, round Round, obs engine.Observa s.log.Printf("fire %s@%s (adopted existing review command)", key, round.Head) } for _, login := range postCo { - s.fireCoTrigger(ctx, round, login) + s.fireCoTrigger(ctx, cfg, round, login) } return PumpResult{Action: "fired", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: reason}, nil } @@ -919,7 +923,7 @@ func (s *Service) fireRound(ctx context.Context, round Round, obs engine.Observa // retries. var coPosts []coPost for _, login := range postCo { - if id, at := s.postCoTrigger(ctx, round, login); id != 0 { + if id, at := s.postCoTrigger(ctx, cfg, round, login); id != 0 { coPosts = append(coPosts, coPost{login: login, id: id, at: at}) } } @@ -971,7 +975,7 @@ func (c Config) coCommandFor(login string) string { // no CodeRabbit quota is spent, and therefore NO FireSlot is taken: the // per-round trigger claims (CoBots[login].ClaimedAt, CAS-set before the // network post) are the concurrency guard. -func (s *Service) fireCoOnly(ctx context.Context, round Round, logins []string, reason string, now time.Time) (PumpResult, error) { +func (s *Service) fireCoOnly(ctx context.Context, cfg Config, round Round, logins []string, reason string, now time.Time) (PumpResult, error) { key := QueueKey(round.Repo, round.PR) if s.cfg.DryRun { return PumpResult{Action: "dry_run", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: reason}, nil @@ -1010,7 +1014,7 @@ func (s *Service) fireCoOnly(ctx context.Context, round Round, logins []string, var posts []coPost for _, login := range claimed { - if id, at := s.postCoTrigger(ctx, round, login); id != 0 { + if id, at := s.postCoTrigger(ctx, cfg, round, login); id != 0 { posts = append(posts, coPost{login: login, id: id, at: at}) } } @@ -1123,7 +1127,7 @@ func commandCreatedAt(commands []engine.CommandSeen, id int64, fallback time.Tim // review` command on the PR is adopted as the round's CodexCommandID so the // self-heal path (which anchors on the round's fire time, later than a pre-existing // command) does not re-post it. -func (s *Service) fireCoReviewWait(ctx context.Context, round Round, obs engine.Observation, reason string, now time.Time) (PumpResult, error) { +func (s *Service) fireCoReviewWait(ctx context.Context, cfg Config, round Round, obs engine.Observation, reason string, now time.Time) (PumpResult, error) { result := PumpResult{Action: "waiting", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: reason} if s.cfg.DryRun { return result, nil @@ -1151,7 +1155,7 @@ func (s *Service) fireCoReviewWait(ctx context.Context, round Round, obs engine. at time.Time } var adopts []adoptCmd - for _, cp := range s.cfg.policy().CoReviewerPolicies() { + for _, cp := range cfg.policy().CoReviewerPolicies() { cmds := obs.CoSeenFor(cp.Login).Commands id := newestCommandID(cmds) if id == 0 { @@ -1254,8 +1258,8 @@ func (s *Service) recordFire(ctx context.Context, round Round, token string, com // 0 on failure. A failed post is non-fatal: it logs and leaves the round's // command unset so a later pump's self-heal retries. The fresh-fire path // folds the returned id into recordFire's write. -func (s *Service) postCoTrigger(ctx context.Context, round Round, login string) (int64, time.Time) { - command := strings.TrimSpace(s.cfg.coCommandFor(login)) +func (s *Service) postCoTrigger(ctx context.Context, cfg Config, round Round, login string) (int64, time.Time) { + command := strings.TrimSpace(cfg.coCommandFor(login)) if command == "" { return 0, time.Time{} } @@ -1284,8 +1288,8 @@ func (s *Service) postCoTrigger(ctx context.Context, round Round, login string) // self-heal retry (the fresh-post path records the id inside recordFire // instead). The CAS guard (same head, command still unset) makes a concurrent // post benign. -func (s *Service) fireCoTrigger(ctx context.Context, round Round, login string) { - id, at := s.postCoTrigger(ctx, round, login) +func (s *Service) fireCoTrigger(ctx context.Context, cfg Config, round Round, login string) { + id, at := s.postCoTrigger(ctx, cfg, round, login) if id == 0 { // Failed post: KEEP the claim — its TTL is the retry backoff. Clearing it // here would let the very next pump repost, bypassing triggerClaimTTL. @@ -1322,7 +1326,7 @@ func (s *Service) fireCoTrigger(ctx context.Context, round Round, login string) // normally the moment the window opens, at which point the recorded command // ids keep the triggers from re-posting. The claim-then-post shape mirrors // selfHealCoReviewers — this path is not serialized by the fire slot either. -func (s *Service) fireCoDeferred(ctx context.Context, round Round, d engine.FireDecision, now time.Time) (PumpResult, error) { +func (s *Service) fireCoDeferred(ctx context.Context, cfg Config, round Round, d engine.FireDecision, now time.Time) (PumpResult, error) { action := func(base string) string { // Preserve the historical action names for the Codex-only case. if len(d.PostCo) <= 1 && len(d.AdoptCo) <= 1 { @@ -1403,7 +1407,7 @@ func (s *Service) fireCoDeferred(ctx context.Context, round Round, d engine.Fire } s.sync(ctx, updated) for _, login := range claimed { - s.fireCoTrigger(ctx, round, login) + s.fireCoTrigger(ctx, cfg, round, login) } if len(claimed) == 0 { result.Action = action("adopted") @@ -1418,12 +1422,12 @@ func (s *Service) fireCoDeferred(ctx context.Context, round Round, d engine.Fire // idempotence comes from the observation — the bot's evidence, a live trigger // command, or an account that reviews on its own all suppress it (see // DecideCoPost) — not a retry counter. -func (s *Service) selfHealCoReviewers(ctx context.Context, round Round, obs engine.Observation, now time.Time) { +func (s *Service) selfHealCoReviewers(ctx context.Context, cfg Config, round Round, obs engine.Observation, now time.Time) { if s.cfg.DryRun || round.FiredAt == nil || obs.Head != round.Head { return } firedAt := round.FiredAt.UTC() - for _, cp := range s.cfg.policy().CoReviewerPolicies() { + for _, cp := range cfg.policy().CoReviewerPolicies() { if round.Co(cp.Login).CommandID != 0 || (dialect.IsCodexBot(cp.Login) && round.CodexCommandID != 0) { continue } @@ -1455,7 +1459,7 @@ func (s *Service) selfHealCoReviewers(ctx context.Context, round Round, obs engi continue } s.sync(ctx, updated) - s.fireCoTrigger(ctx, round, login) + s.fireCoTrigger(ctx, cfg, round, login) } } diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index 715abb3d..27a62b9b 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -2361,7 +2361,7 @@ func TestFireCoDeferredAdoptionHonorsDryRunAndActiveClaim(t *testing.T) { round := *st.Round("o/carrier", 95) svc.cfg.DryRun = true - res, err := svc.fireCoDeferred(ctx, round, engine.FireDecision{Verdict: engine.FireCoDeferred, Reason: "dry run adopt", AdoptCo: map[string]engine.CommandSeen{dialect.CodexBotLogin: {ID: 703, CreatedAt: now.Add(-time.Minute)}}}, now) + res, err := svc.fireCoDeferred(ctx, svc.cfg, round, engine.FireDecision{Verdict: engine.FireCoDeferred, Reason: "dry run adopt", AdoptCo: map[string]engine.CommandSeen{dialect.CodexBotLogin: {ID: 703, CreatedAt: now.Add(-time.Minute)}}}, now) if err != nil { t.Fatal(err) } @@ -2382,7 +2382,7 @@ func TestFireCoDeferredAdoptionHonorsDryRunAndActiveClaim(t *testing.T) { }); err != nil { t.Fatal(err) } - res, err = svc.fireCoDeferred(ctx, round, engine.FireDecision{Verdict: engine.FireCoDeferred, Reason: "claimed adopt", AdoptCo: map[string]engine.CommandSeen{dialect.CodexBotLogin: {ID: 703, CreatedAt: now.Add(-time.Minute)}}}, now) + res, err = svc.fireCoDeferred(ctx, svc.cfg, round, engine.FireDecision{Verdict: engine.FireCoDeferred, Reason: "claimed adopt", AdoptCo: map[string]engine.CommandSeen{dialect.CodexBotLogin: {ID: 703, CreatedAt: now.Add(-time.Minute)}}}, now) if err != nil { t.Fatal(err) } @@ -2395,7 +2395,7 @@ func TestFireCoDeferredAdoptionHonorsDryRunAndActiveClaim(t *testing.T) { } staleNow := now.Add(triggerClaimTTL + time.Second) - res, err = svc.fireCoDeferred(ctx, round, engine.FireDecision{Verdict: engine.FireCoDeferred, Reason: "stale claim adopt", AdoptCo: map[string]engine.CommandSeen{dialect.CodexBotLogin: {ID: 703, CreatedAt: now.Add(-time.Minute)}}}, staleNow) + res, err = svc.fireCoDeferred(ctx, svc.cfg, round, engine.FireDecision{Verdict: engine.FireCoDeferred, Reason: "stale claim adopt", AdoptCo: map[string]engine.CommandSeen{dialect.CodexBotLogin: {ID: 703, CreatedAt: now.Add(-time.Minute)}}}, staleNow) if err != nil { t.Fatal(err) } diff --git a/internal/crq/state.go b/internal/crq/state.go index a9da1d28..bd82577f 100644 --- a/internal/crq/state.go +++ b/internal/crq/state.go @@ -23,6 +23,8 @@ type ( Revision = crqstate.Revision StateStore = crqstate.StateStore StoreConfig = crqstate.StoreConfig + // RepoReviewers is the per-repository reviewer override (see Config.ForRepo). + RepoReviewers = crqstate.RepoReviewers ) const ( diff --git a/internal/state/state.go b/internal/state/state.go index ede4e853..56b8a8c5 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -285,6 +285,17 @@ type State struct { // one. Persisted in the shared state so the whole fleet uses the new issue. CalibrationIssue int `json:"calibration_issue,omitempty"` + // Repos holds per-repository reviewer overrides, keyed by normalized + // "owner/name". + // + // It lives HERE, in the shared state ref, rather than in a .crq.yaml the + // repository carries. The daemon has no checkout of any repository it + // reviews, so an in-repo file would be invisible to it or cost a REST fetch + // per PR — and a daemon and an agent reading different configurations while + // writing one shared state ref is a new class of divergence. Both already + // read this ref, so both cannot disagree about it. + Repos map[string]RepoReviewers `json:"repos,omitempty"` + // Archive keeps recently finished rounds (superseded, closed, cancelled) // for the dashboard and debugging. Bounded by ArchiveMax. Archive []Round `json:"archive,omitempty"` @@ -298,6 +309,51 @@ type State struct { unknown unknownFields } +// RepoReviewers overrides which reviewers run on one repository. A nil slice +// means "no override, use the fleet default"; an empty non-nil slice means +// "none here" — the difference is why these are pointers to slices in JSON +// terms and why SetRepoReviewers takes explicit values. +type RepoReviewers struct { + // CoBots are the co-reviewer logins enabled here, replacing the fleet list. + CoBots []string `json:"cobots,omitempty"` + // Required are the logins that gate convergence here. + Required []string `json:"required,omitempty"` + // SetCoBots/SetRequired record whether each list was set at all, so + // "explicitly none" survives a JSON round trip that drops empty slices. + SetCoBots bool `json:"set_cobots,omitempty"` + SetRequired bool `json:"set_required,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + By string `json:"by,omitempty"` +} + +// RepoOverride returns the override for repo, and whether one exists. +func (s *State) RepoOverride(repo string) (RepoReviewers, bool) { + ov, ok := s.Repos[normalizeRepoKey(repo)] + return ov, ok +} + +// SetRepoOverride records repo's reviewer override, replacing any earlier one. +func (s *State) SetRepoOverride(repo string, ov RepoReviewers) { + if s.Repos == nil { + s.Repos = map[string]RepoReviewers{} + } + s.Repos[normalizeRepoKey(repo)] = ov +} + +// ClearRepoOverride drops repo's override, returning it to the fleet default. +func (s *State) ClearRepoOverride(repo string) bool { + key := normalizeRepoKey(repo) + if _, ok := s.Repos[key]; !ok { + return false + } + delete(s.Repos, key) + return true +} + +func normalizeRepoKey(repo string) string { + return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(repo), ".git")) +} + const SchemaVersion = 3 // ArchiveMax bounds the finished-rounds ring. Active rounds are never diff --git a/llms.txt b/llms.txt index ad5de806..0264233e 100644 --- a/llms.txt +++ b/llms.txt @@ -202,6 +202,18 @@ convergence never hides a rebuttal you have not answered. Address it or decline reason; the loop clears it once the bot withdraws (or you stop declining and fix it). Ambiguous bot replies surface too — crq errs toward showing a possible rebuttal rather than burying it. +Choose which bots review one project: + +```bash +crq reviewers REPO # effective reviewers + budgets +crq reviewers set REPO --bots codex --required codex # only Codex here +crq reviewers clear REPO # fleet default again +``` + +`budget: account` means the reviewer is serialized against the shared CodeRabbit allowance; +`budget: none` means it runs immediately. Stored in the shared state ref, so the daemon and agents +cannot disagree. The primary reviewer is fleet-wide. + Current feedback without triggering a review: ```bash diff --git a/skills/coderabbit-queue/SKILL.md b/skills/coderabbit-queue/SKILL.md index ef9dae27..b4609e1e 100644 --- a/skills/coderabbit-queue/SKILL.md +++ b/skills/coderabbit-queue/SKILL.md @@ -171,6 +171,20 @@ thread left open keeps its finding actionable and `crq next` would repeat `fix` disagreement is not lost: if the bot contests the decline, crq re-surfaces that reply as its own finding. Pass `--keep-open` to leave it unresolved deliberately. +## Which Bots Review Which Project + +```bash +crq reviewers "$REPO" # who runs here, and what each costs +crq reviewers set "$REPO" --bots codex --required codex # only Codex here +crq reviewers clear "$REPO" # back to the fleet default +``` + +Each reviewer reports its `budget`: `account` is serialized against the shared CodeRabbit allowance, +`none` runs immediately and waits for nobody. That is the only property the queue cares about. + +The setting lives in the shared state ref, so the daemon and every agent read the same one. The +primary reviewer is fleet-wide and cannot be set per repository. + ## Fleet Auto-Review To keep all open PRs in scope reviewed while CodeRabbit native auto-review is off: From 5acbd05f67eb7fcee28b9777f6d110033d5df68d Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sun, 26 Jul 2026 19:08:39 +0200 Subject: [PATCH 02/37] Let a project add a reviewer, not only remove one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight review findings, two of them P1. Choices resolved against the fleet's enabled list, so the feature could only ever subtract: with CRQ_COBOTS=codex, asking a project for bugbot was rejected as unknown though it is a registered reviewer. They resolve against the registry now, and a bot the fleet does not enable is built from its registry entry. Naming a bot in --required enables it too, the rule fleet parsing already follows — one that gates but is never triggered waits forever. A primary that is itself a registry bot keeps its silenced entry through an override. That entry carries its wording and check-run hooks, so dropping it costs the PRIMARY its evidence and the round waits out a clean result crq can no longer read. Same defect as the fleet path had, one layer up. A second call naming one half no longer wipes the other: the existing override is loaded and merged, which is what the nil-versus-empty contract promised. An empty --required is refused, because gating on nobody makes Feedback converge before any chosen reviewer runs, and clear takes no mutation flags — ignoring them turned a malformed call into a silent wipe. The Codex-only degrade is gated on Codex being a reviewer here, or an unsolicited auto-review would release the head during an account block on behalf of a bot the project excluded. And sharing a ref only stops an old binary ERASING the field; it does not make it act on one. State now records which hosts write it and what they understand, and `crq reviewers` names any host driving the queue that predates overrides, instead of implying agreement. --- cmd/crq/main.go | 6 +++ internal/crq/config.go | 44 ++++++++++++++------- internal/crq/feedback.go | 8 +++- internal/crq/repoconfig.go | 53 +++++++++++++++++-------- internal/crq/repoconfig_test.go | 62 +++++++++++++++++++++++++++++ internal/crq/reviewers.go | 43 +++++++++++++++++++- internal/crq/state.go | 4 ++ internal/state/state.go | 67 ++++++++++++++++++++++++++++++++ internal/state/store.go | 4 ++ internal/state/writers_test.go | 44 +++++++++++++++++++++ skills/coderabbit-queue/SKILL.md | 6 ++- 11 files changed, 307 insertions(+), 34 deletions(-) create mode 100644 internal/state/writers_test.go diff --git a/cmd/crq/main.go b/cmd/crq/main.go index 6f808089..6fe5c699 100644 --- a/cmd/crq/main.go +++ b/cmd/crq/main.go @@ -780,6 +780,12 @@ func runReviewers(ctx context.Context, service *crq.Service, args []string) int var err error switch action { case "clear": + // Ignoring a mutation flag here would turn a malformed automation call + // like `reviewers clear repo --bots codex` into a silent wipe. + if bots != nil || required != nil { + fatal(errors.New("clear takes no --bots/--required (it drops the whole override)")) + return 1 + } view, err = service.ClearReviewers(ctx, repo) case "set": if bots == nil && required == nil { diff --git a/internal/crq/config.go b/internal/crq/config.go index 98d6cdf8..e505d709 100644 --- a/internal/crq/config.go +++ b/internal/crq/config.go @@ -359,20 +359,18 @@ func parseCoBots(env map[string]string, requiredBots []string) ([]CoBotConfig, e if !enabled[co.Name] && !required { continue } + base := defaultCoBot(co, required) // Uniform across bots: the per-bot key wins, then the registry's legacy // alias if it declares one, then its default command. No bot is named // here — the policy travels as registry metadata. - command := co.Command + command := base.Command if v, ok := env["CRQ_COBOT_"+key+"_CMD"]; ok { command = v } else if co.LegacyCommandEnv != "" { command = stringEnvAllowEmpty(env, co.LegacyCommandEnv, command) } command = strings.TrimSpace(command) - trigger := triggerMode(co.DefaultTrigger, engine.TriggerSelfHeal) - if required && co.RequiredTrigger != "" { - trigger = triggerMode(co.RequiredTrigger, trigger) - } + trigger := base.Trigger switch v := engine.TriggerMode(strings.ToLower(strings.TrimSpace(env["CRQ_COBOT_"+key+"_TRIGGER"]))); v { case engine.TriggerNever, engine.TriggerSelfHeal, engine.TriggerAlways: trigger = v @@ -381,18 +379,38 @@ func parseCoBots(env map[string]string, requiredBots []string) ([]CoBotConfig, e // No trigger command means crq can never post one, whatever the mode. trigger = engine.TriggerNever } - out = append(out, CoBotConfig{ - Login: co.Login, - Name: co.Name, - Command: command, - Trigger: trigger, - Required: required, - SelfHealGrace: durationEnv(env, "CRQ_COBOT_"+key+"_GRACE", 10*time.Minute), - }) + base.Command, base.Trigger = command, trigger + base.SelfHealGrace = durationEnv(env, "CRQ_COBOT_"+key+"_GRACE", defaultSelfHealGrace) + out = append(out, base) } return out, nil } +const defaultSelfHealGrace = 10 * time.Minute + +// defaultCoBot is a co-reviewer's configuration from the registry alone, with no +// environment applied. parseCoBots layers env on top of it, and a per-repo +// override uses it directly for a bot the fleet default does not enable — which +// is the whole point of choosing reviewers per project. +func defaultCoBot(co dialect.CoReviewer, required bool) CoBotConfig { + trigger := triggerMode(co.DefaultTrigger, engine.TriggerSelfHeal) + if required && co.RequiredTrigger != "" { + trigger = triggerMode(co.RequiredTrigger, trigger) + } + command := strings.TrimSpace(co.Command) + if command == "" { + trigger = engine.TriggerNever + } + return CoBotConfig{ + Login: co.Login, + Name: co.Name, + Command: command, + Trigger: trigger, + Required: required, + SelfHealGrace: defaultSelfHealGrace, + } +} + // triggerMode converts a registry trigger string to the engine mode, falling // back when a bot declares none. func triggerMode(name string, fallback engine.TriggerMode) engine.TriggerMode { diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index bc1347cb..d4c3e64a 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -446,7 +446,11 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe // quota block qualifies — a round's own awaiting_retry cooldown also // covers non-quota retries (post failures, timeouts) that must keep their // normal retry handling. - if s.cfg.RateLimitCoDegrade && !report.Converged && st.Account.BlockedUntil != nil { + // Gated on Codex being a reviewer HERE. The degrade releases the head on + // Codex's word while the primary's window is shut; on a repository that + // excluded Codex, an unsolicited auto-review would otherwise stand in for a + // bot nobody asked for. + if cfg.RateLimitCoDegrade && cfg.coBotEnabled(dialect.CodexBotLogin) && !report.Converged && st.Account.BlockedUntil != nil { until := st.Account.BlockedUntil.UTC() if until.After(now) && engine.CodexOnlyEligible(completionRound, obs.eng, &until, now) { report.CodeRabbitDeferred = true @@ -457,7 +461,7 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe case report.Converged: report.Status = "converged" case report.CodeRabbitDeferred && len(report.Findings) == 0 && - engine.DoneExceptWithEvidence(report.ReviewedBy, s.cfg.Bot, dialect.CodexBotLogin): + engine.DoneExceptWithEvidence(report.ReviewedBy, cfg.Bot, dialect.CodexBotLogin): report.Status = "deferred" report.Reason = "codex reviewed clean; coderabbit review deferred until " + report.DeferredUntil.UTC().Format(time.RFC3339) + " (account rate-limited)" diff --git a/internal/crq/repoconfig.go b/internal/crq/repoconfig.go index 22bae4b9..fb17c7eb 100644 --- a/internal/crq/repoconfig.go +++ b/internal/crq/repoconfig.go @@ -16,10 +16,15 @@ type ReviewerView struct { Repo string `json:"repo"` // Overridden says whether this repository has its own configuration or is // simply following the fleet default. - Overridden bool `json:"overridden"` - UpdatedAt string `json:"updated_at,omitempty"` - By string `json:"by,omitempty"` - Reviewers []ReviewerDetail `json:"reviewers"` + Overridden bool `json:"overridden"` + // Lagging names hosts that are driving this queue without understanding + // per-repo overrides — an older binary loads the field, writes it back + // untouched, and keeps deciding from its own fleet-wide configuration. The + // override is real; those hosts will not honour it until they are upgraded. + Lagging []string `json:"lagging_hosts,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + By string `json:"by,omitempty"` + Reviewers []ReviewerDetail `json:"reviewers"` } // ReviewerDetail is one reviewer as it will actually be used. @@ -41,6 +46,7 @@ func (s *Service) Reviewers(ctx context.Context, repo string) (ReviewerView, err repo = NormalizeRepo(repo) cfg := s.cfgFor(st, repo) view := ReviewerView{Repo: repo, Reviewers: []ReviewerDetail{}} + view.Lagging = st.LaggingWriters(CapsRepoOverrides, s.clock().UTC()) if ov, ok := st.RepoOverride(repo); ok { view.Overridden = true view.By = ov.By @@ -73,36 +79,45 @@ func (s *Service) SetReviewers(ctx context.Context, repo string, coBots, require } // Accept either spelling: the login (chatgpt-codex-connector[bot]) or the // short config name (codex), which is what CRQ_COBOTS already takes. + // Resolve against the REGISTRY, not the fleet's enabled list. Restricting a + // project to bots the fleet already runs would make this feature only ever + // subtract, when the point is choosing different reviewers per project. + // Either spelling works: the login, or the short name CRQ_COBOTS takes. known := map[string]string{} - for _, cb := range s.cfg.CoBots { - known[dialect.NormalizeBotName(cb.Login)] = cb.Login - if cb.Name != "" { - known[strings.ToLower(strings.TrimSpace(cb.Name))] = cb.Login - } + for _, co := range dialect.KnownCoReviewers() { + known[dialect.NormalizeBotName(co.Login)] = co.Login + known[strings.ToLower(strings.TrimSpace(co.Name))] = co.Login } - // Refuse a bot crq has no registry entry for: it could never be triggered or - // classified, so accepting it would record a configuration that silently - // does nothing. - resolve := func(known map[string]string, list []string, what string) ([]string, error) { + resolve := func(allowed map[string]string, list []string, what string) ([]string, error) { out := make([]string, 0, len(list)) for _, name := range list { name = strings.TrimSpace(name) if name == "" { continue } - login, ok := known[dialect.NormalizeBotName(name)] + login, ok := allowed[dialect.NormalizeBotName(name)] if !ok { - login, ok = known[strings.ToLower(strings.TrimSpace(name))] + login, ok = allowed[strings.ToLower(name)] } if !ok { - return nil, fmt.Errorf("%s: unknown reviewer %q (known: %s)", what, name, strings.Join(knownLogins(known), ", ")) + return nil, fmt.Errorf("%s: unknown reviewer %q (known: %s)", what, name, strings.Join(knownLogins(allowed), ", ")) } out = append(out, login) } return out, nil } + // Start from the existing override so a call that names one half leaves the + // other alone — the contract the nil/empty distinction promises. ov := RepoReviewers{} + if st, _, err := s.store.Load(ctx); err == nil { + if existing, ok := st.RepoOverride(repo); ok { + ov = existing + } + } else { + return ReviewerView{}, err + } + if coBots != nil { resolved, err := resolve(known, coBots, "--bots") if err != nil { @@ -111,6 +126,12 @@ func (s *Service) SetReviewers(ctx context.Context, repo string, coBots, require ov.CoBots, ov.SetCoBots = resolved, true } if required != nil { + if len(required) == 0 { + // Gating on nobody means Feedback reports converged before anything + // runs, so `crq next` says done and the reviewers chosen by --bots + // never review at all. + return ReviewerView{}, errors.New("--required cannot be empty: a round that gates on nobody converges before any reviewer runs (crq reviewers clear to drop the override)") + } // The primary may gate here even though it cannot be replaced here. allowed := map[string]string{dialect.NormalizeBotName(s.cfg.Bot): s.cfg.Bot} for k, v := range known { diff --git a/internal/crq/repoconfig_test.go b/internal/crq/repoconfig_test.go index ac36cd1b..580164f0 100644 --- a/internal/crq/repoconfig_test.go +++ b/internal/crq/repoconfig_test.go @@ -63,3 +63,65 @@ func TestRepoOverrideReachesTheDecision(t *testing.T) { t.Error("one repository's override must not change another's") } } + +// The override must be able to ADD a reviewer, not only subtract one. Resolving +// choices against the fleet's enabled list made "which bots for which project" +// a one-way filter: with CRQ_COBOTS=codex, asking for bugbot was rejected as +// unknown even though it is a registered reviewer. +func TestOverrideCanEnableABotTheFleetDoesNot(t *testing.T) { + fleet := isolatedConfig(t, map[string]string{"CRQ_COBOTS": "codex"}) + if len(fleet.CoBots) != 1 { + t.Fatalf("fleet CoBots = %+v, want only codex", fleet.CoBots) + } + + got := fleet.ForRepo(RepoReviewers{ + CoBots: []string{"bugbot"}, SetCoBots: true, + Required: []string{"bugbot"}, SetRequired: true, + }) + found := false + for _, cb := range got.CoBots { + if cb.Name == "bugbot" { + found = true + if cb.Command == "" { + t.Error("an enabled bot with no trigger command can never be asked to review") + } + } + } + if !found { + t.Fatalf("CoBots = %+v, want bugbot enabled from the registry", got.CoBots) + } + // Required implies enabled, as the fleet parse already does — a bot that + // gates but is never triggered waits forever. + only := fleet.ForRepo(RepoReviewers{ + CoBots: []string{}, SetCoBots: true, + Required: []string{"macroscope"}, SetRequired: true, + }) + if len(only.CoBots) != 1 || only.CoBots[0].Name != "macroscope" { + t.Errorf("CoBots = %+v, want the required bot enabled too", only.CoBots) + } +} + +// A primary that is itself a registry bot keeps its silenced entry through an +// override: that entry is where its wording and check-run hooks come from, so +// dropping it costs the PRIMARY its evidence and the round waits for a bot +// whose clean result crq can no longer read. +func TestOverrideKeepsTheSilencedPrimaryEntry(t *testing.T) { + fleet := isolatedConfig(t, map[string]string{ + // Bugbot's login is cursor[bot]; naming that as the primary is what makes + // the primary a registry bot. + "CRQ_BOT": "cursor[bot]", + "CRQ_COBOTS": "codex,bugbot", + "CRQ_REVIEW_CMD": "bugbot run", + }) + primaryPresent := func(cfg Config, when string) { + t.Helper() + for _, cb := range cfg.CoBots { + if sameBot(cb.Login, cfg.Bot) { + return + } + } + t.Errorf("%s: CoBots = %+v lost the primary's registry entry", when, cfg.CoBots) + } + primaryPresent(fleet, "fleet default") + primaryPresent(fleet.ForRepo(RepoReviewers{CoBots: []string{"codex"}, SetCoBots: true}), "override naming only codex") +} diff --git a/internal/crq/reviewers.go b/internal/crq/reviewers.go index 886bb990..0fe3c9d5 100644 --- a/internal/crq/reviewers.go +++ b/internal/crq/reviewers.go @@ -188,10 +188,44 @@ func (c Config) ForRepo(ov RepoReviewers) Config { } out := c if ov.SetCoBots { - keep := make([]CoBotConfig, 0, len(c.CoBots)) + // Required implies enabled, the same rule the fleet parse follows: a bot + // that gates but is never triggered waits forever. + wanted := append([]string(nil), ov.CoBots...) + if ov.SetRequired { + for _, login := range ov.Required { + if !containsBot(wanted, login) && !sameBot(login, c.Bot) { + wanted = append(wanted, login) + } + } + } + keep := make([]CoBotConfig, 0, len(wanted)+1) + have := map[string]bool{} for _, cb := range c.CoBots { - if containsBot(ov.CoBots, cb.Login) { + // A primary that is itself a registry bot keeps its silenced entry + // whatever the override says: that entry is where its wording and + // check-run hooks come from, and dropping it costs the primary its + // evidence (see LoadConfig). + if sameBot(cb.Login, c.Bot) { keep = append(keep, cb) + have[dialect.NormalizeBotName(cb.Login)] = true + continue + } + if containsBot(wanted, cb.Login) { + keep = append(keep, cb) + have[dialect.NormalizeBotName(cb.Login)] = true + } + } + // A repository may choose a bot the fleet default does not enable — + // otherwise "which bots for which project" only ever subtracts. Its + // configuration comes from the registry, since there is no per-bot + // environment for a repo. + for _, login := range wanted { + if have[dialect.NormalizeBotName(login)] { + continue + } + if co, ok := dialect.CoReviewerByName(login); ok { + keep = append(keep, defaultCoBot(co, containsBot(ov.Required, login))) + have[dialect.NormalizeBotName(login)] = true } } out.CoBots = keep @@ -207,6 +241,11 @@ func (c Config) ForRepo(ov RepoReviewers) Config { return out } +// sameBot reports whether two spellings name the same reviewer. +func sameBot(a, b string) bool { + return dialect.NormalizeBotName(a) == dialect.NormalizeBotName(b) +} + func containsBot(logins []string, login string) bool { key := dialect.NormalizeBotName(login) for _, candidate := range logins { diff --git a/internal/crq/state.go b/internal/crq/state.go index bd82577f..8a19aaca 100644 --- a/internal/crq/state.go +++ b/internal/crq/state.go @@ -35,6 +35,9 @@ const ( PhaseAwaitingRetry = crqstate.PhaseAwaitingRetry PhaseCompleted = crqstate.PhaseCompleted PhaseAbandoned = crqstate.PhaseAbandoned + + // CapsRepoOverrides is the binary capability per-repo reviewer overrides need. + CapsRepoOverrides = crqstate.CapsRepoOverrides ) var ( @@ -51,6 +54,7 @@ func (c Config) storeConfig() StoreConfig { Timezone: c.Timezone, Scope: c.Scope, CoReviewers: c.coReviewerSummary(), + Host: c.Host, MinInterval: c.MinInterval, } } diff --git a/internal/state/state.go b/internal/state/state.go index 56b8a8c5..f188549d 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -285,6 +285,12 @@ type State struct { // one. Persisted in the shared state so the whole fleet uses the new issue. CalibrationIssue int `json:"calibration_issue,omitempty"` + // Writers records which hosts have written this state and what they can do, + // so a feature that only SOME binaries understand can say so instead of + // pretending agreement. Sharing a ref stops an old binary erasing a new + // field; it does not make that binary act on it. + Writers map[string]WriterSeen `json:"writers,omitempty"` + // Repos holds per-repository reviewer overrides, keyed by normalized // "owner/name". // @@ -309,6 +315,67 @@ type State struct { unknown unknownFields } +// WriterCaps is what THIS binary understands. Bump it when a state field starts +// changing decisions, so a fleet running two versions can tell. +const WriterCaps = 1 + +// CapsRepoOverrides is the capability that makes per-repository reviewer +// overrides safe to act on. +const CapsRepoOverrides = 1 + +// writerTTL is how long a host counts as still active for capability purposes. +const writerTTL = 30 * time.Minute + +// WriterSeen is one host's last write. +type WriterSeen struct { + Caps int `json:"caps"` + At time.Time `json:"at"` +} + +// NoteWriter records that host wrote this state with the given capabilities. +func (s *State) NoteWriter(host string, caps int, now time.Time) { + if host == "" { + return + } + if s.Writers == nil { + s.Writers = map[string]WriterSeen{} + } + s.Writers[host] = WriterSeen{Caps: caps, At: now.UTC()} + // Bounded: a host that has not written in a day is not part of the fleet's + // current behaviour, and the state ref is not an audit log. + for name, seen := range s.Writers { + if now.Sub(seen.At) > 24*time.Hour { + delete(s.Writers, name) + } + } +} + +// LaggingWriters names the hosts that are DRIVING this queue — holding the +// leader lease or the fire slot — without having announced the capability +// needed for caps. +// +// It answers the question a shared config field cannot: "will everyone who acts +// on this actually honour it?" An old binary loads an unknown field, writes it +// back untouched, and keeps deciding from its own fleet-wide configuration. +func (s *State) LaggingWriters(caps int, now time.Time) []string { + acting := map[string]bool{} + if s.Leader != nil && s.Leader.Owner != "" && s.Leader.ExpiresAt.After(now) { + acting[s.Leader.Owner] = true + } + if slot := s.SlotRound(); slot != nil && slot.ByHost != "" { + acting[slot.ByHost] = true + } + var out []string + for host := range acting { + if seen, ok := s.Writers[host]; ok && seen.Caps >= caps && now.Sub(seen.At) <= writerTTL { + continue + } + out = append(out, host) + } + sort.Strings(out) + return out +} + // RepoReviewers overrides which reviewers run on one repository. A nil slice // means "no override, use the fleet default"; an empty non-nil slice means // "none here" — the difference is why these are pointers to slices in JSON diff --git a/internal/state/store.go b/internal/state/store.go index 6b6a83a6..bbdbf71e 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -43,6 +43,9 @@ type StoreConfig struct { // bots ("" hides the dashboard row, keeping co-bot-less dashboards // byte-identical). CoReviewers string + // Host names this process in the state it writes, so the fleet can tell which + // binaries are driving and what they understand (see State.NoteWriter). + Host string // MinInterval is DecideFire's pacing gate. The dashboard needs it so a queue // entry is not advertised as ready before firing would actually accept it. MinInterval time.Duration @@ -209,6 +212,7 @@ func (s *GitStateStore) Update(ctx context.Context, mutate func(*State) error) ( now := time.Now().UTC() st.Rev++ st.UpdatedAt = &now + st.NoteWriter(s.cfg.Host, WriterCaps, now) st.Normalize(now) if err := s.compareAndSwap(ctx, &st, rev); err != nil { if errors.Is(err, ErrCASConflict) { diff --git a/internal/state/writers_test.go b/internal/state/writers_test.go new file mode 100644 index 00000000..0e02555d --- /dev/null +++ b/internal/state/writers_test.go @@ -0,0 +1,44 @@ +package state + +import ( + "testing" + "time" +) + +// Sharing a state ref stops an older binary ERASING a new field. It does not +// make that binary act on it: it loads the field as unknown JSON, writes it back +// untouched, and keeps deciding from its own fleet-wide configuration. So the +// fleet has to be able to say who is driving and what they understand. +func TestLaggingWritersNamesWhoWillIgnoreTheOverride(t *testing.T) { + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + st := New() + st.Leader = &LeaderLease{Owner: "old-mac", Token: "t", ExpiresAt: now.Add(time.Minute)} + + // A leader that has never announced a capability is exactly the old binary. + if got := st.LaggingWriters(CapsRepoOverrides, now); len(got) != 1 || got[0] != "old-mac" { + t.Fatalf("lagging = %v, want the leader named", got) + } + + // Once it writes with the capability, it is no longer lagging. + st.NoteWriter("old-mac", CapsRepoOverrides, now) + if got := st.LaggingWriters(CapsRepoOverrides, now); len(got) != 0 { + t.Errorf("lagging = %v, want none once the leader understands", got) + } + + // A stale stamp does not count: the host may have been downgraded since. + if got := st.LaggingWriters(CapsRepoOverrides, now.Add(2*time.Hour)); len(got) != 0 { + // The lease has expired by then, so nobody is acting. + t.Errorf("lagging = %v, want none when no lease is live", got) + } + st.Leader = &LeaderLease{Owner: "old-mac", Token: "t", ExpiresAt: now.Add(2*time.Hour + time.Minute)} + if got := st.LaggingWriters(CapsRepoOverrides, now.Add(2*time.Hour)); len(got) != 1 { + t.Errorf("lagging = %v, want the leader named again once its stamp is stale", got) + } + + // Writers are bounded: a host silent for a day is not part of the fleet. + st.NoteWriter("other", CapsRepoOverrides, now) + st.NoteWriter("fresh", CapsRepoOverrides, now.Add(25*time.Hour)) + if _, ok := st.Writers["other"]; ok { + t.Errorf("writers = %v, want the day-old host pruned", st.Writers) + } +} diff --git a/skills/coderabbit-queue/SKILL.md b/skills/coderabbit-queue/SKILL.md index b4609e1e..321dacae 100644 --- a/skills/coderabbit-queue/SKILL.md +++ b/skills/coderabbit-queue/SKILL.md @@ -183,7 +183,11 @@ Each reviewer reports its `budget`: `account` is serialized against the shared C `none` runs immediately and waits for nobody. That is the only property the queue cares about. The setting lives in the shared state ref, so the daemon and every agent read the same one. The -primary reviewer is fleet-wide and cannot be set per repository. +primary reviewer is fleet-wide and cannot be set per repository. `--required` cannot be empty (a +round gating on nobody converges before anything runs); use `clear` to drop the override. + +If the output lists `lagging_hosts`, those hosts are driving the queue with a binary that predates +per-repo overrides — they will keep using the fleet default until upgraded. ## Fleet Auto-Review From 7fbcd450c0fccd563982cad8dd8fa5d5b7ab59c2 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sun, 26 Jul 2026 19:27:22 +0200 Subject: [PATCH 03/37] Never gate a repository on a reviewer crq will not ask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine review findings, four of them P1, and they converge on one mistake: the override changed who has to answer without changing who gets asked. `--required bugbot` with no `--bots` set the required half only, so nothing enabled Bugbot and it became an unknown reviewer with no command. Required now implies enabled whichever half was named, the rule fleet parsing already follows. A retained co-reviewer that becomes required is promoted out of a never trigger for the same reason — Codex's fleet entry is never while it is optional, so keeping that entry and flipping only the Required bit left the engine waiting for evidence no command was posted for. Only a never trigger is promoted, so a deliberate selfheal survives. Autoreview decided from the primary alone, so a repo requiring Codex whose head CodeRabbit had already reviewed was never enqueued and Codex was never asked. It evaluates the repository's own required set now. And changing the requirements on a head with a completed round stranded the PR outright: convergence reported the new reviewer pending while enqueue kept skipping the head, because a completed round IS the dedup marker. Those rounds are reopened when the required set actually changes. Three more. The two halves are merged inside the CAS closure, or two hosts setting different halves both derive from one snapshot and the later write drops the other's. An explicit CRQ_FEEDBACK_BOTS survives an override, since it is the one list an operator may widen beyond who reviews. And the lagging-host check now matches the leader's "host=x pid=n" form against the host key capabilities are recorded under — otherwise every current-version daemon read as needing an upgrade. The docs no longer call the Codex example Codex-only: an override chooses the co-reviewers, so the primary is still triggered and still spends quota. Leaving it out of --required means the round does not wait for it. --- internal/crq/auto.go | 25 ++++++- internal/crq/config.go | 21 ++++-- internal/crq/repoconfig.go | 94 +++++++++++++++++++----- internal/crq/repoconfig_test.go | 114 +++++++++++++++++++++++++++++ internal/crq/reviewers.go | 118 ++++++++++++++++++++----------- internal/state/state.go | 44 +++++++++++- internal/state/writers_test.go | 14 ++++ llms.txt | 6 +- skills/coderabbit-queue/SKILL.md | 9 ++- 9 files changed, 372 insertions(+), 73 deletions(-) diff --git a/internal/crq/auto.go b/internal/crq/auto.go index cd69e125..c9d56112 100644 --- a/internal/crq/auto.go +++ b/internal/crq/auto.go @@ -305,17 +305,36 @@ func (s *Service) needsReview(ctx context.Context, state State, repo string, pr if err != nil { return false, "", err } - bot := dialect.NormalizeBotName(s.cfg.Bot) + cfg := s.cfgFor(state, repo) + bot := dialect.NormalizeBotName(cfg.Bot) lastBotReview := "" + // Every reviewer this repository gates on, not just the primary. A repo that + // requires Codex or Bugbot and already has a CodeRabbit review at the head + // would otherwise never be enqueued, so the reviewer it chose is never asked. + reviewedHere := map[string]string{} for _, review := range reviews { - if dialect.NormalizeBotName(review.User.Login) == bot && review.CommitID != "" { + login := dialect.NormalizeBotName(review.User.Login) + if review.CommitID == "" { + continue + } + if login == bot { lastBotReview = dialect.ShortOID(review.CommitID) } + reviewedHere[login] = dialect.ShortOID(review.CommitID) } if incremental { need := lastBotReview != head + missing := cfg.Bot + if !need { + for _, login := range cfg.RequiredBots { + if reviewedHere[dialect.NormalizeBotName(login)] != head { + need, missing = true, login + break + } + } + } if need { - s.logEnqueue(repo, pr, head, "no bot review at head") + s.logEnqueue(repo, pr, head, "no "+dialect.NormalizeBotName(missing)+" review at head") } return need, head, nil } diff --git a/internal/crq/config.go b/internal/crq/config.go index e505d709..a96d1f73 100644 --- a/internal/crq/config.go +++ b/internal/crq/config.go @@ -47,11 +47,15 @@ type Config struct { // Reviewers is the single description of who reviews and what they cost. // Bot / RequiredBots / FeedbackBots / CoBots above are DERIVED from it and // kept only so existing consumers keep compiling; new code should read this. - Reviewers []Reviewer - RateLimitCommand string - RateLimitMarker string - CalibrationMarker string - ReviewDoneMarker string + Reviewers []Reviewer + // FeedbackBotsExplicit records that CRQ_FEEDBACK_BOTS was set. It is the one + // list an operator may widen beyond who reviews, so neither LoadConfig's + // derivation nor a per-repo override may quietly replace it. + FeedbackBotsExplicit bool + RateLimitCommand string + RateLimitMarker string + CalibrationMarker string + ReviewDoneMarker string // CompletionMarker identifies the bot's reply to a processed review command // (CodeRabbit: "Review finished."). Feedback uses it to count a command // round that produced no review object toward convergence. @@ -190,7 +194,12 @@ func LoadConfig() (Config, error) { // CRQ_FEEDBACK_BOTS still wins: it is the one list an operator may widen // beyond who reviews (to surface a bot's findings without waiting for it). cfg.RequiredBots = cfg.reviewerLogins(func(r Reviewer) bool { return r.Required }) - if _, explicit := env["CRQ_FEEDBACK_BOTS"]; !explicit { + // Present-but-empty is not a choice: an operator who exports the variable + // blank has named nobody, and treating that as explicit would freeze the + // derived list and, through ForRepo, ignore every per-repo override. + explicitFeedback := strings.TrimSpace(env["CRQ_FEEDBACK_BOTS"]) != "" + cfg.FeedbackBotsExplicit = explicitFeedback + if !explicitFeedback { // Everyone except a primary the operator deliberately left out of // CRQ_REQUIRED_BOTS. That omission is how you say "do not wait for // CodeRabbit here", and surfacing its findings anyway would put the round diff --git a/internal/crq/repoconfig.go b/internal/crq/repoconfig.go index fb17c7eb..c30f214c 100644 --- a/internal/crq/repoconfig.go +++ b/internal/crq/repoconfig.go @@ -6,6 +6,7 @@ import ( "fmt" "sort" "strings" + "time" "github.com/kristofferR/coderabbit-queue/internal/dialect" ) @@ -107,23 +108,17 @@ func (s *Service) SetReviewers(ctx context.Context, repo string, coBots, require return out, nil } - // Start from the existing override so a call that names one half leaves the - // other alone — the contract the nil/empty distinction promises. - ov := RepoReviewers{} - if st, _, err := s.store.Load(ctx); err == nil { - if existing, ok := st.RepoOverride(repo); ok { - ov = existing - } - } else { - return ReviewerView{}, err - } - + // Both halves are resolved here; the MERGE happens inside the CAS closure, + // because two hosts setting different halves would otherwise derive from the + // same snapshot and the later write would drop the earlier one's half — and + // a retry that reuses an already-merged value cannot fix that. + var setCoBots, setRequired []string if coBots != nil { resolved, err := resolve(known, coBots, "--bots") if err != nil { return ReviewerView{}, err } - ov.CoBots, ov.SetCoBots = resolved, true + setCoBots = resolved } if required != nil { if len(required) == 0 { @@ -141,14 +136,22 @@ func (s *Service) SetReviewers(ctx context.Context, repo string, coBots, require if err != nil { return ReviewerView{}, err } - ov.Required, ov.SetRequired = resolved, true + setRequired = resolved } - now := s.clock().UTC() - ov.UpdatedAt = &now - ov.By = s.cfg.Host + now := s.clock().UTC() state, err := s.store.Update(ctx, func(st *State) error { + ov, _ := st.RepoOverride(repo) + if coBots != nil { + ov.CoBots, ov.SetCoBots = setCoBots, true + } + if required != nil { + ov.Required, ov.SetRequired = setRequired, true + } + ov.UpdatedAt, ov.By = &now, s.cfg.Host + before := s.cfg.ForRepo(mustOverride(st, repo)) st.SetRepoOverride(repo, ov) + s.reopenForChangedReviewers(st, repo, before, s.cfg.ForRepo(ov), now) return nil }) if err != nil { @@ -161,10 +164,13 @@ func (s *Service) SetReviewers(ctx context.Context, repo string, coBots, require // ClearReviewers returns repo to the fleet default. func (s *Service) ClearReviewers(ctx context.Context, repo string) (ReviewerView, error) { repo = NormalizeRepo(repo) + now := s.clock().UTC() state, err := s.store.Update(ctx, func(st *State) error { + before := s.cfg.ForRepo(mustOverride(st, repo)) if !st.ClearRepoOverride(repo) { return ErrNoChange } + s.reopenForChangedReviewers(st, repo, before, s.cfg, now) return nil }) if err != nil && !errors.Is(err, ErrNoChange) { @@ -192,3 +198,59 @@ func knownLogins(known map[string]string) []string { sort.Strings(out) return out } + +// mustOverride is the repo's override, or the zero value meaning "fleet +// default" — the same thing ForRepo treats as no override. +func mustOverride(st *State, repo string) RepoReviewers { + ov, _ := st.RepoOverride(repo) + return ov +} + +// reopenForChangedReviewers requeues this repository's completed rounds when the +// set of reviewers that gates them changed. +// +// A completed round is the "this head was reviewed" dedup marker, so adding a +// required reviewer would otherwise strand the PR: Feedback reports the new bot +// pending, while Enqueue keeps skipping the head because the completed round is +// still there. No eligible round exists to trigger it, and `crq next` waits for +// a push that has no reason to come. +// +// Only rounds whose required set actually changed are touched, and only +// completed ones — an in-flight round is already going to answer. +func (s *Service) reopenForChangedReviewers(st *State, repo string, before, after Config, now time.Time) { + if sameLogins(before.RequiredBots, after.RequiredBots) { + return + } + for _, round := range st.Rounds { + if NormalizeRepo(round.Repo) != NormalizeRepo(repo) || round.Phase != PhaseCompleted { + continue + } + reopened := round + if err := reopened.Reopen(now); err != nil { + continue + } + st.PutRound(reopened) + if s.log != nil { + s.log.Printf("reviewers: requeued %s#%d@%s — the required set changed", round.Repo, round.PR, round.Head) + } + } +} + +// sameLogins compares two reviewer lists as sets, since order is presentation. +func sameLogins(a, b []string) bool { + if len(a) != len(b) { + return false + } + seen := map[string]int{} + for _, login := range a { + seen[dialect.NormalizeBotName(login)]++ + } + for _, login := range b { + key := dialect.NormalizeBotName(login) + seen[key]-- + if seen[key] < 0 { + return false + } + } + return true +} diff --git a/internal/crq/repoconfig_test.go b/internal/crq/repoconfig_test.go index 580164f0..a354dd4a 100644 --- a/internal/crq/repoconfig_test.go +++ b/internal/crq/repoconfig_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/engine" ) // The override has to reach a real decision, not just the config value: a @@ -125,3 +126,116 @@ func TestOverrideKeepsTheSilencedPrimaryEntry(t *testing.T) { primaryPresent(fleet, "fleet default") primaryPresent(fleet.ForRepo(RepoReviewers{CoBots: []string{"codex"}, SetCoBots: true}), "override naming only codex") } + +// Four defects that all end the same way: a round waiting on a reviewer crq +// never asks, or never waiting at all. +func TestOverrideNeverGatesOnAReviewerItCannotTrigger(t *testing.T) { + t.Run("required alone enables the bot", func(t *testing.T) { + // `reviewers set repo --required bugbot` with no --bots: SetRequired is + // true and SetCoBots is false, so nothing enabled Bugbot and it became an + // unknown required reviewer with no command. + fleet := isolatedConfig(t, map[string]string{"CRQ_COBOTS": "codex"}) + got := fleet.ForRepo(RepoReviewers{Required: []string{"bugbot"}, SetRequired: true}) + for _, r := range got.Reviewers { + if dialect.NormalizeBotName(r.Login) != "cursor" { + continue + } + if !r.Required { + t.Error("bugbot must gate here") + } + if r.Command == "" || r.Trigger == engine.TriggerNever { + t.Errorf("bugbot = %+v: required but never triggered, so the round waits forever", r) + } + return + } + t.Errorf("reviewers = %+v, want bugbot enabled by requiring it", got.Reviewers) + }) + + t.Run("a promoted co-reviewer gets a trigger", func(t *testing.T) { + // Codex's fleet entry is trigger=never while it is optional. Retaining + // that entry and only flipping Required leaves the engine waiting for + // evidence no command was ever posted for. + fleet := isolatedConfig(t, map[string]string{"CRQ_COBOTS": "codex,bugbot"}) + for _, cb := range fleet.CoBots { + if cb.Name == "codex" && cb.Trigger != engine.TriggerNever { + t.Skip("codex is already triggered by default here; nothing to promote") + } + } + got := fleet.ForRepo(RepoReviewers{ + CoBots: []string{"codex"}, SetCoBots: true, + Required: []string{"codex"}, SetRequired: true, + }) + for _, cb := range got.CoBots { + if cb.Name == "codex" && cb.Trigger == engine.TriggerNever { + t.Errorf("codex = %+v: required with no trigger", cb) + } + } + }) + + t.Run("an explicit feedback set survives", func(t *testing.T) { + // CRQ_FEEDBACK_BOTS is the one list an operator may widen beyond who + // reviews. Deriving over it would silently stop surfacing those findings + // the moment the repository got any override at all. + fleet := isolatedConfig(t, map[string]string{"CRQ_FEEDBACK_BOTS": "sonar[bot]"}) + got := fleet.ForRepo(RepoReviewers{CoBots: []string{"codex"}, SetCoBots: true}) + if len(got.FeedbackBots) != 1 || got.FeedbackBots[0] != "sonar[bot]" { + t.Errorf("FeedbackBots = %v, want the explicit setting kept", got.FeedbackBots) + } + }) +} + +// Changing who has to review a head must not strand the PR. A completed round +// is the "this head was reviewed" dedup marker, so adding a required reviewer +// leaves convergence reporting it pending while enqueue keeps skipping the head: +// no eligible round exists to trigger it, and next waits for a push that has no +// reason to come. +func TestChangingRequirementsReopensACompletedRound(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.CoBots = codexCoBots(nil) + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + repo, pr, head := "o/r", 3, "aaaaaaaa1" + seedRound(t, store, cfg, repo, pr, head, PhaseCompleted, time.Now().UTC(), 11) + + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + round := st.Round(repo, pr) + if round == nil || round.Phase != PhaseQueued { + t.Fatalf("round = %#v, want it requeued so the new reviewer can be asked", round) + } + if round.Head != head { + t.Errorf("head = %q, want the same head %q — what changed is who must answer", round.Head, head) + } + + // An override that does not change the required set leaves rounds alone: a + // finished round must not be reopened for nothing. + if _, err := store.Update(ctx, func(st *State) error { + r := st.Round(repo, pr) + if err := r.Reserve("t", "h", time.Now().UTC()); err != nil { + return err + } + if err := r.Fire(12, time.Now().UTC()); err != nil { + return err + } + if err := r.Complete(); err != nil { + return err + } + st.PutRound(*r) + return nil + }); err != nil { + t.Fatal(err) + } + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}); err != nil { + t.Fatal(err) + } + st, _, _ = store.Load(ctx) + if round := st.Round(repo, pr); round == nil || round.Phase != PhaseCompleted { + t.Errorf("round = %#v, want it left completed when nothing changed", round) + } +} diff --git a/internal/crq/reviewers.go b/internal/crq/reviewers.go index 0fe3c9d5..318df208 100644 --- a/internal/crq/reviewers.go +++ b/internal/crq/reviewers.go @@ -187,60 +187,94 @@ func (c Config) ForRepo(ov RepoReviewers) Config { return c } out := c - if ov.SetCoBots { - // Required implies enabled, the same rule the fleet parse follows: a bot - // that gates but is never triggered waits forever. - wanted := append([]string(nil), ov.CoBots...) - if ov.SetRequired { - for _, login := range ov.Required { - if !containsBot(wanted, login) && !sameBot(login, c.Bot) { - wanted = append(wanted, login) - } - } - } - keep := make([]CoBotConfig, 0, len(wanted)+1) - have := map[string]bool{} + + // The effective required set, whichever half the override named. + required := c.RequiredBots + if ov.SetRequired { + required = ov.Required + } + + // The effective co-reviewer set. Required implies enabled — the rule the + // fleet parse already follows, and it has to hold when only the required + // half is overridden too: a bot that gates but is never triggered makes the + // round wait for evidence crq never asks for. + enabled := ov.CoBots + if !ov.SetCoBots { + enabled = nil for _, cb := range c.CoBots { - // A primary that is itself a registry bot keeps its silenced entry - // whatever the override says: that entry is where its wording and - // check-run hooks come from, and dropping it costs the primary its - // evidence (see LoadConfig). - if sameBot(cb.Login, c.Bot) { - keep = append(keep, cb) - have[dialect.NormalizeBotName(cb.Login)] = true - continue - } - if containsBot(wanted, cb.Login) { - keep = append(keep, cb) - have[dialect.NormalizeBotName(cb.Login)] = true - } + enabled = append(enabled, cb.Login) } - // A repository may choose a bot the fleet default does not enable — - // otherwise "which bots for which project" only ever subtracts. Its - // configuration comes from the registry, since there is no per-bot - // environment for a repo. - for _, login := range wanted { - if have[dialect.NormalizeBotName(login)] { - continue - } - if co, ok := dialect.CoReviewerByName(login); ok { - keep = append(keep, defaultCoBot(co, containsBot(ov.Required, login))) - have[dialect.NormalizeBotName(login)] = true - } + } + for _, login := range required { + if sameBot(login, c.Bot) || containsBot(enabled, login) { + continue + } + if _, ok := dialect.CoReviewerByName(login); ok { + enabled = append(enabled, login) } - out.CoBots = keep } - if ov.SetRequired { - out.RequiredBots = append([]string(nil), ov.Required...) + + keep := make([]CoBotConfig, 0, len(enabled)+1) + have := map[string]bool{} + for _, cb := range c.CoBots { + switch { + case sameBot(cb.Login, c.Bot): + // A primary that is itself a registry bot keeps its silenced entry + // whatever the override says: that entry carries its wording and + // check-run hooks, and dropping it costs the PRIMARY its evidence. + case containsBot(enabled, cb.Login): + cb.Required = containsBot(required, cb.Login) + cb = promoteTrigger(cb) + default: + continue + } + keep = append(keep, cb) + have[dialect.NormalizeBotName(cb.Login)] = true } + // A repository may choose a bot the fleet does not enable — otherwise + // "which bots for which project" only ever subtracts. Its configuration + // comes from the registry, since there is no per-bot environment for a repo. + for _, login := range enabled { + if have[dialect.NormalizeBotName(login)] { + continue + } + if co, ok := dialect.CoReviewerByName(login); ok { + keep = append(keep, defaultCoBot(co, containsBot(required, login))) + have[dialect.NormalizeBotName(login)] = true + } + } + out.CoBots = keep + out.RequiredBots = append([]string(nil), required...) + // Rebuild the derived views from the overridden lists, exactly as // LoadConfig does, so no view can answer differently from another. out.Reviewers = buildReviewers(out.Bot, out.ReviewCommand, out.RequiredBots, out.CoBots) out.RequiredBots = out.reviewerLogins(func(r Reviewer) bool { return r.Required }) - out.FeedbackBots = out.reviewerLogins(func(r Reviewer) bool { return r.Required || !r.Metered() }) + if !c.FeedbackBotsExplicit { + out.FeedbackBots = out.reviewerLogins(func(r Reviewer) bool { return r.Required || !r.Metered() }) + } return out } +// promoteTrigger stops a required co-reviewer from being one crq never asks. +// +// A fleet entry carries the trigger its OWN required-ness produced: Codex +// defaults to never and only becomes always when required. Retaining that entry +// while making the bot required leaves the engine waiting for evidence no +// command was ever posted for. Only a never trigger is promoted, so an operator +// who deliberately configured selfheal keeps it. +func promoteTrigger(cb CoBotConfig) CoBotConfig { + if !cb.Required || cb.Trigger != engine.TriggerNever || cb.Command == "" { + return cb + } + if co, ok := dialect.CoReviewerByName(cb.Name); ok && co.RequiredTrigger != "" { + cb.Trigger = triggerMode(co.RequiredTrigger, engine.TriggerAlways) + return cb + } + cb.Trigger = engine.TriggerAlways + return cb +} + // sameBot reports whether two spellings name the same reviewer. func sameBot(a, b string) bool { return dialect.NormalizeBotName(a) == dialect.NormalizeBotName(b) diff --git a/internal/state/state.go b/internal/state/state.go index f188549d..ac5a8cea 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -359,8 +359,13 @@ func (s *State) NoteWriter(host string, caps int, now time.Time) { // back untouched, and keeps deciding from its own fleet-wide configuration. func (s *State) LaggingWriters(caps int, now time.Time) []string { acting := map[string]bool{} - if s.Leader != nil && s.Leader.Owner != "" && s.Leader.ExpiresAt.After(now) { - acting[s.Leader.Owner] = true + // The leader records itself as "host= pid=", while capabilities are + // keyed by host alone. Comparing the two directly would report every + // current-version daemon as lagging. + if s.Leader != nil && s.Leader.ExpiresAt.After(now) { + if host := ownerHost(s.Leader.Owner); host != "" { + acting[host] = true + } } if slot := s.SlotRound(); slot != nil && slot.ByHost != "" { acting[slot.ByHost] = true @@ -376,6 +381,17 @@ func (s *State) LaggingWriters(caps int, now time.Time) []string { return out } +// ownerHost extracts the host from a leader owner string ("host=x pid=1"), +// falling back to the whole value for an owner that is just a host. +func ownerHost(owner string) string { + for _, field := range strings.Fields(owner) { + if rest, ok := strings.CutPrefix(field, "host="); ok { + return rest + } + } + return strings.TrimSpace(owner) +} + // RepoReviewers overrides which reviewers run on one repository. A nil slice // means "no override, use the fleet default"; an empty non-nil slice means // "none here" — the difference is why these are pointers to slices in JSON @@ -495,6 +511,30 @@ func (r *Round) ReleaseToQueue(reason string, now time.Time) error { return nil } +// Reopen puts a completed round back in the queue because the set of reviewers +// that has to answer for its head changed. +// +// A completed round is the "this head was reviewed" dedup marker, so a newly +// required reviewer would otherwise strand the PR: convergence reports it +// pending while enqueue keeps skipping the head, and no eligible round exists to +// trigger it. This is the one transition that reopens a finished round, and it +// keeps the head, the attempts and the co-reviewer bookkeeping — what changed is +// who still has to answer, not what happened. +func (r *Round) Reopen(now time.Time) error { + if r.Phase != PhaseCompleted { + return r.illegal(PhaseQueued) + } + r.Phase = PhaseQueued + r.Token = "" + r.ReservedAt = nil + r.WaitDeadline = nil + r.RetryAt = nil + t := now.UTC() + r.LastAttemptAt = &t + r.Note = "reviewer requirements changed" + return nil +} + // Acknowledge records that the bot has seen the fired command (reaction, // in-progress summary, or other non-terminal reply): fired → reviewing. The // fire slot may be released; the round itself stays open until Complete. diff --git a/internal/state/writers_test.go b/internal/state/writers_test.go index 0e02555d..f805f1d4 100644 --- a/internal/state/writers_test.go +++ b/internal/state/writers_test.go @@ -42,3 +42,17 @@ func TestLaggingWritersNamesWhoWillIgnoreTheOverride(t *testing.T) { t.Errorf("writers = %v, want the day-old host pruned", st.Writers) } } + +// The leader records itself as "host= pid=" while capabilities are +// keyed by host alone. Comparing those directly reported every current-version +// daemon as needing an upgrade — the warning would have been pure noise. +func TestLaggingWritersMatchesTheLeadersHostForm(t *testing.T) { + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + st := New() + st.Leader = &LeaderLease{Owner: "host=cachyos pid=1234", ExpiresAt: now.Add(time.Minute)} + st.NoteWriter("cachyos", CapsRepoOverrides, now) + + if got := st.LaggingWriters(CapsRepoOverrides, now); len(got) != 0 { + t.Errorf("lagging = %v, want none — the leader IS the capable writer", got) + } +} diff --git a/llms.txt b/llms.txt index 0264233e..1a62f1ab 100644 --- a/llms.txt +++ b/llms.txt @@ -206,10 +206,14 @@ Choose which bots review one project: ```bash crq reviewers REPO # effective reviewers + budgets -crq reviewers set REPO --bots codex --required codex # only Codex here +crq reviewers set REPO --bots codex --required codex # Codex is the only required co-reviewer crq reviewers clear REPO # fleet default again ``` +The primary is fleet-wide: an override chooses the CO-reviewers, so CodeRabbit is still triggered +and still spends account quota even when it is left out of `--required`. Leaving it out means the +round does not WAIT for it, not that it is never asked. + `budget: account` means the reviewer is serialized against the shared CodeRabbit allowance; `budget: none` means it runs immediately. Stored in the shared state ref, so the daemon and agents cannot disagree. The primary reviewer is fleet-wide. diff --git a/skills/coderabbit-queue/SKILL.md b/skills/coderabbit-queue/SKILL.md index 321dacae..129e2452 100644 --- a/skills/coderabbit-queue/SKILL.md +++ b/skills/coderabbit-queue/SKILL.md @@ -175,15 +175,18 @@ finding. Pass `--keep-open` to leave it unresolved deliberately. ```bash crq reviewers "$REPO" # who runs here, and what each costs -crq reviewers set "$REPO" --bots codex --required codex # only Codex here +crq reviewers set "$REPO" --bots codex --required codex # Codex the only required co-reviewer crq reviewers clear "$REPO" # back to the fleet default ``` Each reviewer reports its `budget`: `account` is serialized against the shared CodeRabbit allowance, `none` runs immediately and waits for nobody. That is the only property the queue cares about. -The setting lives in the shared state ref, so the daemon and every agent read the same one. The -primary reviewer is fleet-wide and cannot be set per repository. `--required` cannot be empty (a +The setting lives in the shared state ref, so the daemon and every agent read the same one. + +The primary reviewer is fleet-wide: an override chooses the **co-reviewers**. Leaving the primary out +of `--required` means the round does not wait for it — not that it is never triggered, so it still +spends account quota. `--required` cannot be empty (a round gating on nobody converges before anything runs); use `clear` to drop the override. If the output lists `lagging_hosts`, those hosts are driving the queue with a binary that predates From c3cba2244e2308201eb8c7bfc4c89ec42362ffce Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sun, 26 Jul 2026 19:46:02 +0200 Subject: [PATCH 04/37] Refuse a reviewers command that looks like it worked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of four findings; the fourth is answered rather than fixed. `crq reviewers owner/repo --bots codex` is a set command missing its verb. It showed the configuration and exited 0, which tells automation the mutation worked when nothing changed. It is refused now, naming the verb. `crq reviewers clear owner-repo` cleared a key nothing uses and exited 0, so a typo left the real override in force while the caller believed it had restored the fleet default. Clear validates the shape set already validates. On the fourth: reopening a completed round can re-ask a primary that finished with a completion reply and no Review object, because DecideFire's already-reviewed gate reads submitted reviews. Marking the round co-only does not help — that flag records what a fire DID, it does not gate one — and the alternatives all need an observation the CAS closure cannot make. So the trade is stated in the code: one duplicate review in an uncommon case, against a PR stranded silently and permanently. The loud failure is the better one, and the gate is where a real fix belongs. --- cmd/crq/main.go | 7 +++++++ internal/crq/repoconfig.go | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/cmd/crq/main.go b/cmd/crq/main.go index 6fe5c699..1bc64fdc 100644 --- a/cmd/crq/main.go +++ b/cmd/crq/main.go @@ -794,6 +794,13 @@ func runReviewers(ctx context.Context, service *crq.Service, args []string) int } view, err = service.SetReviewers(ctx, repo, splitList(bots), splitList(required)) default: + // `crq reviewers owner/repo --bots codex` is a set command missing its + // verb. Showing the configuration and exiting 0 tells automation the + // mutation worked when nothing changed. + if bots != nil || required != nil { + fatal(errors.New("did you mean `crq reviewers set`? --bots/--required only apply to set")) + return 1 + } view, err = service.Reviewers(ctx, repo) } if err != nil { diff --git a/internal/crq/repoconfig.go b/internal/crq/repoconfig.go index c30f214c..5fb9af8d 100644 --- a/internal/crq/repoconfig.go +++ b/internal/crq/repoconfig.go @@ -164,6 +164,12 @@ func (s *Service) SetReviewers(ctx context.Context, repo string, coBots, require // ClearReviewers returns repo to the fleet default. func (s *Service) ClearReviewers(ctx context.Context, repo string) (ReviewerView, error) { repo = NormalizeRepo(repo) + // The same shape check set does. A typo like "owner-repo" would otherwise + // clear a key nothing uses and exit 0, so automation believes it restored the + // fleet default while the real override is still in force. + if !strings.Contains(repo, "/") { + return ReviewerView{}, fmt.Errorf("repo must be owner/name, got %q", repo) + } now := s.clock().UTC() state, err := s.store.Update(ctx, func(st *State) error { before := s.cfg.ForRepo(mustOverride(st, repo)) @@ -217,6 +223,12 @@ func mustOverride(st *State, repo string) RepoReviewers { // // Only rounds whose required set actually changed are touched, and only // completed ones — an in-flight round is already going to answer. +// +// Known cost: DecideFire's already-reviewed gate reads submitted reviews, so a +// round the primary finished with a completion REPLY and no Review object can be +// asked once more. That is one duplicate review in an uncommon case, against a +// PR stranded silently and permanently — the loud failure is the better one, and +// the gate is where a real fix belongs. func (s *Service) reopenForChangedReviewers(st *State, repo string, before, after Config, now time.Time) { if sameLogins(before.RequiredBots, after.RequiredBots) { return From 37d0bc2dcb5a227662050e2b84aa8814424786b5 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sun, 26 Jul 2026 21:37:36 +0200 Subject: [PATCH 05/37] Recognise a completion reply as a reviewed head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four P1s, and the first retires a trade-off I had argued for instead of fixing. Reopening a completed round could re-ask a primary that answered with a completion reply and no Review object, because the already-reviewed gate read only obs.Reviews. The reviewer's point was that the gate is where the fix belongs, and they were right: it now also counts a completion reply paired to that round's own command. Reopening no longer risks a duplicate review, so the round can be requeued when the required set changes without the cost I had written into a comment. A repository enabling a bot the fleet disabled was built from registry defaults, silently discarding the operator's CRQ_COBOT__CMD, TRIGGER and GRACE — a different bot than the one configured. Every registry co-reviewer is now resolved with the environment applied, enabled or not, and an override picks from that. Capabilities are keyed per PROCESS rather than per host. A new CLI and an old daemon on one machine is the ordinary upgrade, and a per-host key let the CLI's write vouch for the daemon that had not been upgraded — hiding exactly the case the signal exists to catch. And applyFire revalidates the override before acting. Deciding and writing are two steps, so an operator removing a co-reviewer between them would otherwise have crq post that bot's trigger on the authority of a configuration that no longer exists. --- internal/crq/config.go | 87 +++++++++++++++++++++++----------- internal/crq/repoconfig.go | 9 ++-- internal/crq/reviewers.go | 45 ++++++++++++++++-- internal/crq/service.go | 7 +++ internal/crq/state.go | 2 +- internal/engine/fire.go | 9 ++++ internal/state/state.go | 27 ++++------- internal/state/writers_test.go | 20 +++++--- 8 files changed, 145 insertions(+), 61 deletions(-) diff --git a/internal/crq/config.go b/internal/crq/config.go index a96d1f73..9be51c11 100644 --- a/internal/crq/config.go +++ b/internal/crq/config.go @@ -44,6 +44,11 @@ type Config struct { // CRQ_COBOT__* keys). An entry exists for every wanted or required // co-reviewer; required ones are already folded into RequiredBots. CoBots []CoBotConfig + // KnownCoBots is every registry co-reviewer with the operator's per-bot + // environment applied, enabled here or not. A repository override picks from + // this, so enabling a bot the fleet disabled still honours CRQ_COBOT__* + // instead of silently falling back to registry defaults. + KnownCoBots []CoBotConfig // Reviewers is the single description of who reviews and what they cost. // Bot / RequiredBots / FeedbackBots / CoBots above are DERIVED from it and // kept only so existing consumers keep compiling; new code should read this. @@ -52,10 +57,13 @@ type Config struct { // list an operator may widen beyond who reviews, so neither LoadConfig's // derivation nor a per-repo override may quietly replace it. FeedbackBotsExplicit bool - RateLimitCommand string - RateLimitMarker string - CalibrationMarker string - ReviewDoneMarker string + // OverrideAt is when the per-repo reviewer override this configuration was + // built from was last written, so a fire can tell whether it still holds. + OverrideAt *time.Time + RateLimitCommand string + RateLimitMarker string + CalibrationMarker string + ReviewDoneMarker string // CompletionMarker identifies the bot's reply to a processed review command // (CodeRabbit: "Review finished."). Feedback uses it to count a command // round that produced no review object toward convergence. @@ -188,6 +196,7 @@ func LoadConfig() (Config, error) { } // Built here, after the command is resolved, because the primary's trigger is // part of describing it. + cfg.KnownCoBots = parseAllCoBots(env) cfg.Reviewers = buildReviewers(cfg.Bot, cfg.ReviewCommand, requiredBots, coBots) // The legacy lists are now VIEWS of cfg.Reviewers rather than parallel // parses, so they cannot answer differently from it. An explicit @@ -335,6 +344,44 @@ type CoBotConfig struct { // legacy alias of CRQ_COBOT_CODEX_CMD. Bugbot/Macroscope default to // `selfheal` — they auto-review pushes, so crq only nudges one that went // silent on a head it should have covered. +// resolveCoBot applies the operator's per-bot environment to a registry entry. +// Uniform across bots: the per-bot key wins, then the registry's legacy alias if +// it declares one, then its default command. No bot is named here — the policy +// travels as registry metadata. +func resolveCoBot(env map[string]string, co dialect.CoReviewer, required bool) CoBotConfig { + key := strings.ToUpper(co.Name) + base := defaultCoBot(co, required) + command := base.Command + if v, ok := env["CRQ_COBOT_"+key+"_CMD"]; ok { + command = v + } else if co.LegacyCommandEnv != "" { + command = stringEnvAllowEmpty(env, co.LegacyCommandEnv, command) + } + command = strings.TrimSpace(command) + trigger := base.Trigger + switch v := engine.TriggerMode(strings.ToLower(strings.TrimSpace(env["CRQ_COBOT_"+key+"_TRIGGER"]))); v { + case engine.TriggerNever, engine.TriggerSelfHeal, engine.TriggerAlways: + trigger = v + } + if command == "" { + // No trigger command means crq can never post one, whatever the mode. + trigger = engine.TriggerNever + } + base.Command, base.Trigger = command, trigger + base.SelfHealGrace = durationEnv(env, "CRQ_COBOT_"+key+"_GRACE", defaultSelfHealGrace) + return base +} + +// parseAllCoBots resolves every registry co-reviewer with the environment +// applied, regardless of whether CRQ_COBOTS enables it. +func parseAllCoBots(env map[string]string) []CoBotConfig { + out := make([]CoBotConfig, 0, 4) + for _, co := range dialect.KnownCoReviewers() { + out = append(out, resolveCoBot(env, co, false)) + } + return out +} + func parseCoBots(env map[string]string, requiredBots []string) ([]CoBotConfig, error) { enabled := map[string]bool{} var unknown []string @@ -368,29 +415,7 @@ func parseCoBots(env map[string]string, requiredBots []string) ([]CoBotConfig, e if !enabled[co.Name] && !required { continue } - base := defaultCoBot(co, required) - // Uniform across bots: the per-bot key wins, then the registry's legacy - // alias if it declares one, then its default command. No bot is named - // here — the policy travels as registry metadata. - command := base.Command - if v, ok := env["CRQ_COBOT_"+key+"_CMD"]; ok { - command = v - } else if co.LegacyCommandEnv != "" { - command = stringEnvAllowEmpty(env, co.LegacyCommandEnv, command) - } - command = strings.TrimSpace(command) - trigger := base.Trigger - switch v := engine.TriggerMode(strings.ToLower(strings.TrimSpace(env["CRQ_COBOT_"+key+"_TRIGGER"]))); v { - case engine.TriggerNever, engine.TriggerSelfHeal, engine.TriggerAlways: - trigger = v - } - if command == "" { - // No trigger command means crq can never post one, whatever the mode. - trigger = engine.TriggerNever - } - base.Command, base.Trigger = command, trigger - base.SelfHealGrace = durationEnv(env, "CRQ_COBOT_"+key+"_GRACE", defaultSelfHealGrace) - out = append(out, base) + out = append(out, resolveCoBot(env, co, required)) } return out, nil } @@ -507,3 +532,11 @@ func ownerOf(repo string) string { owner, _, _ := strings.Cut(repo, "/") return owner } + +// WriterID identifies this PROCESS in the shared state, in the same form the +// autoreview leader records. A new CLI and an old daemon commonly run on one +// machine, so a per-host key would let the upgraded CLI's write vouch for the +// daemon that has not been upgraded. +func (c Config) WriterID() string { + return fmt.Sprintf("host=%s pid=%d", c.Host, os.Getpid()) +} diff --git a/internal/crq/repoconfig.go b/internal/crq/repoconfig.go index 5fb9af8d..49c9ac48 100644 --- a/internal/crq/repoconfig.go +++ b/internal/crq/repoconfig.go @@ -224,11 +224,10 @@ func mustOverride(st *State, repo string) RepoReviewers { // Only rounds whose required set actually changed are touched, and only // completed ones — an in-flight round is already going to answer. // -// Known cost: DecideFire's already-reviewed gate reads submitted reviews, so a -// round the primary finished with a completion REPLY and no Review object can be -// asked once more. That is one duplicate review in an uncommon case, against a -// PR stranded silently and permanently — the loud failure is the better one, and -// the gate is where a real fix belongs. +// The primary is not re-asked: DecideFire's already-reviewed gate now counts a +// completion reply paired to the round's command, not only a submitted Review +// object, so a reopened round that the primary already answered dedupes instead +// of buying a second review. func (s *Service) reopenForChangedReviewers(st *State, repo string, before, after Config, now time.Time) { if sameLogins(before.RequiredBots, after.RequiredBots) { return diff --git a/internal/crq/reviewers.go b/internal/crq/reviewers.go index 318df208..195ca485 100644 --- a/internal/crq/reviewers.go +++ b/internal/crq/reviewers.go @@ -238,8 +238,13 @@ func (c Config) ForRepo(ov RepoReviewers) Config { if have[dialect.NormalizeBotName(login)] { continue } - if co, ok := dialect.CoReviewerByName(login); ok { - keep = append(keep, defaultCoBot(co, containsBot(required, login))) + // From the operator's resolved settings, not registry defaults: a bot the + // fleet disabled still has CRQ_COBOT__CMD/TRIGGER/GRACE, and + // ignoring them silently gives this repository a different bot than the + // one that was configured. + if cb, ok := c.knownCoBot(login); ok { + cb.Required = containsBot(required, login) + keep = append(keep, promoteTrigger(cb)) have[dialect.NormalizeBotName(login)] = true } } @@ -297,5 +302,39 @@ func (s *Service) cfgFor(st State, repo string) Config { if !ok { return s.cfg } - return s.cfg.ForRepo(ov) + out := s.cfg.ForRepo(ov) + out.OverrideAt = ov.UpdatedAt + return out +} + +// overrideChanged reports whether repo's reviewer override differs from the one +// cfg was built from. +// +// Deciding and writing are two steps. An operator removing a co-reviewer between +// them would otherwise have crq claim and post that bot's trigger anyway, on the +// authority of a configuration that no longer exists. +func overrideChanged(st *State, repo string, cfg Config) bool { + ov, _ := st.RepoOverride(repo) + switch { + case ov.UpdatedAt == nil && cfg.OverrideAt == nil: + return false + case ov.UpdatedAt == nil || cfg.OverrideAt == nil: + return true + default: + return !ov.UpdatedAt.Equal(*cfg.OverrideAt) + } +} + +// knownCoBot is a registry co-reviewer with the operator's environment applied, +// enabled fleet-wide or not. +func (c Config) knownCoBot(login string) (CoBotConfig, bool) { + for _, cb := range c.KnownCoBots { + if sameBot(cb.Login, login) || strings.EqualFold(cb.Name, strings.TrimSpace(login)) { + return cb, true + } + } + if co, ok := dialect.CoReviewerByName(login); ok { + return defaultCoBot(co, false), true + } + return CoBotConfig{}, false } diff --git a/internal/crq/service.go b/internal/crq/service.go index a8e4028e..a6f1f5be 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -668,6 +668,13 @@ func firedOrEnqueuedAt(r Round) time.Time { // applyFire executes a DecideFire verdict. func (s *Service) applyFire(ctx context.Context, cfg Config, round Round, obs engine.Observation, d engine.FireDecision, now time.Time) (PumpResult, error) { + // The decision was made from a configuration that may have been replaced + // since. Acting on it would post a trigger for a co-reviewer an operator has + // just removed, or skip one they have just required. + if st, _, err := s.store.Load(ctx); err == nil && overrideChanged(&st, round.Repo, cfg) { + return PumpResult{Action: "deduped", Repo: round.Repo, PR: round.PR, Head: round.Head, + Reason: "reviewer configuration changed while deciding"}, nil + } switch d.Verdict { case engine.FireDrop: return s.abandonRound(ctx, round, "pr closed", "skipped") diff --git a/internal/crq/state.go b/internal/crq/state.go index 8a19aaca..cd422b10 100644 --- a/internal/crq/state.go +++ b/internal/crq/state.go @@ -54,7 +54,7 @@ func (c Config) storeConfig() StoreConfig { Timezone: c.Timezone, Scope: c.Scope, CoReviewers: c.coReviewerSummary(), - Host: c.Host, + Host: c.WriterID(), MinInterval: c.MinInterval, } } diff --git a/internal/engine/fire.go b/internal/engine/fire.go index 7b4c9f59..638961e3 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -157,6 +157,15 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic break } } + // A submitted Review is not the only way the primary finishes. It also + // answers a command with a completion reply and no Review object, and + // reading only obs.Reviews there means crq posts the command again and buys + // a second review of code it has already been told about. The reply is + // paired to THIS round's command, so it only counts while the round still + // tracks the head. + if !reviewedHead && r.Head == obs.Head && r.CommandID != 0 { + reviewedHead = CommandHasCompletionReply(obs, p, r.CommandID) + } // Belt-and-braces live check: even with a fresh round, never fire at a // head the bot has already reviewed (e.g. state was reinitialized). But a // CodeRabbit review does not finish a round that a gating co-reviewer still diff --git a/internal/state/state.go b/internal/state/state.go index ac5a8cea..5d0ff339 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -332,7 +332,10 @@ type WriterSeen struct { At time.Time `json:"at"` } -// NoteWriter records that host wrote this state with the given capabilities. +// NoteWriter records that a process wrote this state with the given +// capabilities. The key identifies the PROCESS, not the machine: a new CLI and +// an old daemon on one host is the ordinary upgrade, and keying by hostname +// would let the CLI's write vouch for the daemon that has not been upgraded. func (s *State) NoteWriter(host string, caps int, now time.Time) { if host == "" { return @@ -359,13 +362,10 @@ func (s *State) NoteWriter(host string, caps int, now time.Time) { // back untouched, and keeps deciding from its own fleet-wide configuration. func (s *State) LaggingWriters(caps int, now time.Time) []string { acting := map[string]bool{} - // The leader records itself as "host= pid=", while capabilities are - // keyed by host alone. Comparing the two directly would report every - // current-version daemon as lagging. - if s.Leader != nil && s.Leader.ExpiresAt.After(now) { - if host := ownerHost(s.Leader.Owner); host != "" { - acting[host] = true - } + // The leader identifies itself as "host= pid=", which is exactly the + // process identity capabilities are recorded under. + if s.Leader != nil && s.Leader.ExpiresAt.After(now) && strings.TrimSpace(s.Leader.Owner) != "" { + acting[s.Leader.Owner] = true } if slot := s.SlotRound(); slot != nil && slot.ByHost != "" { acting[slot.ByHost] = true @@ -381,17 +381,6 @@ func (s *State) LaggingWriters(caps int, now time.Time) []string { return out } -// ownerHost extracts the host from a leader owner string ("host=x pid=1"), -// falling back to the whole value for an owner that is just a host. -func ownerHost(owner string) string { - for _, field := range strings.Fields(owner) { - if rest, ok := strings.CutPrefix(field, "host="); ok { - return rest - } - } - return strings.TrimSpace(owner) -} - // RepoReviewers overrides which reviewers run on one repository. A nil slice // means "no override, use the fleet default"; an empty non-nil slice means // "none here" — the difference is why these are pointers to slices in JSON diff --git a/internal/state/writers_test.go b/internal/state/writers_test.go index f805f1d4..bc93c18c 100644 --- a/internal/state/writers_test.go +++ b/internal/state/writers_test.go @@ -12,15 +12,15 @@ import ( func TestLaggingWritersNamesWhoWillIgnoreTheOverride(t *testing.T) { now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) st := New() - st.Leader = &LeaderLease{Owner: "old-mac", Token: "t", ExpiresAt: now.Add(time.Minute)} + st.Leader = &LeaderLease{Owner: "host=old-mac pid=7", Token: "t", ExpiresAt: now.Add(time.Minute)} // A leader that has never announced a capability is exactly the old binary. - if got := st.LaggingWriters(CapsRepoOverrides, now); len(got) != 1 || got[0] != "old-mac" { + if got := st.LaggingWriters(CapsRepoOverrides, now); len(got) != 1 || got[0] != "host=old-mac pid=7" { t.Fatalf("lagging = %v, want the leader named", got) } // Once it writes with the capability, it is no longer lagging. - st.NoteWriter("old-mac", CapsRepoOverrides, now) + st.NoteWriter("host=old-mac pid=7", CapsRepoOverrides, now) if got := st.LaggingWriters(CapsRepoOverrides, now); len(got) != 0 { t.Errorf("lagging = %v, want none once the leader understands", got) } @@ -30,7 +30,7 @@ func TestLaggingWritersNamesWhoWillIgnoreTheOverride(t *testing.T) { // The lease has expired by then, so nobody is acting. t.Errorf("lagging = %v, want none when no lease is live", got) } - st.Leader = &LeaderLease{Owner: "old-mac", Token: "t", ExpiresAt: now.Add(2*time.Hour + time.Minute)} + st.Leader = &LeaderLease{Owner: "host=old-mac pid=7", Token: "t", ExpiresAt: now.Add(2*time.Hour + time.Minute)} if got := st.LaggingWriters(CapsRepoOverrides, now.Add(2*time.Hour)); len(got) != 1 { t.Errorf("lagging = %v, want the leader named again once its stamp is stale", got) } @@ -46,13 +46,21 @@ func TestLaggingWritersNamesWhoWillIgnoreTheOverride(t *testing.T) { // The leader records itself as "host= pid=" while capabilities are // keyed by host alone. Comparing those directly reported every current-version // daemon as needing an upgrade — the warning would have been pure noise. -func TestLaggingWritersMatchesTheLeadersHostForm(t *testing.T) { +func TestLaggingWritersMatchesTheLeadersProcessIdentity(t *testing.T) { now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) st := New() st.Leader = &LeaderLease{Owner: "host=cachyos pid=1234", ExpiresAt: now.Add(time.Minute)} - st.NoteWriter("cachyos", CapsRepoOverrides, now) + st.NoteWriter("host=cachyos pid=1234", CapsRepoOverrides, now) if got := st.LaggingWriters(CapsRepoOverrides, now); len(got) != 0 { t.Errorf("lagging = %v, want none — the leader IS the capable writer", got) } + + // A new CLI on the same machine must not vouch for an old daemon: that is + // the ordinary upgrade, and per-host keying would hide exactly it. + st.NoteWriter("host=cachyos pid=9999", CapsRepoOverrides, now) + st.Leader = &LeaderLease{Owner: "host=cachyos pid=1", ExpiresAt: now.Add(time.Minute)} + if got := st.LaggingWriters(CapsRepoOverrides, now); len(got) != 1 { + t.Errorf("lagging = %v, want the un-upgraded daemon named", got) + } } From e09bfae29f6f44bc0e4f7741fd03db3cddd983b7 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Mon, 27 Jul 2026 03:10:26 +0200 Subject: [PATCH 06/37] Requeue only the pull requests a reviewer change can strand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten findings across both reviewers; two are answered rather than fixed. Changing a repository's required set requeued every completed round it had. Rounds are never deleted, so a repository's merged and closed PRs stay behind as completed dedup markers: on a busy repo that hands Pump hundreds of dead rounds to observe and drop, one per tick, ahead of every real one. A stranded PR is by definition an open one, so the requeue asks GitHub which PRs are open first and touches only those. Reopening also stamped LastAttemptAt, which is the adoption floor for a FAILED attempt. Raising it discarded a newly required co-reviewer's own unanswered trigger as too old to adopt, so crq posted that bot a second request for the very round the reopen exists to let it answer. The reopened round's already-reviewed gate read the ADOPTION helper, which deliberately omits Completion's prior-review requirement and its failed-summary guard — it answers "was this command spoken for", a weaker question. A completion reply nothing backs would have written a completed marker that every later same-head check skips, forever. Capabilities are keyed per process, but a round recorded its bare hostname, which no writer entry can match. Every `crq reviewers` call during a fire therefore named the current process in lagging_hosts and told the operator to upgrade a binary that already understands overrides. The round now records the writer id; the dashboard still prints the machine name. applyFire re-read the state on EVERY verdict to revalidate the override, including the FireNo no-op that is most of what a pump does — an extra four API calls per tick against the git-backed store. Only the verdicts that post a configuration-chosen command pay for it now. Setting reviewers with neither half named wrote a fresh UpdatedAt anyway, and that timestamp IS the override's identity, so a no-op call discarded every in-flight fire decision for the repository. Reading a repository now applies the shape check set and clear apply, so `crq reviewers show` fails instead of reporting the fleet default for a repository nobody has. The help conflated budget with requiredness ("none ... waits for nobody"), required --bots for an update the handler accepts with --required alone, and offered an empty value for a flag that refuses one. Not fixed: feedback.go reading isConfiguredBot from the fleet config rather than the repo's is identical by construction — the primary is not overridable, so ForRepo never touches Config.Bot. And SetReviewers' store.Update is an operator's explicit mutation, not the apply phase of a review decision: Enqueue and Cancel write outside it too, and DryRun means "request no reviews", not "silently ignore a configuration command". --- README.md | 2 +- cmd/crq/main.go | 15 +++--- internal/crq/repoconfig.go | 88 ++++++++++++++++++++++++++------ internal/crq/repoconfig_test.go | 36 ++++++++++++- internal/crq/service.go | 32 +++++++++--- internal/crq/service_test.go | 6 +++ internal/engine/completion.go | 18 +++++++ internal/engine/engine_test.go | 66 ++++++++++++++++++++++++ internal/engine/fire.go | 7 +-- internal/state/dashboard.go | 19 +++++-- internal/state/state.go | 22 +++++--- internal/state/writers_test.go | 50 ++++++++++++++++++ skills/coderabbit-queue/SKILL.md | 4 +- 13 files changed, 319 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 5f3c96bd..c9acec41 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ crq feedback # current normalized findings as JSON, WITHOUT trigger crq resolve [...] # resolve addressed review threads crq decline [...] --reason "" [--resolve] # record why a finding is declined crq reviewers # which bots review this project, and what each costs -crq reviewers set --bots [--required ] # choose them +crq reviewers set [--bots ] [--required ] # choose them (either flag alone) crq reviewers clear # back to the fleet default crq autoreview # ⭐ review ALL open PRs automatically, rate-coordinated # (--no-incremental = first review only; --once = single pass for cron) diff --git a/cmd/crq/main.go b/cmd/crq/main.go index 1bc64fdc..c9a0b8c3 100644 --- a/cmd/crq/main.go +++ b/cmd/crq/main.go @@ -363,8 +363,8 @@ USAGE reply on a thread to record why a finding is declined (resolves it; --keep-open leaves it open) crq reviewers which bots review this project (and what each costs) - crq reviewers set --bots [--required ] - choose this project's reviewers + crq reviewers set [--bots ] [--required ] + choose this project's reviewers (either flag alone) crq reviewers clear go back to the fleet default crq autoreview [--once] [--no-incremental] keep open PRs reviewed, rate-coordinated @@ -522,7 +522,7 @@ intend to keep working). Thread IDs come from .findings[].thread_id. `) case "reviewers": fmt.Print(`crq reviewers -crq reviewers set --bots [--required ] +crq reviewers set [--bots ] [--required ] crq reviewers clear Which bots review one project, and what each of them costs. @@ -530,11 +530,14 @@ Which bots review one project, and what each of them costs. Without a subcommand it reports the reviewers that will actually run there — the fleet default, or this repository's own choice if it has one. Each entry carries its budget: "account" is serialized against the shared CodeRabbit allowance, -"none" runs immediately and waits for nobody. +"none" runs immediately, outside that queue. Budget is not requiredness: whether +a round WAITS for a reviewer is --required, whatever the reviewer costs. set --bots chooses the co-reviewers; --required chooses which reviewers - gate convergence. An empty value means none here, which is a different - answer from not setting it at all. + gate convergence. Either flag alone updates only its own half. An + empty --bots means none here, which is a different answer from not + setting it at all; an empty --required is refused, because a round + that gates on nobody converges before any reviewer runs. clear drops the override so the repository follows the fleet again. The configuration lives in the shared state ref, not in a file the repository diff --git a/internal/crq/repoconfig.go b/internal/crq/repoconfig.go index 49c9ac48..f3016d6a 100644 --- a/internal/crq/repoconfig.go +++ b/internal/crq/repoconfig.go @@ -6,9 +6,9 @@ import ( "fmt" "sort" "strings" - "time" "github.com/kristofferR/coderabbit-queue/internal/dialect" + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) // ReviewerView is how one repository's reviewers read after its override is @@ -40,11 +40,17 @@ type ReviewerDetail struct { // Reviewers reports the reviewers that will run on repo. func (s *Service) Reviewers(ctx context.Context, repo string) (ReviewerView, error) { + repo = NormalizeRepo(repo) + // The same shape check set and clear do. A malformed target reads no + // override, so reporting it would answer with the fleet default and exit 0 — + // telling the caller its typo is a repository crq is following. + if err := checkRepoShape(repo); err != nil { + return ReviewerView{}, err + } st, _, err := s.store.Load(ctx) if err != nil { return ReviewerView{}, err } - repo = NormalizeRepo(repo) cfg := s.cfgFor(st, repo) view := ReviewerView{Repo: repo, Reviewers: []ReviewerDetail{}} view.Lagging = st.LaggingWriters(CapsRepoOverrides, s.clock().UTC()) @@ -75,8 +81,15 @@ func (s *Service) Reviewers(ctx context.Context, repo string) (ReviewerView, err // mean per-repo classifiers. func (s *Service) SetReviewers(ctx context.Context, repo string, coBots, required []string) (ReviewerView, error) { repo = NormalizeRepo(repo) - if repo == "" || !strings.Contains(repo, "/") { - return ReviewerView{}, fmt.Errorf("repo must be owner/name, got %q", repo) + if err := checkRepoShape(repo); err != nil { + return ReviewerView{}, err + } + if coBots == nil && required == nil { + // Neither half was named, so there is nothing to set. Writing anyway would + // stamp a fresh UpdatedAt, and that timestamp IS the override's identity: + // applyFire revalidates against it, so a no-op call would discard every + // in-flight fire decision for the repository. + return s.Reviewers(ctx, repo) } // Accept either spelling: the login (chatgpt-codex-connector[bot]) or the // short config name (codex), which is what CRQ_COBOTS already takes. @@ -139,6 +152,14 @@ func (s *Service) SetReviewers(ctx context.Context, repo string, coBots, require setRequired = resolved } + // Read before the write: the requeue below may only touch live pull requests, + // and the CAS closure cannot ask GitHub. A failed lookup fails the whole + // command, which is retryable — writing the override and skipping the requeue + // would strand exactly the PRs the requeue exists for. + open, err := s.openPRs(ctx, repo) + if err != nil { + return ReviewerView{}, err + } now := s.clock().UTC() state, err := s.store.Update(ctx, func(st *State) error { ov, _ := st.RepoOverride(repo) @@ -151,7 +172,7 @@ func (s *Service) SetReviewers(ctx context.Context, repo string, coBots, require ov.UpdatedAt, ov.By = &now, s.cfg.Host before := s.cfg.ForRepo(mustOverride(st, repo)) st.SetRepoOverride(repo, ov) - s.reopenForChangedReviewers(st, repo, before, s.cfg.ForRepo(ov), now) + s.reopenForChangedReviewers(st, repo, before, s.cfg.ForRepo(ov), open) return nil }) if err != nil { @@ -164,19 +185,22 @@ func (s *Service) SetReviewers(ctx context.Context, repo string, coBots, require // ClearReviewers returns repo to the fleet default. func (s *Service) ClearReviewers(ctx context.Context, repo string) (ReviewerView, error) { repo = NormalizeRepo(repo) - // The same shape check set does. A typo like "owner-repo" would otherwise - // clear a key nothing uses and exit 0, so automation believes it restored the - // fleet default while the real override is still in force. - if !strings.Contains(repo, "/") { - return ReviewerView{}, fmt.Errorf("repo must be owner/name, got %q", repo) + // A typo like "owner-repo" would otherwise clear a key nothing uses and exit + // 0, so automation believes it restored the fleet default while the real + // override is still in force. + if err := checkRepoShape(repo); err != nil { + return ReviewerView{}, err + } + open, err := s.openPRs(ctx, repo) + if err != nil { + return ReviewerView{}, err } - now := s.clock().UTC() state, err := s.store.Update(ctx, func(st *State) error { before := s.cfg.ForRepo(mustOverride(st, repo)) if !st.ClearRepoOverride(repo) { return ErrNoChange } - s.reopenForChangedReviewers(st, repo, before, s.cfg, now) + s.reopenForChangedReviewers(st, repo, before, s.cfg, open) return nil }) if err != nil && !errors.Is(err, ErrNoChange) { @@ -188,6 +212,15 @@ func (s *Service) ClearReviewers(ctx context.Context, repo string) (ReviewerView return s.Reviewers(ctx, repo) } +// checkRepoShape is the one repository-shape check every reviewers path applies, +// so reading a target can never succeed where setting it would fail. +func checkRepoShape(repo string) error { + if repo == "" || !strings.Contains(repo, "/") { + return fmt.Errorf("repo must be owner/name, got %q", repo) + } + return nil +} + // knownLogins is the deduplicated set of accepted reviewers, for an error a // caller can act on. The map holds each bot twice (login and short name), so it // is the values that must be deduplicated, not the keys. @@ -221,14 +254,18 @@ func mustOverride(st *State, repo string) RepoReviewers { // still there. No eligible round exists to trigger it, and `crq next` waits for // a push that has no reason to come. // -// Only rounds whose required set actually changed are touched, and only -// completed ones — an in-flight round is already going to answer. +// Only rounds whose required set actually changed are touched, only completed +// ones — an in-flight round is already going to answer — and only those whose +// pull request is still open. Rounds are never deleted, so a repository's merged +// and closed PRs stay behind as completed dedup markers: requeueing those would +// hand Pump hundreds of dead rounds to observe and drop one per tick, ahead of +// every real one, and a stranded PR is by definition an open one. // // The primary is not re-asked: DecideFire's already-reviewed gate now counts a // completion reply paired to the round's command, not only a submitted Review // object, so a reopened round that the primary already answered dedupes instead // of buying a second review. -func (s *Service) reopenForChangedReviewers(st *State, repo string, before, after Config, now time.Time) { +func (s *Service) reopenForChangedReviewers(st *State, repo string, before, after Config, open map[int]bool) { if sameLogins(before.RequiredBots, after.RequiredBots) { return } @@ -236,8 +273,11 @@ func (s *Service) reopenForChangedReviewers(st *State, repo string, before, afte if NormalizeRepo(round.Repo) != NormalizeRepo(repo) || round.Phase != PhaseCompleted { continue } + if !open[round.PR] { + continue + } reopened := round - if err := reopened.Reopen(now); err != nil { + if err := reopened.Reopen(); err != nil { continue } st.PutRound(reopened) @@ -247,6 +287,22 @@ func (s *Service) reopenForChangedReviewers(st *State, repo string, before, afte } } +// openPRs is the set of repo's currently open pull request numbers — the only +// ones a reviewer change can strand. +func (s *Service) openPRs(ctx context.Context, repo string) (map[int]bool, error) { + open := map[int]bool{} + err := s.gh.EachOpenPR(ctx, repo, true, func(pr ghapi.SearchPR) (bool, error) { + if NormalizeRepo(pr.Repo) == repo { + open[pr.Number] = true + } + return false, nil + }) + if err != nil { + return nil, err + } + return open, nil +} + // sameLogins compares two reviewer lists as sets, since order is presentation. func sameLogins(a, b []string) bool { if len(a) != len(b) { diff --git a/internal/crq/repoconfig_test.go b/internal/crq/repoconfig_test.go index a354dd4a..ad40ecf5 100644 --- a/internal/crq/repoconfig_test.go +++ b/internal/crq/repoconfig_test.go @@ -7,6 +7,7 @@ import ( "github.com/kristofferR/coderabbit-queue/internal/dialect" "github.com/kristofferR/coderabbit-queue/internal/engine" + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) // The override has to reach a real decision, not just the config value: a @@ -194,8 +195,10 @@ func TestChangingRequirementsReopensACompletedRound(t *testing.T) { cfg := firingConfig() cfg.CoBots = codexCoBots(nil) store := NewMemoryStore(cfg) - svc := NewService(cfg, newFakeGitHub(), store, nil) + gh := newFakeGitHub() repo, pr, head := "o/r", 3, "aaaaaaaa1" + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} + svc := NewService(cfg, gh, store, nil) seedRound(t, store, cfg, repo, pr, head, PhaseCompleted, time.Now().UTC(), 11) if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}); err != nil { @@ -239,3 +242,34 @@ func TestChangingRequirementsReopensACompletedRound(t *testing.T) { t.Errorf("round = %#v, want it left completed when nothing changed", round) } } + +// Rounds are never deleted, so a repository's merged and closed PRs stay behind +// as completed dedup markers. Requeueing those on a reviewer change would put +// every dead round ahead of real work — Pump observes and drops them one per +// tick — and no closed PR can be stranded by a reviewer it will never get. +func TestChangingRequirementsLeavesClosedPRsAlone(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.CoBots = codexCoBots(nil) + store := NewMemoryStore(cfg) + gh := newFakeGitHub() + repo, open, merged := "o/r", 4, 5 + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: open}} + svc := NewService(cfg, gh, store, nil) + seedRound(t, store, cfg, repo, open, "aaaaaaaa1", PhaseCompleted, time.Now().UTC(), 11) + seedRound(t, store, cfg, repo, merged, "bbbbbbbb2", PhaseCompleted, time.Now().UTC(), 12) + + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round(repo, open); round == nil || round.Phase != PhaseQueued { + t.Errorf("open round = %#v, want it requeued so the new reviewer can be asked", round) + } + if round := st.Round(repo, merged); round == nil || round.Phase != PhaseCompleted { + t.Errorf("merged round = %#v, want it left completed — a closed PR cannot be stranded", round) + } +} diff --git a/internal/crq/service.go b/internal/crq/service.go index a6f1f5be..05c826e5 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -670,10 +670,14 @@ func firedOrEnqueuedAt(r Round) time.Time { func (s *Service) applyFire(ctx context.Context, cfg Config, round Round, obs engine.Observation, d engine.FireDecision, now time.Time) (PumpResult, error) { // The decision was made from a configuration that may have been replaced // since. Acting on it would post a trigger for a co-reviewer an operator has - // just removed, or skip one they have just required. - if st, _, err := s.store.Load(ctx); err == nil && overrideChanged(&st, round.Repo, cfg) { - return PumpResult{Action: "deduped", Repo: round.Repo, PR: round.PR, Head: round.Head, - Reason: "reviewer configuration changed while deciding"}, nil + // just removed, or skip one they have just required — so only the verdicts + // that POST pay for the re-read. FireNo is by far the most common outcome of + // a pump, and a Load is four API calls against the git-backed store. + if firePosts(d.Verdict) { + if st, _, err := s.store.Load(ctx); err == nil && overrideChanged(&st, round.Repo, cfg) { + return PumpResult{Action: "deduped", Repo: round.Repo, PR: round.PR, Head: round.Head, + Reason: "reviewer configuration changed while deciding"}, nil + } } switch d.Verdict { case engine.FireDrop: @@ -697,6 +701,18 @@ func (s *Service) applyFire(ctx context.Context, cfg Config, round Round, obs en } } +// firePosts reports whether a verdict posts a command chosen by the repository's +// reviewer configuration. The rest either write nothing to GitHub or write a +// round transition the override has no say in, and their round-identity CAS +// already guards them. +func firePosts(v engine.FireVerdict) bool { + switch v { + case engine.FireCoOnly, engine.FireCoDeferred, engine.FireAdopt, engine.FirePost: + return true + } + return false +} + func mapFireNo(reason string) string { switch { case strings.Contains(reason, "could not read head"): @@ -821,7 +837,7 @@ func (s *Service) fireRound(ctx context.Context, cfg Config, round Round, obs en if !sameRound(r, round) || !r.FireEligible(now) { return ErrNoChange } - if err := r.Reserve(token, s.cfg.Host, now); err != nil { + if err := r.Reserve(token, s.cfg.WriterID(), now); err != nil { return err } if err := r.Fire(adoptID, firedAt); err != nil { @@ -883,7 +899,7 @@ func (s *Service) fireRound(ctx context.Context, cfg Config, round Round, obs en if !sameRound(r, round) || !r.FireEligible(now) { return ErrNoChange } - if err := r.Reserve(token, s.cfg.Host, now); err != nil { + if err := r.Reserve(token, s.cfg.WriterID(), now); err != nil { return err } st.FireSlot = &FireSlot{Key: key, Token: token, Since: now} @@ -1038,7 +1054,7 @@ func (s *Service) fireCoOnly(ctx context.Context, cfg Config, round Round, login // still-queued round through Reserve (a pure phase transition — no // global FireSlot is registered) so the park is a legal edge. if r.FireEligible(now) { - if rerr := r.Reserve(randomToken(), s.cfg.Host, now); rerr != nil { + if rerr := r.Reserve(randomToken(), s.cfg.WriterID(), now); rerr != nil { return rerr } } @@ -1069,7 +1085,7 @@ func (s *Service) fireCoOnly(ctx context.Context, cfg Config, round Round, login return ErrNoChange } if r.FireEligible(now) { - if err := r.Reserve(randomToken(), s.cfg.Host, now); err != nil { + if err := r.Reserve(randomToken(), s.cfg.WriterID(), now); err != nil { return err } if err := r.Fire(posts[0].id, firedAt); err != nil { diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index 27a62b9b..e5594d0b 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -761,6 +761,12 @@ func TestPumpPersistsPostedReviewAfterTransientStateFailure(t *testing.T) { if r == nil || r.Phase != PhaseFired || r.CommandID == 0 { t.Fatalf("posted review metadata was not persisted after retry: %#v", r) } + // The firing PROCESS, in the form capabilities are recorded under: a bare + // hostname here can never match a writer entry, so LaggingWriters would name + // this very process as needing an upgrade for as long as the fire lasts. + if r.ByHost != cfg.WriterID() { + t.Errorf("ByHost = %q, want the writer id %q", r.ByHost, cfg.WriterID()) + } if state.FiredMarker("owner/repo", 12) != "abcdef123" { t.Fatalf("fired marker was not persisted after retry") } diff --git a/internal/engine/completion.go b/internal/engine/completion.go index 7334efe2..a60e7d1b 100644 --- a/internal/engine/completion.go +++ b/internal/engine/completion.go @@ -247,6 +247,24 @@ func CommandHasCompletionReply(obs Observation, p Policy, commandID int64) bool return false } +// PrimaryCompletedRound reports whether the primary answered THIS round's own +// command with a completion reply that counts as its review of the head. +// +// It is the gate that lets a reopened round skip re-asking a primary that +// already answered, so it holds to Completion's rule 4 rather than to the weaker +// adoption question: CommandHasCompletionReply deliberately omits the +// prior-review requirement and the failed-summary guard, and a reply failing +// either is not a review of this head. Deduping on it writes a completed marker +// that every later same-head check skips, so a wrong yes here is one nothing +// recovers from. +func PrimaryCompletedRound(r state.Round, obs Observation, p Policy) bool { + if r.CommandID == 0 || r.FiredAt == nil { + return false + } + return CommandHasCompletionReply(obs, p, r.CommandID) && + completionReplyForRound(obs, p, r.FiredAt.UTC()) +} + func botHasAnyReview(reviews []ReviewSeen, bot string) bool { for _, review := range reviews { if sameBot(review.Bot, bot) { diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 98fc08ba..f60e13e1 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -944,3 +944,69 @@ func TestAcceptAccountBlock(t *testing.T) { }) } } + +// A reopened round asks whether the primary already answered THIS round's +// command, and answering yes writes a completed marker every later same-head +// check skips — so the gate must hold to Completion's evidence, not to the +// weaker adoption question CommandHasCompletionReply answers. +func TestReopenedRoundDedupesOnlyOnCompletionEvidence(t *testing.T) { + free := Global{SlotFree: true} + now := t0.Add(10 * time.Minute) + head := "abcdef123" + + reopened := func(t *testing.T) state.Round { + t.Helper() + r := firedRound(t, head) + if err := r.Complete(); err != nil { + t.Fatal(err) + } + if err := r.Reopen(); err != nil { + t.Fatal(err) + } + return r + } + command := dialect.BotEvent{Kind: dialect.EvCommand, Bot: "kristofferR", CommentID: 1001, + CreatedAt: t0.Add(2 * time.Second), UpdatedAt: t0.Add(2 * time.Second)} + completion := dialect.BotEvent{Kind: dialect.EvCompletion, Bot: "coderabbitai[bot]", CommentID: 1002, + AutoReply: true, CreatedAt: t0.Add(time.Minute), UpdatedAt: t0.Add(time.Minute)} + priorReview := ReviewSeen{Bot: "coderabbitai[bot]", Commit: "0000000099", SubmittedAt: t0.Add(-time.Hour)} + + cases := []struct { + name string + obs Observation + want FireVerdict + }{ + { + // The reply stands in for a re-review that found nothing new. + name: "completion reply with a prior review", + obs: Observation{Head: head, Open: true, Reviews: []ReviewSeen{priorReview}, + Events: []dialect.BotEvent{command, completion}}, + want: FireDedupe, + }, + { + // Nothing to stand in for: the bot has never submitted a review, so + // the head has not been reviewed and the round must still fire. + name: "completion reply with no review anywhere", + obs: Observation{Head: head, Open: true, + Events: []dialect.BotEvent{command, completion}}, + want: FirePost, + }, + { + // The review failed after answering, so the reply describes a review + // that did not land. + name: "failed summary after the completion reply", + obs: Observation{Head: head, Open: true, Reviews: []ReviewSeen{priorReview}, + Events: []dialect.BotEvent{command, completion, + {Kind: dialect.EvFailed, Bot: "coderabbitai[bot]", CommentID: 900, + CreatedAt: t0, UpdatedAt: t0.Add(2 * time.Minute)}}}, + want: FirePost, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if d := DecideFire(free, reopened(t), tc.obs, now, policy); d.Verdict != tc.want { + t.Fatalf("verdict = %v, want %v (%+v)", d.Verdict, tc.want, d) + } + }) + } +} diff --git a/internal/engine/fire.go b/internal/engine/fire.go index 638961e3..bff99281 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -162,9 +162,10 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic // reading only obs.Reviews there means crq posts the command again and buys // a second review of code it has already been told about. The reply is // paired to THIS round's command, so it only counts while the round still - // tracks the head. - if !reviewedHead && r.Head == obs.Head && r.CommandID != 0 { - reviewedHead = CommandHasCompletionReply(obs, p, r.CommandID) + // tracks the head, and it must carry the evidence Completion asks for — a + // reply nothing backs would mark an unreviewed head reviewed for good. + if !reviewedHead && r.Head == obs.Head { + reviewedHead = PrimaryCompletedRound(r, obs, p) } // Belt-and-braces live check: even with a fresh round, never fire at a // head the bot has already reviewed (e.g. state was reinitialized). But a diff --git a/internal/state/dashboard.go b/internal/state/dashboard.go index 6509b1d3..e60caa2f 100644 --- a/internal/state/dashboard.go +++ b/internal/state/dashboard.go @@ -191,6 +191,19 @@ func dash(s string) string { return s } +// hostName renders a round's writer id ("host=blue pid=4711") as the machine +// name the host column has always shown. The round stores the writer id because +// that is what capabilities are keyed by; the pid is bookkeeping for +// LaggingWriters, not something a reader of the table needs. +func hostName(writer string) string { + rest, ok := strings.CutPrefix(writer, "host=") + if !ok { + return writer + } + name, _, _ := strings.Cut(rest, " ") + return name +} + // RenderDashboard renders the human-facing dashboard for v3 state: rounds by // phase instead of v2's queue/fired/awaiting maps. func RenderDashboard(st State, cfg StoreConfig) string { @@ -266,7 +279,7 @@ func RenderDashboard(st State, cfg StoreConfig) string { for _, r := range inFlight { fmt.Fprintf(&b, "| [%s#%d](https://github.com/%s/pull/%d) | `%s` | %s | %s | %s | %s | `%s` |\n", r.Repo, r.PR, r.Repo, r.PR, r.Head, r.Phase, - fmtStamp(firedTimeOf(r), loc), fmtStamp(r.WaitDeadline, loc), dash(coBotMarks(r)), r.ByHost) + fmtStamp(firedTimeOf(r), loc), fmtStamp(r.WaitDeadline, loc), dash(coBotMarks(r)), hostName(r.ByHost)) } } @@ -303,7 +316,7 @@ func RenderDashboard(st State, cfg StoreConfig) string { } fmt.Fprintf(&b, "| %s | [%s#%d](https://github.com/%s/pull/%d) | `%s` | %s | %s | %d | %s | `%s` |\n", position, e.Repo, e.PR, e.Repo, e.PR, e.Head, ready, dash(e.Why), - e.Attempts, fmtStamp(&e.EnqueuedAt, loc), e.ByHost) + e.Attempts, fmtStamp(&e.EnqueuedAt, loc), hostName(e.ByHost)) } } @@ -315,7 +328,7 @@ func RenderDashboard(st State, cfg StoreConfig) string { fmt.Fprintf(&b, "| PR | commit | requested | host |\n|---|---|---|---|\n") for _, r := range requested { fmt.Fprintf(&b, "| [%s#%d](https://github.com/%s/pull/%d) | `%s` | %s | `%s` |\n", - r.Repo, r.PR, r.Repo, r.PR, r.Head, fmtStamp(r.FiredAt, loc), r.ByHost) + r.Repo, r.PR, r.Repo, r.PR, r.Head, fmtStamp(r.FiredAt, loc), hostName(r.ByHost)) } } diff --git a/internal/state/state.go b/internal/state/state.go index 5d0ff339..50dd1376 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -103,7 +103,11 @@ type Round struct { // the round is retried or surfaced as timed out. WaitDeadline *time.Time `json:"wait_deadline,omitempty"` - Token string `json:"token,omitempty"` // reservation token (CAS race detection) + Token string `json:"token,omitempty"` // reservation token (CAS race detection) + // ByHost identifies the PROCESS that reserved this round, in the writer form + // "host= pid=" — the key NoteWriter records capabilities under, so + // LaggingWriters can ask whether the process driving a fire understands the + // configuration it is firing from. The dashboard shows the machine name. ByHost string `json:"by_host,omitempty"` Note string `json:"note,omitempty"` // human-readable reason for the last transition @@ -454,14 +458,15 @@ func (e *TransitionError) Error() string { func (r *Round) illegal(to Phase) error { return &TransitionError{From: r.Phase, To: to} } // Reserve takes the fire slot for this round: queued (or retry-eligible -// awaiting_retry) → reserved. -func (r *Round) Reserve(token, host string, now time.Time) error { +// awaiting_retry) → reserved. writer is the reserving process's writer id (see +// ByHost), not a bare hostname. +func (r *Round) Reserve(token, writer string, now time.Time) error { if r.Phase != PhaseQueued && !r.retryEligible(now) { return r.illegal(PhaseReserved) } r.Phase = PhaseReserved r.Token = token - r.ByHost = host + r.ByHost = writer t := now.UTC() r.ReservedAt = &t r.Note = "" @@ -509,7 +514,12 @@ func (r *Round) ReleaseToQueue(reason string, now time.Time) error { // trigger it. This is the one transition that reopens a finished round, and it // keeps the head, the attempts and the co-reviewer bookkeeping — what changed is // who still has to answer, not what happened. -func (r *Round) Reopen(now time.Time) error { +// +// LastAttemptAt is deliberately left alone: it is the adoption floor for a +// FAILED attempt, and moving it would discard a newly required co-reviewer's own +// unanswered trigger comment as too old to adopt — so crq would post that bot a +// second request for the very round the reopen exists to let it answer. +func (r *Round) Reopen() error { if r.Phase != PhaseCompleted { return r.illegal(PhaseQueued) } @@ -518,8 +528,6 @@ func (r *Round) Reopen(now time.Time) error { r.ReservedAt = nil r.WaitDeadline = nil r.RetryAt = nil - t := now.UTC() - r.LastAttemptAt = &t r.Note = "reviewer requirements changed" return nil } diff --git a/internal/state/writers_test.go b/internal/state/writers_test.go index bc93c18c..304f4d87 100644 --- a/internal/state/writers_test.go +++ b/internal/state/writers_test.go @@ -64,3 +64,53 @@ func TestLaggingWritersMatchesTheLeadersProcessIdentity(t *testing.T) { t.Errorf("lagging = %v, want the un-upgraded daemon named", got) } } + +// The other acting process is whoever holds the fire slot, and a round records +// that process in ByHost. Recording a bare hostname there could never match a +// writer entry, so every `crq reviewers` call during a fire named the current +// process as lagging — telling operators to upgrade a binary that already +// understands overrides. +func TestLaggingWritersMatchesTheFireSlotOwner(t *testing.T) { + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + writer := "host=cachyos pid=1234" + st := New() + r, err := st.NewRound("owner/repo", 7, "abcdef123", now) + if err != nil { + t.Fatal(err) + } + if err := r.Reserve("tok", writer, now); err != nil { + t.Fatal(err) + } + st.PutRound(*r) + st.FireSlot = &FireSlot{Key: Key("owner/repo", 7), Token: "tok", Since: now} + + if got := st.LaggingWriters(CapsRepoOverrides, now); len(got) != 1 || got[0] != writer { + t.Fatalf("lagging = %v, want the un-announced slot owner named", got) + } + st.NoteWriter(writer, CapsRepoOverrides, now) + if got := st.LaggingWriters(CapsRepoOverrides, now); len(got) != 0 { + t.Errorf("lagging = %v, want none — the process firing IS the capable writer", got) + } +} + +// Reopening a round is not a failed attempt. Moving LastAttemptAt would raise +// the adoption floor past a newly required co-reviewer's own unanswered trigger, +// so crq would post that bot a second request for the round it is reopening to +// let it answer. +func TestReopenKeepsTheAdoptionFloor(t *testing.T) { + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + r := Round{Repo: "owner/repo", PR: 7, Head: "abcdef123", Phase: PhaseFired, + FiredAt: &now, CommandID: 11} + if err := r.Complete(); err != nil { + t.Fatal(err) + } + if err := r.Reopen(); err != nil { + t.Fatal(err) + } + if r.Phase != PhaseQueued { + t.Fatalf("phase = %s, want the round requeued", r.Phase) + } + if r.LastAttemptAt != nil { + t.Errorf("LastAttemptAt = %v, want it untouched by a reopen", r.LastAttemptAt) + } +} diff --git a/skills/coderabbit-queue/SKILL.md b/skills/coderabbit-queue/SKILL.md index 129e2452..aea5e2fe 100644 --- a/skills/coderabbit-queue/SKILL.md +++ b/skills/coderabbit-queue/SKILL.md @@ -180,7 +180,9 @@ crq reviewers clear "$REPO" # back to the fleet defa ``` Each reviewer reports its `budget`: `account` is serialized against the shared CodeRabbit allowance, -`none` runs immediately and waits for nobody. That is the only property the queue cares about. +`none` runs immediately, outside that queue. That is the only property the queue cares about — it says +what a reviewer costs, never whether a round waits for it. `--required` alone decides that, and either +flag may be given without the other (`--bots` and `--required` update separate halves of the override). The setting lives in the shared state ref, so the daemon and every agent read the same one. From 375d9c106c62928c708401b3e7603162c0541040 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Mon, 27 Jul 2026 04:20:43 +0200 Subject: [PATCH 07/37] Reach a pull request the reviewer change could not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reviewer change requeues a repository's completed rounds, but three paths let one slip through and leave the PR waiting on a bot crq never asks. A closed PR's round was skipped, and closed is not final: reopened at the same head, its completed round is the dedup marker that hides the requirement added while it was shut. Mark those rounds instead of requeueing them — requeueing dead work would put it ahead of every live round — and let whichever enqueue path next finds the PR alive reopen it. FireDedupe was exempt from the apply-time revalidation because it posts nothing. But its completed round asserts that everyone gating the head has answered, and an override landing after the decision finds the round still queued, so nothing requeues it. Revalidate dedupe too, and report the refusal as the lost race it is rather than as a dedupe. `crq reviewers ` accepted "owner/", "/name" and "owner/name/extra". The read path never contacts GitHub, so a typo printed the fleet default and exited 0, reading as a report about a project crq follows. --- AGENTS.md | 6 ++ internal/crq/auto.go | 11 ++- internal/crq/repoconfig.go | 34 ++++++- internal/crq/repoconfig_test.go | 151 ++++++++++++++++++++++++++++++++ internal/crq/service.go | 41 ++++++--- internal/state/state.go | 9 ++ 6 files changed, 239 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 399e9461..b22a0d9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,12 @@ r.Head == head → skip`. A completed round stays as the "this head was reviewed dedup marker. A rate-limited requeue parks the round in `awaiting_retry` (keeping its head/attempts/history), it does not delete a fired marker. +The one exception to that skip is `Round.ReviewersChanged`: a reviewer change +requeues the repository's completed rounds, but only for PRs that are open — +marking the closed ones instead of handing Pump dead work. A marked round is +reopened by whichever enqueue path next sees the PR alive, so reopening a PR +picks up the requirements it missed while it was shut. + The global `FireSlot` allows ≤1 concurrent fire fleet-wide (CAS). A bot ack releases the slot while the review keeps running (the round moves to `reviewing`); the round itself stays open until `Completion` is done. diff --git a/internal/crq/auto.go b/internal/crq/auto.go index c9d56112..6c1cb547 100644 --- a/internal/crq/auto.go +++ b/internal/crq/auto.go @@ -299,7 +299,16 @@ func (s *Service) needsReview(ctx context.Context, state State, repo string, pr return false, "", err } if r := state.Round(repo, pr); r != nil && r.Head == head { - return false, head, nil + // Unless the round is a marker for reviewers that changed while this PR + // was closed: it answered for a set that no longer gates the head, and + // enqueueBatch reopens it. Deciding here rather than falling through to + // the live checks is also the only correct answer — those read Review + // objects, which a co-reviewer answering by comment never submits. + if !r.ReviewersChanged { + return false, head, nil + } + s.logEnqueue(repo, pr, head, "reviewer requirements changed while the pr was closed") + return true, head, nil } reviews, err := s.gh.ListReviews(ctx, repo, pr) if err != nil { diff --git a/internal/crq/repoconfig.go b/internal/crq/repoconfig.go index f3016d6a..162f33a8 100644 --- a/internal/crq/repoconfig.go +++ b/internal/crq/repoconfig.go @@ -214,8 +214,14 @@ func (s *Service) ClearReviewers(ctx context.Context, repo string) (ReviewerView // checkRepoShape is the one repository-shape check every reviewers path applies, // so reading a target can never succeed where setting it would fail. +// +// Exactly two nonempty components: "owner/", "/name" and "owner/name/extra" name +// no repository, and the read path never contacts GitHub — so a typo would +// otherwise print the fleet default and exit 0, reading as a report about a +// project crq follows. func checkRepoShape(repo string) error { - if repo == "" || !strings.Contains(repo, "/") { + owner, name, ok := strings.Cut(repo, "/") + if !ok || owner == "" || name == "" || strings.Contains(name, "/") { return fmt.Errorf("repo must be owner/name, got %q", repo) } return nil @@ -261,6 +267,11 @@ func mustOverride(st *State, repo string) RepoReviewers { // hand Pump hundreds of dead rounds to observe and drop one per tick, ahead of // every real one, and a stranded PR is by definition an open one. // +// A closed PR's round is marked instead of requeued, because closed is not +// final: reopened at the same head, its completed round would be the dedup +// marker that hides the requirement the operator added while it was shut. The +// mark costs nothing until an enqueue finds the PR alive again. +// // The primary is not re-asked: DecideFire's already-reviewed gate now counts a // completion reply paired to the round's command, not only a submitted Review // object, so a reopened round that the primary already answered dedupes instead @@ -274,6 +285,11 @@ func (s *Service) reopenForChangedReviewers(st *State, repo string, before, afte continue } if !open[round.PR] { + if !round.ReviewersChanged { + marked := round + marked.ReviewersChanged = true + st.PutRound(marked) + } continue } reopened := round @@ -287,6 +303,22 @@ func (s *Service) reopenForChangedReviewers(st *State, repo string, before, afte } } +// requeueIfReviewersChanged reopens a completed round that a reviewer change +// marked while its pull request was closed, and reports whether it did. This is +// the other half of reopenForChangedReviewers: the enqueue paths call it when +// the PR turns out to be alive after all, which is the moment the round stops +// being a harmless dead marker and starts being the thing that strands the PR. +func requeueIfReviewersChanged(st *State, r *Round) bool { + if r == nil || r.Phase != PhaseCompleted || !r.ReviewersChanged { + return false + } + if err := r.Reopen(); err != nil { + return false + } + st.PutRound(*r) + return true +} + // openPRs is the set of repo's currently open pull request numbers — the only // ones a reviewer change can strand. func (s *Service) openPRs(ctx context.Context, repo string) (map[int]bool, error) { diff --git a/internal/crq/repoconfig_test.go b/internal/crq/repoconfig_test.go index ad40ecf5..9aa085bd 100644 --- a/internal/crq/repoconfig_test.go +++ b/internal/crq/repoconfig_test.go @@ -243,6 +243,157 @@ func TestChangingRequirementsReopensACompletedRound(t *testing.T) { } } +// Dedupe writes the "every required reviewer answered this head" marker, so a +// required reviewer added between the decision and the write must void it. The +// round is still queued when the operator's write lands — nothing to requeue — +// and dedupe posts nothing, so only this revalidation catches the race. +func TestDedupeIsRevalidatedAgainstAReviewerChange(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.CoBots = codexCoBots(nil) + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + now := time.Now().UTC() + repo, settled, raced, head := "o/r", 9, 10, "aaaaaaaa1" + seedRound(t, store, cfg, repo, settled, head, PhaseQueued, now, 0) + seedRound(t, store, cfg, repo, raced, head, PhaseQueued, now, 0) + dedupe := engine.FireDecision{Verdict: engine.FireDedupe, Reason: "bot already reviewed head"} + + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + round := *st.Round(repo, settled) + if _, err := svc.applyFire(ctx, svc.cfgFor(st, repo), round, engine.Observation{}, dedupe, now); err != nil { + t.Fatal(err) + } + if got := roundPhase(t, store, repo, settled); got != PhaseCompleted { + t.Fatalf("phase = %s, want an unraced dedupe to still complete the round", got) + } + + // Now the override lands after the decision was made from svc.cfg. + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}); err != nil { + t.Fatal(err) + } + st, _, err = store.Load(ctx) + if err != nil { + t.Fatal(err) + } + round = *st.Round(repo, raced) + result, err := svc.applyFire(ctx, svc.cfg, round, engine.Observation{}, dedupe, now) + if err != nil { + t.Fatal(err) + } + if result.Action != "lost_race" { + t.Errorf("action = %q, want the stale dedupe reported as a lost race", result.Action) + } + if got := roundPhase(t, store, repo, raced); got != PhaseQueued { + t.Fatalf("phase = %s, want the round left queued so the new reviewer is asked", got) + } +} + +// Every reviewers path rejects a target that is not exactly owner/name. The read +// path never contacts GitHub, so a typo would otherwise report the fleet default +// and exit 0 — a plausible answer about a project that does not exist. +func TestReviewersRejectAnIncompleteRepository(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + for _, repo := range []string{"", "owner", "owner/", "/name", "owner/name/extra"} { + if _, err := svc.Reviewers(ctx, repo); err == nil { + t.Errorf("Reviewers(%q) = nil error, want the malformed target refused", repo) + } + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, nil); err == nil { + t.Errorf("SetReviewers(%q) = nil error, want the malformed target refused", repo) + } + if _, err := svc.ClearReviewers(ctx, repo); err == nil { + t.Errorf("ClearReviewers(%q) = nil error, want the malformed target refused", repo) + } + } + if _, err := svc.Reviewers(ctx, "owner/name"); err != nil { + t.Errorf("Reviewers(owner/name) = %v, want a well-formed target accepted", err) + } +} + +// A closed PR is not a dead PR. Requirements that change while it is shut must +// still reach it if it is reopened at the same head, or its completed round is +// the dedup marker that hides the reviewer the operator added. +func TestAReopenedPRPicksUpRequirementsChangedWhileItWasClosed(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.CoBots = codexCoBots(nil) + store := NewMemoryStore(cfg) + gh := newFakeGitHub() + repo, manual, auto, head := "o/r", 7, 8, "aaaaaaaa1" + svc := NewService(cfg, gh, store, nil) + for _, pr := range []int{manual, auto} { + seedRound(t, store, cfg, repo, pr, head, PhaseCompleted, time.Now().UTC(), 11) + } + + // Both PRs are closed while the required set changes: no round is requeued. + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + for _, pr := range []int{manual, auto} { + round := st.Round(repo, pr) + if round == nil || round.Phase != PhaseCompleted { + t.Fatalf("round = %#v, want a closed PR left alone", round) + } + if !round.ReviewersChanged { + t.Fatalf("round = %#v, want the change recorded for a reopen", round) + } + } + + // Both come back at the same head — the manual path first. + var pull ghapi.Pull + pull.State = "open" + pull.Head.SHA = head + "bcdef1234" + gh.pulls[fakeKey(repo, manual)] = pull + gh.pulls[fakeKey(repo, auto)] = pull + result, err := svc.Enqueue(ctx, repo, manual) + if err != nil { + t.Fatal(err) + } + if !result.Queued || result.Deduped { + t.Fatalf("enqueue = %#v, want the reopened round queued rather than deduped", result) + } + + // And the autoreview path, which never calls Enqueue. + st, _, err = store.Load(ctx) + if err != nil { + t.Fatal(err) + } + need, gotHead, err := svc.needsReview(ctx, st, repo, auto, true) + if err != nil { + t.Fatal(err) + } + if !need || gotHead != head { + t.Fatalf("needsReview = %v %q, want the reopened PR enqueued at %q", need, gotHead, head) + } + if err := svc.enqueueBatch(ctx, []queueCandidate{{Repo: repo, PR: auto, Head: gotHead}}); err != nil { + t.Fatal(err) + } + + st, _, err = store.Load(ctx) + if err != nil { + t.Fatal(err) + } + for _, pr := range []int{manual, auto} { + round := st.Round(repo, pr) + if round == nil || round.Phase != PhaseQueued || round.Head != head { + t.Fatalf("round = %#v, want it queued at the same head so the new reviewer is asked", round) + } + if round.ReviewersChanged { + t.Errorf("round = %#v, want the mark cleared — it answers under the current requirements now", round) + } + } +} + // Rounds are never deleted, so a repository's merged and closed PRs stay behind // as completed dedup markers. Requeueing those on a reviewer change would put // every dead round ahead of real work — Pump observes and drops them one per diff --git a/internal/crq/service.go b/internal/crq/service.go index 05c826e5..81e1c40c 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -128,6 +128,14 @@ func (s *Service) Enqueue(ctx context.Context, repo string, pr int) (EnqueueResu now := s.clock() r := st.Round(repo, pr) if r != nil && r.Head == head { + // A PR reopened after its reviewers changed: the completed round is a + // marker for requirements that no longer hold, so it goes back in the + // queue instead of deduping the enqueue that would have asked. + if requeueIfReviewersChanged(st, r) { + result.Queued = true + result.Seq = r.Seq + return nil + } switch r.Phase { case PhaseFired, PhaseReviewing, PhaseCompleted: result.Deduped = true @@ -181,6 +189,9 @@ func (s *Service) enqueueBatch(ctx context.Context, items []queueCandidate) erro repo := NormalizeRepo(it.Repo) if r := st.Round(repo, it.PR); r != nil { if r.Head == it.Head { + if requeueIfReviewersChanged(st, r) { + added++ + } continue } if _, err := st.Supersede(repo, it.PR, it.Head, now); err != nil { @@ -670,12 +681,14 @@ func firedOrEnqueuedAt(r Round) time.Time { func (s *Service) applyFire(ctx context.Context, cfg Config, round Round, obs engine.Observation, d engine.FireDecision, now time.Time) (PumpResult, error) { // The decision was made from a configuration that may have been replaced // since. Acting on it would post a trigger for a co-reviewer an operator has - // just removed, or skip one they have just required — so only the verdicts - // that POST pay for the re-read. FireNo is by far the most common outcome of - // a pump, and a Load is four API calls against the git-backed store. - if firePosts(d.Verdict) { + // just removed, skip one they have just required, or record that a head is + // reviewed by a set that no longer gates it — so only the verdicts the + // reviewer configuration decides pay for the re-read. FireNo is by far the + // most common outcome of a pump, and a Load is four API calls against the + // git-backed store. + if fireFollowsReviewers(d.Verdict) { if st, _, err := s.store.Load(ctx); err == nil && overrideChanged(&st, round.Repo, cfg) { - return PumpResult{Action: "deduped", Repo: round.Repo, PR: round.PR, Head: round.Head, + return PumpResult{Action: "lost_race", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: "reviewer configuration changed while deciding"}, nil } } @@ -701,13 +714,19 @@ func (s *Service) applyFire(ctx context.Context, cfg Config, round Round, obs en } } -// firePosts reports whether a verdict posts a command chosen by the repository's -// reviewer configuration. The rest either write nothing to GitHub or write a -// round transition the override has no say in, and their round-identity CAS -// already guards them. -func firePosts(v engine.FireVerdict) bool { +// fireFollowsReviewers reports whether a verdict was decided by the repository's +// reviewer configuration: the four that post a command it chose, and FireDedupe, +// whose completed round asserts that everyone this repository gates on has +// already answered the head. Dedupe posts nothing, so SetReviewers cannot see it +// coming — the round is still queued when the override lands, and a requeue of a +// queued round is a no-op — yet the marker it writes is exactly what stops the +// newly required reviewer from ever being asked. +// +// The rest either write nothing to GitHub or write a round transition the +// override has no say in, and their round-identity CAS already guards them. +func fireFollowsReviewers(v engine.FireVerdict) bool { switch v { - case engine.FireCoOnly, engine.FireCoDeferred, engine.FireAdopt, engine.FirePost: + case engine.FireCoOnly, engine.FireCoDeferred, engine.FireAdopt, engine.FirePost, engine.FireDedupe: return true } return false diff --git a/internal/state/state.go b/internal/state/state.go index 50dd1376..66214c44 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -103,6 +103,14 @@ type Round struct { // the round is retried or surfaced as timed out. WaitDeadline *time.Time `json:"wait_deadline,omitempty"` + // ReviewersChanged marks a completed round whose required reviewer set + // changed while its pull request was closed. Requeueing a closed PR's round + // would hand Pump dead work ahead of every live round, so the change is + // recorded on the marker instead: if the PR is ever reopened, enqueue reopens + // the round rather than treating it as "this head was reviewed". Reopen + // clears it — the reopened round answers under the current requirements. + ReviewersChanged bool `json:"reviewers_changed,omitempty"` + Token string `json:"token,omitempty"` // reservation token (CAS race detection) // ByHost identifies the PROCESS that reserved this round, in the writer form // "host= pid=" — the key NoteWriter records capabilities under, so @@ -528,6 +536,7 @@ func (r *Round) Reopen() error { r.ReservedAt = nil r.WaitDeadline = nil r.RetryAt = nil + r.ReviewersChanged = false r.Note = "reviewer requirements changed" return nil } From c50f7059807abfaf22afe11db992dbfb76406e2e Mon Sep 17 00:00:00 2001 From: kristofferR Date: Mon, 27 Jul 2026 04:53:02 +0200 Subject: [PATCH 08/37] Hold the fire slot until the primary answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P1/P2s from Codex, both about a decision outliving the state it was made from. Convergence and slot release were the same question. A repository whose required set omits the primary — required Codex only — converges the moment Codex answers, so Progress completed the round and released the fire slot while the `@coderabbitai review` it had posted was still unacknowledged. The next PR could then spend the account allowance alongside it, which is the one thing the queue exists to prevent, and a late account-block reply landed on a completed round nothing progresses. A round that spent the quota now stays fired until the primary reacts or replies, or until its in-flight timeout gives up on it; at that timeout a converged round completes rather than buying a second metered review no configured reviewer asked for. A co-only round posted no command of its own and still completes at once. The override revalidation ran in applyFire, one read before the effect's own write. SetReviewers committing in that window left the effect applying a decision the new configuration would not have made — for FireDedupe permanently, since dedupe posts nothing, the round is still queued when the override lands (requeuing a queued round is a no-op), and the marker it writes is what stops the newly required reviewer from ever being asked. The check now runs inside each effect's own CAS mutation: the dedupe write, the slot reservation, and the co-reviewer trigger claims. That is the commit point in every case — nothing is posted before it — so the window is gone, and the separate Load with it. --- AGENTS.md | 7 +++- internal/crq/repoconfig_test.go | 54 +++++++++++++++++++++++++ internal/crq/reviewers.go | 5 ++- internal/crq/service.go | 72 +++++++++++++++------------------ internal/engine/engine_test.go | 39 ++++++++++++++++++ internal/engine/progress.go | 65 ++++++++++++++++++++--------- 6 files changed, 183 insertions(+), 59 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b22a0d9f..8e36935f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,12 @@ picks up the requirements it missed while it was shut. The global `FireSlot` allows ≤1 concurrent fire fleet-wide (CAS). A bot ack releases the slot while the review keeps running (the round moves to -`reviewing`); the round itself stays open until `Completion` is done. +`reviewing`); the round itself stays open until `Completion` is done. The +converse does not hold: convergence alone never releases the slot. A repository +whose required set omits the primary converges as soon as its co-reviewers +answer, and completing there would hand the slot to the next PR while the +metered command is still unanswered — so a round that spent the quota stays +`fired` until the primary acknowledges or its in-flight timeout expires. ## observe → decide → apply diff --git a/internal/crq/repoconfig_test.go b/internal/crq/repoconfig_test.go index 9aa085bd..f07b67e6 100644 --- a/internal/crq/repoconfig_test.go +++ b/internal/crq/repoconfig_test.go @@ -292,6 +292,60 @@ func TestDedupeIsRevalidatedAgainstAReviewerChange(t *testing.T) { } } +// hookedStore runs a hook once, immediately before the next Update's mutation +// reaches the store — the window a fire effect's own CAS has to close, and the +// one a check made before the write cannot see into. +type hookedStore struct { + StateStore + hook func() +} + +func (h *hookedStore) Update(ctx context.Context, mutate func(*State) error) (State, error) { + if h.hook != nil { + hook := h.hook + h.hook = nil + hook() + } + return h.StateStore.Update(ctx, mutate) +} + +// The revalidation has to happen in the write itself: an override that lands +// after any pre-flight re-read but before the dedupe commits would otherwise +// still complete the round under the reviewer set it replaced. +func TestDedupeIsRevalidatedInsideItsWrite(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.CoBots = codexCoBots(nil) + store := NewMemoryStore(cfg) + hooked := &hookedStore{StateStore: store} + svc := NewService(cfg, newFakeGitHub(), hooked, nil) + now := time.Now().UTC() + repo, pr, head := "o/r", 11, "aaaaaaaa1" + seedRound(t, store, cfg, repo, pr, head, PhaseQueued, now, 0) + + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + round := *st.Round(repo, pr) + hooked.hook = func() { + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}); err != nil { + t.Error(err) + } + } + dedupe := engine.FireDecision{Verdict: engine.FireDedupe, Reason: "bot already reviewed head"} + result, err := svc.applyFire(ctx, svc.cfgFor(st, repo), round, engine.Observation{}, dedupe, now) + if err != nil { + t.Fatal(err) + } + if result.Action != "lost_race" { + t.Errorf("action = %q, want the dedupe voided by the override that beat its write", result.Action) + } + if got := roundPhase(t, store, repo, pr); got != PhaseQueued { + t.Fatalf("phase = %s, want the round left queued so the new reviewer is asked", got) + } +} + // Every reviewers path rejects a target that is not exactly owner/name. The read // path never contacts GitHub, so a typo would otherwise report the fleet default // and exit 0 — a plausible answer about a project that does not exist. diff --git a/internal/crq/reviewers.go b/internal/crq/reviewers.go index 195ca485..74b3f890 100644 --- a/internal/crq/reviewers.go +++ b/internal/crq/reviewers.go @@ -312,7 +312,10 @@ func (s *Service) cfgFor(st State, repo string) Config { // // Deciding and writing are two steps. An operator removing a co-reviewer between // them would otherwise have crq claim and post that bot's trigger anyway, on the -// authority of a configuration that no longer exists. +// authority of a configuration that no longer exists. It is therefore called +// from INSIDE the CAS mutation that commits the decision, where the state it +// reads is the state the write lands on; a separate read beforehand would leave +// the same window one step earlier. func overrideChanged(st *State, repo string, cfg Config) bool { ov, _ := st.RepoOverride(repo) switch { diff --git a/internal/crq/service.go b/internal/crq/service.go index 81e1c40c..a8da231c 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -678,25 +678,22 @@ func firedOrEnqueuedAt(r Round) time.Time { } // applyFire executes a DecideFire verdict. +// +// The decision was made from a configuration that may have been replaced since. +// Acting on it would post a trigger for a co-reviewer an operator has just +// removed, skip one they have just required, or record that a head is reviewed +// by a set that no longer gates it — so the verdicts the reviewer configuration +// decides revalidate it (overrideChanged) inside their own commit point, the CAS +// mutation that claims the trigger, reserves the slot or writes the dedupe +// marker. Checking it in a separate read here would leave exactly the window it +// is meant to close: SetReviewers commits in between, and the mutation goes on +// to apply the decision the new configuration would not have made. func (s *Service) applyFire(ctx context.Context, cfg Config, round Round, obs engine.Observation, d engine.FireDecision, now time.Time) (PumpResult, error) { - // The decision was made from a configuration that may have been replaced - // since. Acting on it would post a trigger for a co-reviewer an operator has - // just removed, skip one they have just required, or record that a head is - // reviewed by a set that no longer gates it — so only the verdicts the - // reviewer configuration decides pay for the re-read. FireNo is by far the - // most common outcome of a pump, and a Load is four API calls against the - // git-backed store. - if fireFollowsReviewers(d.Verdict) { - if st, _, err := s.store.Load(ctx); err == nil && overrideChanged(&st, round.Repo, cfg) { - return PumpResult{Action: "lost_race", Repo: round.Repo, PR: round.PR, Head: round.Head, - Reason: "reviewer configuration changed while deciding"}, nil - } - } switch d.Verdict { case engine.FireDrop: return s.abandonRound(ctx, round, "pr closed", "skipped") case engine.FireDedupe: - return s.dedupeRound(ctx, round, now, d.Reason) + return s.dedupeRound(ctx, cfg, round, now, d.Reason) case engine.FireCoOnly: return s.fireCoOnly(ctx, cfg, round, d.PostCo, d.Reason, now) case engine.FireCoDeferred: @@ -714,24 +711,6 @@ func (s *Service) applyFire(ctx context.Context, cfg Config, round Round, obs en } } -// fireFollowsReviewers reports whether a verdict was decided by the repository's -// reviewer configuration: the four that post a command it chose, and FireDedupe, -// whose completed round asserts that everyone this repository gates on has -// already answered the head. Dedupe posts nothing, so SetReviewers cannot see it -// coming — the round is still queued when the override lands, and a requeue of a -// queued round is a no-op — yet the marker it writes is exactly what stops the -// newly required reviewer from ever being asked. -// -// The rest either write nothing to GitHub or write a round transition the -// override has no say in, and their round-identity CAS already guards them. -func fireFollowsReviewers(v engine.FireVerdict) bool { - switch v { - case engine.FireCoOnly, engine.FireCoDeferred, engine.FireAdopt, engine.FirePost, engine.FireDedupe: - return true - } - return false -} - func mapFireNo(reason string) string { switch { case strings.Contains(reason, "could not read head"): @@ -777,7 +756,7 @@ func (s *Service) abandonRound(ctx context.Context, round Round, reason, action // dedupeRound completes a not-yet-fired round because the bot already reviewed // its head, leaving the completed round as the dedupe marker (v2's Fired[key]). -func (s *Service) dedupeRound(ctx context.Context, round Round, now time.Time, reason string) (PumpResult, error) { +func (s *Service) dedupeRound(ctx context.Context, cfg Config, round Round, now time.Time, reason string) (PumpResult, error) { result := PumpResult{Action: "deduped", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: reason} if s.cfg.DryRun { return result, nil @@ -786,7 +765,13 @@ func (s *Service) dedupeRound(ctx context.Context, round Round, now time.Time, r updated, err := s.store.Update(ctx, func(st *State) error { deduped = false r := st.Round(round.Repo, round.PR) - if !sameRound(r, round) || !r.FireEligible(now) { + // The marker this writes asserts that everyone the repository gates on has + // already answered the head, so a reviewer change committed since the + // decision voids it — and voids it permanently: dedupe posts nothing, so + // SetReviewers cannot see it coming, the round is still queued when the + // override lands (requeuing a queued round is a no-op), yet the marker is + // exactly what stops the newly required reviewer from ever being asked. + if !sameRound(r, round) || !r.FireEligible(now) || overrideChanged(st, round.Repo, cfg) { return ErrNoChange } if err := r.Dedupe(now); err != nil { @@ -853,7 +838,9 @@ func (s *Service) fireRound(ctx context.Context, cfg Config, round Round, obs en return ErrNoChange } r := st.Round(round.Repo, round.PR) - if !sameRound(r, round) || !r.FireEligible(now) { + // postCo below was chosen by cfg's reviewers; a change since means the + // claims written here are for a set the operator has replaced. + if !sameRound(r, round) || !r.FireEligible(now) || overrideChanged(st, round.Repo, cfg) { return ErrNoChange } if err := r.Reserve(token, s.cfg.WriterID(), now); err != nil { @@ -909,13 +896,15 @@ func (s *Service) fireRound(ctx context.Context, cfg Config, round Round, obs en return PumpResult{Action: "fired", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: reason}, nil } - // Reserve the slot, then post the command. + // Reserve the slot, then post the command. The reservation is this fire's + // commit point — nothing is posted before it — so it is where the reviewer + // configuration the decision used is revalidated. reserved, err := s.store.Update(ctx, func(st *State) error { if st.FireSlot != nil { return ErrNoChange } r := st.Round(round.Repo, round.PR) - if !sameRound(r, round) || !r.FireEligible(now) { + if !sameRound(r, round) || !r.FireEligible(now) || overrideChanged(st, round.Repo, cfg) { return ErrNoChange } if err := r.Reserve(token, s.cfg.WriterID(), now); err != nil { @@ -1026,7 +1015,9 @@ func (s *Service) fireCoOnly(ctx context.Context, cfg Config, round Round, login updated, err := s.store.Update(ctx, func(st *State) error { claimed = claimed[:0] r := st.Round(round.Repo, round.PR) - if !sameRound(r, round) || !r.FireEligible(now) { + // The claim is what authorizes the posts below, so the reviewer set that + // chose them must still be the configured one when it commits. + if !sameRound(r, round) || !r.FireEligible(now) || overrideChanged(st, round.Repo, cfg) { return ErrNoChange } for _, login := range logins { @@ -1394,7 +1385,10 @@ func (s *Service) fireCoDeferred(ctx context.Context, cfg Config, round Round, d updated, err := s.store.Update(ctx, func(st *State) error { adopted, claimed = 0, claimed[:0] r := st.Round(round.Repo, round.PR) - if !sameRound(r, round) || (r.Phase != PhaseQueued && r.Phase != PhaseAwaitingRetry) { + // As in fireCoOnly: the claims and adoptions written here name the + // co-reviewers cfg chose, so a reviewer change since voids them. + if !sameRound(r, round) || (r.Phase != PhaseQueued && r.Phase != PhaseAwaitingRetry) || + overrideChanged(st, round.Repo, cfg) { return ErrNoChange } changed := false diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index f60e13e1..34a5a9d3 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -166,6 +166,45 @@ func TestReviewAtHeadCompletesRound(t *testing.T) { } } +// TestFireSlotHeldUntilPrimaryAcknowledges separates convergence from slot +// release. A repository may leave the primary out of its required set (required +// Codex only), and then the round converges the moment Codex answers — while the +// account-metered command it posted is still unacknowledged. Completing there +// would hand the slot to the next PR mid-review, which is the serialization the +// whole queue exists for. +func TestFireSlotHeldUntilPrimaryAcknowledges(t *testing.T) { + p := withCodex(policy, "@codex review") + p.RequiredBots = []string{dialect.CodexBotLogin} + obs := Observation{Head: "abcdef123", Open: true, + Reviews: []ReviewSeen{{Bot: dialect.CodexBotLogin, ReviewID: 4, Commit: "abcdef1234567890", SubmittedAt: t0.Add(time.Minute)}}} + + r := firedRound(t, "abcdef123") + if got := Completion(r, obs, p); !got.Done { + t.Fatalf("the only required reviewer answered; want a converged round, got %+v", got) + } + if tr := Progress(r, state.AccountQuota{}, obs, t0.Add(2*time.Minute), p); tr.Outcome != KeepWaiting { + t.Fatalf("converged is not acknowledged — the slot must stay held, got %+v", tr) + } + // The primary reacts to the command: the slot has done its job, so the + // converged round completes. + acked := obs + acked.Reacted = true + if tr := Progress(r, state.AccountQuota{}, acked, t0.Add(2*time.Minute), p); tr.Outcome != OutComplete { + t.Fatalf("an acknowledged converged round must complete, got %+v", tr) + } + // It never reacts: the in-flight window ends the wait rather than buying a + // second metered review no configured reviewer asked for. + if tr := Progress(r, state.AccountQuota{}, obs, t0.Add(16*time.Minute), p); tr.Outcome != OutComplete { + t.Fatalf("the in-flight timeout must end a converged round, not re-fire it, got %+v", tr) + } + // A co-only round spent no quota and holds no slot, so it completes at once. + co := firedRound(t, "abcdef123") + co.CoOnly = true + if tr := Progress(co, state.AccountQuota{}, obs, t0.Add(2*time.Minute), p); tr.Outcome != OutComplete { + t.Fatalf("a co-only round has no primary command to wait on, got %+v", tr) + } +} + func TestInflightTimeoutCarriesCooldown(t *testing.T) { r := firedRound(t, "abcdef123") now := t0.Add(16 * time.Minute) diff --git a/internal/engine/progress.go b/internal/engine/progress.go index 2b82c0c9..99b5a5ba 100644 --- a/internal/engine/progress.go +++ b/internal/engine/progress.go @@ -125,31 +125,35 @@ func Progress(r state.Round, q state.AccountQuota, obs Observation, now time.Tim return Transition{Outcome: OutRetry, Reason: "review failed", RetryAt: now.Add(p.retryBackoff())} } - if completion.Done { + // Convergence and slot release answer different questions. A repository may + // leave the primary out of its required set (required Codex only), and then + // completion is done the moment that co-reviewer answers — while the + // account-metered command this round posted is still unacknowledged. + // Completing here would release the fire slot, letting the next PR spend the + // shared allowance alongside a review that is still running, and would leave a + // late account-block reply landing on a completed round nobody progresses. So + // a round that spent the quota stays fired until the primary speaks (below) or + // its in-flight timeout gives up on it; a co-only round posted no command of + // its own and has nothing to wait for. + if completion.Done && (r.Phase != state.PhaseFired || r.CoOnly) { return Transition{Outcome: OutComplete, Reason: "feedback complete"} } if r.Phase == state.PhaseFired { - // A bare reaction acknowledges the command; the review is still running. - if obs.Reacted { - return Transition{Outcome: OutReviewing, Reason: "bot reacted"} - } - // Any other bot comment in the round window acknowledges it too — but an - // account-block/paused/already-reviewed notice is not an ack (v2), and - // neither is the in-progress summary of a PREVIOUS round edit... which - // it cannot be: UpdatedAt gates the window. An in-progress summary IS - // an ack that reviewing started. - for _, ev := range obs.Events { - if !sameBot(ev.Bot, p.Bot) || ev.CommentID == r.CommandID || ev.UpdatedAt.Before(firedAt) { - continue - } - switch ev.Kind { - case dialect.EvRateLimited, dialect.EvPaused, dialect.EvAlreadyReviewed: - continue + if reason, acked := primaryAck(r, obs, p, firedAt); acked { + if completion.Done { + return Transition{Outcome: OutComplete, Reason: "feedback complete"} } - return Transition{Outcome: OutReviewing, Reason: "bot responded"} + return Transition{Outcome: OutReviewing, Reason: reason} } if now.Sub(firedAt) > p.InflightTimeout { + // Nothing more is owed: everything this repository gates on has + // answered and the slot has been held for the whole in-flight window, + // so the serialization the wait exists for is satisfied. Re-firing + // would buy another metered review no configured reviewer asked for. + if completion.Done { + return Transition{Outcome: OutComplete, Reason: "feedback complete; primary never acknowledged"} + } return Transition{Outcome: OutRetry, Reason: "in-flight timeout", RetryAt: now.Add(p.retryBackoff())} } return Transition{Outcome: KeepWaiting, Reason: "review in flight"} @@ -162,6 +166,31 @@ func Progress(r state.Round, q state.AccountQuota, obs Observation, now time.Tim return Transition{Outcome: KeepWaiting, Reason: "reviewing"} } +// primaryAck reports whether the primary has acknowledged this round's command, +// and how — the condition that releases the fire slot. +// +// A bare reaction acknowledges it; so does any other comment of the bot's in the +// round window — but an account-block/paused/already-reviewed notice is not an +// ack (v2), and neither is the in-progress summary of a PREVIOUS round edit... +// which it cannot be: UpdatedAt gates the window. An in-progress summary IS an +// ack that reviewing started. +func primaryAck(r state.Round, obs Observation, p Policy, firedAt time.Time) (string, bool) { + if obs.Reacted { + return "bot reacted", true + } + for _, ev := range obs.Events { + if !sameBot(ev.Bot, p.Bot) || ev.CommentID == r.CommandID || ev.UpdatedAt.Before(firedAt) { + continue + } + switch ev.Kind { + case dialect.EvRateLimited, dialect.EvPaused, dialect.EvAlreadyReviewed: + continue + } + return "bot responded", true + } + return "", false +} + // resolveBlockWindow ports v2's requeueInflight window logic: reuse the // standing block when the SAME edited account-quota comment is re-observed // (CodeRabbit edits one comment in place — a re-observation must not extend From 0ff2ba1c8d07cc84ceb04567e6647aee3cf3979c Mon Sep 17 00:00:00 2001 From: kristofferR Date: Mon, 27 Jul 2026 04:54:58 +0200 Subject: [PATCH 09/37] Hold the same fire slot on the loop's way out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Progress now keeps the slot until the primary acknowledges the command a round fired, but the loop releases that slot too: completeWaitRound ends the wait by completing the round, and the two convergence callers reach it on exactly the case the engine rule was written for — a required set that omits the primary, converged on a co-reviewer alone while the `@coderabbitai review` this round posted is still unanswered. Ask the engine the same question there (PrimaryAckPending, exported for it) and leave such a round fired. Its head is already the dedup marker, so nothing re-fires it, and any Pump finishes it on the acknowledgement or on the in-flight timeout. The timed-out and never-fired waits still end unconditionally: neither is a claim that the round is done. --- internal/crq/feedback.go | 29 +++++++++++++++++++++----- internal/crq/feedback_test.go | 39 +++++++++++++++++++++++++++++++++++ internal/engine/progress.go | 15 ++++++++++++++ 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index d4c3e64a..35526617 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -58,6 +58,12 @@ type FeedbackReport struct { // window entirely — it cannot apply to a round that never spends quota. PrimaryUnavailable bool `json:"primary_review_unavailable,omitempty"` PrimaryUnavailableReason string `json:"primary_review_unavailable_reason,omitempty"` + // PrimaryAckPending reports that the round still holds the fire slot for a + // review command the primary has not acknowledged. A required set that omits + // the primary converges without it, so the loop must not read convergence as + // permission to release the slot. Not serialized: the feedback JSON contract + // is frozen. + PrimaryAckPending bool `json:"-"` } // CoReviewerStatus is one co-reviewer's observed state for the current head. @@ -126,6 +132,7 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe } completion := engine.Completion(completionRound, obs.eng, cfg.policy()) report.ReviewedBy = completion.ReviewedBy + report.PrimaryAckPending = engine.PrimaryAckPending(completionRound, obs.eng, cfg.policy()) // Completion does not always arrive as a review: a clean-summary comment, a // paired completion reply and a co-reviewer check run all satisfy it. Anchor // the settle window on the newest of ANY of them, or a round completed by a @@ -540,7 +547,7 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport // timeout so the caller retries later instead. A skipped wait result is // terminal, not retryable, so preserve it as a skipped report. if waitResult.Action == "skipped" { - s.completeWaitRound(ctx, repo, pr, "") + s.completeWaitRound(ctx, repo, pr, "", false) } return FeedbackReport{Status: status, Repo: NormalizeRepo(repo), PR: pr, Head: waitResult.Head, Reason: waitResult.Reason, ReviewedBy: map[string]bool{}, Findings: []dialect.Finding{}}, code, nil } @@ -593,7 +600,7 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport report.Status = "feedback" if allReviewed(report.ReviewedBy) { report.Reason = "all required reviewers finished; address findings, push once, and resolve threads" - s.completeWaitRound(ctx, repo, pr, head) + s.completeWaitRound(ctx, repo, pr, head, report.PrimaryAckPending) } else if report.CodeRabbitDeferred && engine.DoneExceptWithEvidence(report.ReviewedBy, s.cfg.Bot, dialect.CodexBotLogin) { // Degraded round: every required bot except the rate-limited // CodeRabbit has finished. These findings are this round's work — @@ -631,7 +638,7 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport } if s.cfg.SettleWindow <= 0 || s.clock().Sub(settledAt) >= s.cfg.SettleWindow { if report.Converged { - s.completeWaitRound(ctx, repo, pr, head) + s.completeWaitRound(ctx, repo, pr, head, report.PrimaryAckPending) } return report, 0, nil } @@ -686,7 +693,7 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport // A degraded round must not be completed on timeout: marking the head // reviewed would silently cancel the still-owed CodeRabbit review. if !report.CodeRabbitDeferred { - s.completeWaitRound(ctx, repo, pr, head) + s.completeWaitRound(ctx, repo, pr, head, false) } if len(report.Findings) > 0 { report.Status = "feedback" @@ -794,7 +801,16 @@ func (s *Service) pushWaitDeadline(ctx context.Context, repo string, pr int, hea // completeWaitRound ends the wait by completing the fired/reviewing round. The // completed round remains as the "this head was reviewed" dedup marker, so a // subsequent enqueue/needsReview at the same head is deduped rather than re-fired. -func (s *Service) completeWaitRound(ctx context.Context, repo string, pr int, head string) { +// +// holdUnacked leaves alone a round still holding the fire slot for a review +// command the primary has not acknowledged: with the primary outside the +// required set the round converges without it, and completing here would release +// the slot to the next pull request while this one's metered command is +// unanswered — the same release Progress now withholds. The round stays fired +// and any Pump finishes it, on the acknowledgement or on the in-flight timeout. +// Only the convergence callers pass it; a timed-out or never fired wait must +// still end. +func (s *Service) completeWaitRound(ctx context.Context, repo string, pr int, head string, holdUnacked bool) { repo = NormalizeRepo(repo) changed := false state, err := s.store.Update(ctx, func(st *State) error { @@ -806,6 +822,9 @@ func (s *Service) completeWaitRound(ctx context.Context, repo string, pr int, he if head != "" && r.Head != head { return ErrNoChange } + if holdUnacked && st.FireSlot != nil && st.FireSlot.Key == QueueKey(repo, pr) { + return ErrNoChange + } if err := r.Complete(); err != nil { return err } diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index b28682ca..7eac3872 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -2201,3 +2201,42 @@ func TestExcludeSkipNoticeIsTargeted(t *testing.T) { // Which occurrences of a stable id count as settled is decided by the head each // one names, not by this filter — see // TestCoReplayBugbotSiblingSettlementUsesHead, which drives the real threads. + +// The loop completes the round it waited on, which releases the fire slot. With +// the primary outside the required set the round converges without it, so that +// completion would hand the next pull request a second concurrent metered +// command while this one's is still unanswered. Holding leaves the round fired +// for a Pump to finish — on the acknowledgement, or on the in-flight timeout. +func TestCompleteWaitRoundHoldsAnUnacknowledgedFireSlot(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + now := time.Now().UTC() + repo, pr, head := "o/r", 4, "aaaaaaaa1" + seedRound(t, store, cfg, repo, pr, head, PhaseFired, now, 12) + + svc.completeWaitRound(ctx, repo, pr, head, true) + if got := roundPhase(t, store, repo, pr); got != PhaseFired { + t.Fatalf("phase = %s, want the round left fired while the primary is silent", got) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if st.FireSlot == nil { + t.Fatal("the fire slot must stay held until the primary acknowledges") + } + + svc.completeWaitRound(ctx, repo, pr, head, false) + if got := roundPhase(t, store, repo, pr); got != PhaseCompleted { + t.Fatalf("phase = %s, want an acknowledged round completed as before", got) + } + st, _, err = store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if st.FireSlot != nil { + t.Fatalf("completing the round must release the slot, got %+v", st.FireSlot) + } +} diff --git a/internal/engine/progress.go b/internal/engine/progress.go index 99b5a5ba..d206b1a2 100644 --- a/internal/engine/progress.go +++ b/internal/engine/progress.go @@ -191,6 +191,21 @@ func primaryAck(r state.Round, obs Observation, p Policy, firedAt time.Time) (st return "", false } +// PrimaryAckPending reports whether a round is still holding the fire slot for a +// command the primary has not acknowledged — the condition Progress waits on +// above, exported because the LOOP releases that same slot when it completes the +// round it waited on, and convergence is no more permission to do so there. +// +// Only a fired round can be holding it: a co-only one posted no command of its +// own, and a reviewing one released the slot when the ack it is named for landed. +func PrimaryAckPending(r state.Round, obs Observation, p Policy) bool { + if r.Phase != state.PhaseFired || r.FiredAt == nil || r.CoOnly { + return false + } + _, acked := primaryAck(r, obs, p, r.FiredAt.UTC()) + return !acked +} + // resolveBlockWindow ports v2's requeueInflight window logic: reuse the // standing block when the SAME edited account-quota comment is re-observed // (CodeRabbit edits one comment in place — a re-observation must not extend From cb869fb4e5f9df95d644335374b69ec1ea79b89d Mon Sep 17 00:00:00 2001 From: kristofferR Date: Mon, 27 Jul 2026 05:21:30 +0200 Subject: [PATCH 10/37] Ask the current configuration, not the one the decision read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four gaps review found in the reviewer override, each one a decision made from a configuration that no longer holds by the time it is written. Completion is the one that cannot be undone: reopenForChangedReviewers leaves an in-flight round alone because it is already going to answer, so a required reviewer added between Progress deciding and applyTransition writing is caught by neither — the completed marker dedupes the head under the set that no longer gates it. Revalidate the override inside that write, as the fire paths already do. The self-heal sweep needs the same: its claim is what authorizes the post, and it was the one trigger path that could still post for a bot `crq reviewers set` had removed. A repository may also name the primary when the fleet configuration carried no entry for it — not enabled, not fleet-required — and skipping it as "not a co-reviewer here" costs the PRIMARY the wording and check-run hooks its own evidence is read through, so a check-only clean result is never fetched. Add the silenced entry the fleet parse would have had. And promoteTrigger can now tell a `never` that fell out of the registry defaults from one CRQ_COBOT__TRIGGER named: the fleet parse lets the operator's value win over the required trigger, and a per-repo requirement must not switch a command back on behind them. Finally, writer identity: host and pid do not name a process over time. A container daemon restarts as pid 1 and pids are reused, so an old binary could inherit a capable predecessor's recorded capabilities and vouch for itself while ignoring every override. Stamp a per-run id, and let the autoreview leader use WriterID rather than spelling the format twice. --- README.md | 4 +- internal/crq/auto.go | 7 +- internal/crq/config.go | 43 +++++--- internal/crq/repoconfig_test.go | 171 ++++++++++++++++++++++++++++++++ internal/crq/reviewers.go | 24 ++++- internal/crq/service.go | 26 ++++- internal/state/dashboard.go | 8 +- internal/state/state.go | 7 +- 8 files changed, 258 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index c9acec41..b39c884f 100644 --- a/README.md +++ b/README.md @@ -515,7 +515,9 @@ the same fire step as the CodeRabbit one. Bugbot and Macroscope default to `self already auto-review every push: crq stays silent unless a bot it has seen working misses the current head for longer than `CRQ_COBOT__GRACE`. In every mode crq suppresses the trigger when the bot auto-reviews, has already reviewed the head, has a check run in flight, or has a live command on the -PR — so no bot is ever double-asked. +PR — so no bot is ever double-asked. A mode you set explicitly wins over requiredness, per-repo +requiredness included: `crq reviewers set` gives a required bot the trigger its registry default +would have had, but never overrides a `never` you configured yourself. A co-reviewer that joins a round on its own (an actionable comment, a review, a check run) gates that round dynamically: convergence waits for it even though it isn't required. An exhaustion notice — for diff --git a/internal/crq/auto.go b/internal/crq/auto.go index 6c1cb547..b73456b7 100644 --- a/internal/crq/auto.go +++ b/internal/crq/auto.go @@ -3,8 +3,6 @@ package crq import ( "context" "errors" - "fmt" - "os" "strings" "time" @@ -21,7 +19,10 @@ type AutoOptions struct { var errLostLeadership = errors.New("lost autoreview leadership mid-pass") func (s *Service) AutoReview(ctx context.Context, opts AutoOptions) error { - owner := fmt.Sprintf("host=%s pid=%d", s.cfg.Host, os.Getpid()) + // The same identity capabilities are recorded under, so LaggingWriters can + // ask whether the process holding the lease understands what it is deciding + // from. Spelling it out here again would let the two drift apart. + owner := s.cfg.WriterID() token := randomToken() for { held, err := s.acquireLeader(ctx, owner, token) diff --git a/internal/crq/config.go b/internal/crq/config.go index 9be51c11..9cb6d5dc 100644 --- a/internal/crq/config.go +++ b/internal/crq/config.go @@ -328,12 +328,18 @@ func listEnv(env map[string]string, key, fallback string) []string { // convergence via RequiredBots membership. Trigger and SelfHealGrace shape // when crq may post Command (see engine.DecideCoPost). type CoBotConfig struct { - Login string - Name string - Command string - Trigger engine.TriggerMode - Required bool - SelfHealGrace time.Duration + Login string + Name string + Command string + Trigger engine.TriggerMode + // TriggerExplicit records that CRQ_COBOT__TRIGGER named this mode, + // rather than it falling out of the registry defaults. The fleet parse lets + // that value win over the registry's required trigger, so a per-repo override + // promoting the bot to required must not quietly overrule it either — an + // operator who disabled a bot's command asked for that on every repository. + TriggerExplicit bool + Required bool + SelfHealGrace time.Duration } // parseCoBots resolves the enabled co-reviewers from CRQ_COBOTS (default all @@ -358,16 +364,16 @@ func resolveCoBot(env map[string]string, co dialect.CoReviewer, required bool) C command = stringEnvAllowEmpty(env, co.LegacyCommandEnv, command) } command = strings.TrimSpace(command) - trigger := base.Trigger + trigger, explicit := base.Trigger, false switch v := engine.TriggerMode(strings.ToLower(strings.TrimSpace(env["CRQ_COBOT_"+key+"_TRIGGER"]))); v { case engine.TriggerNever, engine.TriggerSelfHeal, engine.TriggerAlways: - trigger = v + trigger, explicit = v, true } if command == "" { // No trigger command means crq can never post one, whatever the mode. trigger = engine.TriggerNever } - base.Command, base.Trigger = command, trigger + base.Command, base.Trigger, base.TriggerExplicit = command, trigger, explicit base.SelfHealGrace = durationEnv(env, "CRQ_COBOT_"+key+"_GRACE", defaultSelfHealGrace) return base } @@ -533,10 +539,19 @@ func ownerOf(repo string) string { return owner } -// WriterID identifies this PROCESS in the shared state, in the same form the -// autoreview leader records. A new CLI and an old daemon commonly run on one -// machine, so a per-host key would let the upgraded CLI's write vouch for the -// daemon that has not been upgraded. +// processRun distinguishes this RUN of crq from an earlier one that happened to +// get the same pid. Host and pid do not identify a process over time — a +// containerized daemon restarts as pid 1, and ordinary pids are reused — so +// without it a replacement process inherits its predecessor's recorded +// capabilities: an old binary restarted into a capable one's pid would vouch for +// itself, and LaggingWriters would report no incompatible writer while that +// daemon ignores every repository override. +var processRun = randomToken()[:8] + +// WriterID identifies this PROCESS in the shared state, and is what the +// autoreview leader records as its owner. A new CLI and an old daemon commonly +// run on one machine, so a per-host key would let the upgraded CLI's write vouch +// for the daemon that has not been upgraded. func (c Config) WriterID() string { - return fmt.Sprintf("host=%s pid=%d", c.Host, os.Getpid()) + return fmt.Sprintf("host=%s pid=%d run=%s", c.Host, os.Getpid(), processRun) } diff --git a/internal/crq/repoconfig_test.go b/internal/crq/repoconfig_test.go index f07b67e6..2c54571f 100644 --- a/internal/crq/repoconfig_test.go +++ b/internal/crq/repoconfig_test.go @@ -2,6 +2,7 @@ package crq import ( "context" + "strings" "testing" "time" @@ -478,3 +479,173 @@ func TestChangingRequirementsLeavesClosedPRsAlone(t *testing.T) { t.Errorf("merged round = %#v, want it left completed — a closed PR cannot be stranded", round) } } + +// The other half of TestOverrideKeepsTheSilencedPrimaryEntry: when the fleet +// configuration never carried an entry for a registry-bot primary (not enabled, +// not fleet-required), a repository that gates on it has to gain one. Skipping +// it as "not a co-reviewer here" leaves observation without that bot's wording +// and check-run hooks, so a check-only clean result is never fetched and the +// primary this repository waits for stays pending until the round times out. +func TestOverrideAddsTheSilencedPrimaryEntryTheFleetLacked(t *testing.T) { + fleet := isolatedConfig(t, map[string]string{ + // Bugbot's login is cursor[bot]: naming it primary makes the primary a + // registry bot, and neither CRQ_COBOTS nor CRQ_REQUIRED_BOTS mentions it. + "CRQ_BOT": "cursor[bot]", + "CRQ_REVIEW_CMD": "bugbot run", + "CRQ_COBOTS": "codex", + "CRQ_REQUIRED_BOTS": dialect.CodexBotLogin, + "CRQ_COBOT_CODEX_CMD": "@codex review", + }) + for _, cb := range fleet.CoBots { + if sameBot(cb.Login, fleet.Bot) { + t.Fatalf("fleet CoBots = %+v already carry the primary; this test needs the gap", fleet.CoBots) + } + } + + got := fleet.ForRepo(RepoReviewers{ + Required: []string{"cursor[bot]", dialect.CodexBotLogin}, SetRequired: true, + }) + var primary *CoBotConfig + for i, cb := range got.CoBots { + if sameBot(cb.Login, got.Bot) { + primary = &got.CoBots[i] + } + } + if primary == nil { + t.Fatalf("CoBots = %+v, want the primary's registry entry so its evidence can be read", got.CoBots) + } + // Silenced: it is asked as the primary, and asking the same bot twice is the + // bug the fleet parse silences it for. + if primary.Trigger != engine.TriggerNever { + t.Errorf("primary entry = %+v, want trigger never — it is triggered as the primary", *primary) + } + if !got.coChecksRelevant() { + t.Error("the primary's check runs must be fetched; a check-only clean result is its whole answer") + } + // And it is still exactly one reviewer, account-metered, not a second free one. + if p, ok := got.Primary(); !ok || !sameBot(p.Login, got.Bot) { + t.Errorf("Primary() = %+v/%v, want the configured primary", p, ok) + } + metered := 0 + for _, r := range got.Reviewers { + if r.Metered() { + metered++ + } + } + if metered != 1 { + t.Errorf("reviewers = %+v, want exactly one metered entry", got.Reviewers) + } +} + +// An operator who sets CRQ_COBOT__TRIGGER=never has disabled that bot's +// command everywhere: the fleet parse already lets that value win over the +// registry's required trigger. A repository requiring the bot must not be able +// to turn it back on — that would post the command the operator switched off. +func TestOverrideKeepsAnExplicitNeverTrigger(t *testing.T) { + fleet := isolatedConfig(t, map[string]string{ + "CRQ_COBOTS": "codex", + "CRQ_COBOT_CODEX_TRIGGER": "never", + }) + got := fleet.ForRepo(RepoReviewers{ + CoBots: []string{"codex"}, SetCoBots: true, + Required: []string{"codex"}, SetRequired: true, + }) + for _, cb := range got.CoBots { + if cb.Name != "codex" { + continue + } + if cb.Trigger != engine.TriggerNever { + t.Errorf("codex = %+v, want the explicit never kept — the fleet parse honours it too", cb) + } + return + } + t.Fatalf("CoBots = %+v, want codex required here", got.CoBots) +} + +// Completing a round writes the "this head was reviewed" dedup marker, and +// reopenForChangedReviewers deliberately leaves an in-flight round alone — it is +// already going to answer. A required reviewer added between Progress deciding +// and that write is therefore caught by neither: the marker dedupes the head +// under the set that no longer gates it, and nothing re-fires it. +func TestCompletionIsRevalidatedInsideItsWrite(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.CoBots = codexCoBots(nil) + store := NewMemoryStore(cfg) + hooked := &hookedStore{StateStore: store} + gh := newFakeGitHub() + repo, pr, head := "o/r", 12, "aaaaaaaa1" + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} + svc := NewService(cfg, gh, hooked, nil) + now := time.Now().UTC() + seedRound(t, store, cfg, repo, pr, head, PhaseFired, now, 21) + + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + stale := svc.cfgFor(st, repo) + hooked.hook = func() { + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}); err != nil { + t.Error(err) + } + } + done := engine.Transition{Outcome: engine.OutComplete, Reason: "feedback complete"} + if _, err := hooked.Update(ctx, func(st *State) error { + return svc.applyTransition(st, st.Round(repo, pr), done, now, stale) + }); err != nil { + t.Fatal(err) + } + if got := roundPhase(t, store, repo, pr); got != PhaseFired { + t.Fatalf("phase = %s, want the stale completion dropped so the new reviewer is still asked", got) + } +} + +// The self-heal sweep claims a co-reviewer's trigger post under CAS and then +// posts it. The claim is what authorizes the post, so — as in fireCoOnly — the +// reviewer set that chose the bot has to still be the configured one when the +// claim commits, or `crq reviewers set` reports a bot disabled while crq is +// asking it for a review. +func TestSelfHealTriggerIsRevalidatedInsideItsClaim(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.RequiredBots = []string{cfg.Bot, dialect.CodexBotLogin} + cfg.CoBots = codexCoBots(cfg.RequiredBots) + store := NewMemoryStore(cfg) + hooked := &hookedStore{StateStore: store} + gh := newFakeGitHub() + repo, pr, head := "o/r", 13, "aaaaaaaa1" + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} + svc := NewService(cfg, gh, hooked, nil) + now := time.Now().UTC() + seedRound(t, store, cfg, repo, pr, head, PhaseFired, now, 22) + + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + round := *st.Round(repo, pr) + stale := svc.cfgFor(st, repo) + // Nothing observed for the head yet, so an always-mode trigger wants posting. + obs := engine.Observation{Head: head, Open: true} + hooked.hook = func() { + // The operator drops the co-reviewer, keeping only the primary. + if _, err := svc.SetReviewers(ctx, repo, []string{}, []string{cfg.Bot}); err != nil { + t.Error(err) + } + } + svc.selfHealCoReviewers(ctx, stale, round, obs, now) + + for _, body := range gh.posted { + if strings.Contains(body, "@codex") { + t.Errorf("posted %q for a co-reviewer the operator had just removed", body) + } + } + st, _, err = store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if c := st.Round(repo, pr).Co(dialect.CodexBotLogin); c.ClaimedAt != nil || c.CommandID != 0 { + t.Errorf("codex bookkeeping = %+v, want no claim for a removed reviewer", c) + } +} diff --git a/internal/crq/reviewers.go b/internal/crq/reviewers.go index 74b3f890..95dcdb12 100644 --- a/internal/crq/reviewers.go +++ b/internal/crq/reviewers.go @@ -231,6 +231,23 @@ func (c Config) ForRepo(ov RepoReviewers) Config { keep = append(keep, cb) have[dialect.NormalizeBotName(cb.Login)] = true } + // A primary that is itself a registry bot but that the fleet neither enabled + // nor required has no entry to preserve above — and this repository naming it + // is exactly when its evidence has to be read. Add the silenced entry the + // fleet parse would have carried: without it observation loses that bot's + // wording and check-run hooks, so a check-only clean result is never fetched + // and the primary stays pending until the round times out. Recording it here + // also keeps the loop below from adding it as an ordinary co-reviewer, which + // would ask the same bot twice. + if primary := c.Bot; primary != "" && !have[dialect.NormalizeBotName(primary)] && + (containsBot(required, primary) || containsBot(enabled, primary)) { + if cb, ok := c.knownCoBot(primary); ok { + cb.Required = containsBot(required, primary) + cb.Trigger = engine.TriggerNever // triggered as the primary; asking twice is the bug + keep = append(keep, cb) + have[dialect.NormalizeBotName(primary)] = true + } + } // A repository may choose a bot the fleet does not enable — otherwise // "which bots for which project" only ever subtracts. Its configuration // comes from the registry, since there is no per-bot environment for a repo. @@ -267,9 +284,12 @@ func (c Config) ForRepo(ov RepoReviewers) Config { // defaults to never and only becomes always when required. Retaining that entry // while making the bot required leaves the engine waiting for evidence no // command was ever posted for. Only a never trigger is promoted, so an operator -// who deliberately configured selfheal keeps it. +// who deliberately configured selfheal keeps it — and only one that FELL OUT of +// the registry defaults, since CRQ_COBOT__TRIGGER=never already wins over +// the required trigger fleet-wide and posting the command a repository override +// away from that would be crq overruling the operator. func promoteTrigger(cb CoBotConfig) CoBotConfig { - if !cb.Required || cb.Trigger != engine.TriggerNever || cb.Command == "" { + if !cb.Required || cb.Trigger != engine.TriggerNever || cb.Command == "" || cb.TriggerExplicit { return cb } if co, ok := dialect.CoReviewerByName(cb.Name); ok && co.RequiredTrigger != "" { diff --git a/internal/crq/service.go b/internal/crq/service.go index a8da231c..14a731f7 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -518,7 +518,7 @@ func (s *Service) progressSlotRound(ctx context.Context, slot Round) (PumpResult if r == nil || st.FireSlot == nil || st.FireSlot.Token != slot.Token { return ErrNoChange } - return s.applyTransition(st, r, tr, now) + return s.applyTransition(st, r, tr, now, cfg) }) if err != nil { return PumpResult{}, err @@ -536,10 +536,23 @@ func (s *Service) progressSlotRound(ctx context.Context, slot Round) (PumpResult // applyTransition applies a fired/reviewing round's engine Transition to state: // the round transition plus any fire-slot release and account-quota block. -func (s *Service) applyTransition(st *State, r *Round, tr engine.Transition, now time.Time) error { +// +// cfg is the configuration Progress decided from, revalidated here for the same +// reason applyFire revalidates its verdicts — this runs inside the CAS mutation, +// where the state it reads is the state the write lands on. +func (s *Service) applyTransition(st *State, r *Round, tr engine.Transition, now time.Time, cfg Config) error { key := QueueKey(r.Repo, r.PR) switch tr.Outcome { case engine.OutComplete: + // The completed round is the "this head was reviewed" dedup marker, and + // reopenForChangedReviewers deliberately leaves an in-flight round alone — + // it is already going to answer. A reviewer change that commits between + // the decision and this write would therefore be answered by neither: the + // marker dedupes the head under the set that no longer gates it. Drop the + // stale transition; the next pump decides again under the new set. + if overrideChanged(st, r.Repo, cfg) { + return ErrNoChange + } if err := r.Complete(); err != nil { return err } @@ -661,7 +674,7 @@ func (s *Service) sweepReviewing(ctx context.Context, st State, now time.Time) ( if r == nil || !sameRound(r, *target) || (r.Phase != PhaseFired && r.Phase != PhaseReviewing) { return ErrNoChange } - return s.applyTransition(st, r, tr, now) + return s.applyTransition(st, r, tr, now, cfg) }) if err != nil { return st, err @@ -1475,12 +1488,15 @@ func (s *Service) selfHealCoReviewers(ctx context.Context, cfg Config, round Rou // not serialized by the fire slot, so two concurrent pumps observing an // unset command would otherwise both post. A claim older than // triggerClaimTTL is stale (the poster died mid-flight) and may be - // re-claimed. + // re-claimed. As in fireCoOnly, the claim is what authorizes the post, so + // the reviewer set that chose this bot must still be the configured one + // when it commits — otherwise a bot `crq reviewers set` has just removed + // is asked for a review anyway. login := cp.Login claimed := false updated, err := s.store.Update(ctx, func(st *State) error { r := st.Round(round.Repo, round.PR) - if !sameRound(r, round) || r.Co(login).CommandID != 0 { + if !sameRound(r, round) || r.Co(login).CommandID != 0 || overrideChanged(st, round.Repo, cfg) { return ErrNoChange } if c := r.Co(login); c.ClaimedAt != nil && now.Sub(c.ClaimedAt.UTC()) < triggerClaimTTL { diff --git a/internal/state/dashboard.go b/internal/state/dashboard.go index e60caa2f..0ace6c27 100644 --- a/internal/state/dashboard.go +++ b/internal/state/dashboard.go @@ -191,10 +191,10 @@ func dash(s string) string { return s } -// hostName renders a round's writer id ("host=blue pid=4711") as the machine -// name the host column has always shown. The round stores the writer id because -// that is what capabilities are keyed by; the pid is bookkeeping for -// LaggingWriters, not something a reader of the table needs. +// hostName renders a round's writer id ("host=blue pid=4711 run=1a2b3c4d") as +// the machine name the host column has always shown. The round stores the writer +// id because that is what capabilities are keyed by; the pid and run id are +// bookkeeping for LaggingWriters, not something a reader of the table needs. func hostName(writer string) string { rest, ok := strings.CutPrefix(writer, "host=") if !ok { diff --git a/internal/state/state.go b/internal/state/state.go index 66214c44..1715aa25 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -113,7 +113,7 @@ type Round struct { Token string `json:"token,omitempty"` // reservation token (CAS race detection) // ByHost identifies the PROCESS that reserved this round, in the writer form - // "host= pid=" — the key NoteWriter records capabilities under, so + // "host= pid= run=" — the key NoteWriter records capabilities under, so // LaggingWriters can ask whether the process driving a fire understands the // configuration it is firing from. The dashboard shows the machine name. ByHost string `json:"by_host,omitempty"` @@ -374,8 +374,9 @@ func (s *State) NoteWriter(host string, caps int, now time.Time) { // back untouched, and keeps deciding from its own fleet-wide configuration. func (s *State) LaggingWriters(caps int, now time.Time) []string { acting := map[string]bool{} - // The leader identifies itself as "host= pid=", which is exactly the - // process identity capabilities are recorded under. + // The leader identifies itself as "host= pid= run=", which is + // exactly the process identity capabilities are recorded under — the run + // component is what keeps a restart into a reused pid from inheriting them. if s.Leader != nil && s.Leader.ExpiresAt.After(now) && strings.TrimSpace(s.Leader.Owner) != "" { acting[s.Leader.Owner] = true } From 9467b4b78ea6f7c36ecdd670588fa7f24f401fee Mon Sep 17 00:00:00 2001 From: kristofferR Date: Mon, 27 Jul 2026 05:32:17 +0200 Subject: [PATCH 11/37] Hold the slot for the command, not for the head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Progress and the loop both keep a converged round `fired` when the primary has not acknowledged the command it posted, so the slot is not handed to the next pull request while the account allowance is still in flight. But staying `fired` holds the slot only for as long as the round exists, and converging is exactly what tells the agent to push: `Enqueue` supersedes the round, `Normalize` drops a slot no round holds, and the next `Pump` buys a second metered review while the first is unanswered. The obligation belongs to the command, not to the head it was posted at. The loop now stamps `FireSlot.HoldUntil` in the same write that declines to complete the round, bounded by that command's in-flight window — the deadline crq would have given up at anyway, so an unanswered command cannot wedge the queue for longer than waiting for it would have. The fire gates ask `SlotHeld` rather than "is a round holding it", and `Normalize` keeps a slot the hold still covers. --- AGENTS.md | 7 ++++++- internal/crq/feedback.go | 20 +++++++++++++++--- internal/crq/feedback_test.go | 37 +++++++++++++++++++++++++++++++++ internal/crq/service.go | 2 +- internal/state/state.go | 39 ++++++++++++++++++++++++++++++++--- 5 files changed, 97 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8e36935f..209b67f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,12 @@ converse does not hold: convergence alone never releases the slot. A repository whose required set omits the primary converges as soon as its co-reviewers answer, and completing there would hand the slot to the next PR while the metered command is still unanswered — so a round that spent the quota stays -`fired` until the primary acknowledges or its in-flight timeout expires. +`fired` until the primary acknowledges or its in-flight timeout expires. Staying +`fired` holds the slot only while the round exists, and converging is what tells +the agent to push, so the loop also stamps `FireSlot.HoldUntil`: the hold belongs +to the command, not to the head it was posted at, and it outlives the supersede +that the success it reported invites. Every fire gate asks `SlotHeld`, not +"is there a round holding it". ## observe → decide → apply diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index 35526617..97d7dbfc 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -807,9 +807,10 @@ func (s *Service) pushWaitDeadline(ctx context.Context, repo string, pr int, hea // required set the round converges without it, and completing here would release // the slot to the next pull request while this one's metered command is // unanswered — the same release Progress now withholds. The round stays fired -// and any Pump finishes it, on the acknowledgement or on the in-flight timeout. -// Only the convergence callers pass it; a timed-out or never fired wait must -// still end. +// and any Pump finishes it, on the acknowledgement or on the in-flight timeout, +// and the hold is stamped on the slot so the push this success invites cannot +// drop it along with the superseded round. Only the convergence callers pass it; +// a timed-out or never fired wait must still end. func (s *Service) completeWaitRound(ctx context.Context, repo string, pr int, head string, holdUnacked bool) { repo = NormalizeRepo(repo) changed := false @@ -823,6 +824,19 @@ func (s *Service) completeWaitRound(ctx context.Context, repo string, pr int, he return ErrNoChange } if holdUnacked && st.FireSlot != nil && st.FireSlot.Key == QueueKey(repo, pr) { + // Leaving the round fired keeps the slot only while the round exists, + // and converging is precisely the loop's signal to push: the head + // advance that follows archives this round, and the slot would be + // released with the command it was taken for still unanswered. So + // record the hold on the slot itself, where it survives the supersede. + // Bounded by the in-flight window, the deadline Progress gives up at. + if r.FiredAt != nil { + until := r.FiredAt.UTC().Add(s.cfg.InflightTimeout) + if until.After(s.clock()) && (st.FireSlot.HoldUntil == nil || st.FireSlot.HoldUntil.Before(until)) { + st.HoldSlotUntil(until) + return nil + } + } return ErrNoChange } if err := r.Complete(); err != nil { diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index 7eac3872..054e341c 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -2240,3 +2240,40 @@ func TestCompleteWaitRoundHoldsAnUnacknowledgedFireSlot(t *testing.T) { t.Fatalf("completing the round must release the slot, got %+v", st.FireSlot) } } + +// Converging is the loop's signal to push, and the push supersedes the round the +// held slot points at. Leaving the round fired is therefore not enough on its +// own: the replacement round archives its predecessor, Normalize drops a slot no +// round holds, and the next Pump spends the account allowance on a second review +// command while the first is still unanswered. +func TestAHeldFireSlotSurvivesTheHeadAdvanceItInvites(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.InflightTimeout = time.Hour + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + now := time.Now().UTC() + repo, pr, head := "o/r", 5, "aaaaaaaa1" + seedRound(t, store, cfg, repo, pr, head, PhaseFired, now, 12) + svc.completeWaitRound(ctx, repo, pr, head, true) + + // The push: the agent acted on the success the loop just reported. + if _, err := store.Update(ctx, func(st *State) error { + _, err := st.Supersede(repo, pr, "bbbbbbbb2", now) + return err + }); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if !st.SlotHeld(now) { + t.Fatalf("fire slot = %+v, want it still held for the unanswered command", st.FireSlot) + } + // Bounded by the in-flight window, so an unanswered command cannot wedge the + // queue for longer than crq would have waited for it anyway. + if st.SlotHeld(now.Add(cfg.InflightTimeout + time.Minute)) { + t.Error("the hold must expire with the in-flight window it was taken for") + } +} diff --git a/internal/crq/service.go b/internal/crq/service.go index 14a731f7..0bd90745 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -487,7 +487,7 @@ func quotaFreeVerdict(v engine.FireVerdict) bool { func (s *Service) global(st State, now time.Time) engine.Global { return engine.Global{ - SlotFree: st.SlotRound() == nil, + SlotFree: !st.SlotHeld(now), BlockedUntil: st.Account.BlockedUntil, LastFired: st.LastFired, } diff --git a/internal/state/state.go b/internal/state/state.go index 1715aa25..fc61972c 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -249,6 +249,12 @@ type FireSlot struct { Key string `json:"key"` // repo#pr holding the slot Token string `json:"token"` Since time.Time `json:"since"` + // HoldUntil keeps the slot taken after the round holding it went away with + // its metered command still unacknowledged — a head advance archives the + // round, and the command it posted does not stop being in flight because of + // that. Set to the end of that command's in-flight window, so the hold is + // bounded by the same deadline Progress would have applied. + HoldUntil *time.Time `json:"hold_until,omitempty"` } // AccountQuota is the CodeRabbit account-wide review quota (NOT the GitHub @@ -747,6 +753,33 @@ func (s *State) SlotRound() *Round { return &r } +// SlotHeld reports whether the fire slot is taken — the question every fire gate +// actually asks. A live round holds it; so does an orphaned hold, left behind +// when the round that posted a still-unacknowledged metered command was +// superseded by a new head. Without the second case the expected push after a +// round that converged without its primary would free the slot for a second +// concurrent metered command. +func (s *State) SlotHeld(now time.Time) bool { + if s.FireSlot == nil { + return false + } + if s.SlotRound() != nil { + return true + } + return s.FireSlot.HoldUntil != nil && s.FireSlot.HoldUntil.After(now) +} + +// HoldSlotUntil keeps the current fire slot held past the round that owns it. +// The caller sets the deadline, since only it knows the in-flight window the +// command this slot was taken for is bounded by. +func (s *State) HoldSlotUntil(until time.Time) { + if s.FireSlot == nil { + return + } + u := until.UTC() + s.FireSlot.HoldUntil = &u +} + // NextEligible returns the fire-eligible round with the lowest Seq, or nil. func (s *State) NextEligible(now time.Time) *Round { var best *Round @@ -831,7 +864,7 @@ func (s *State) Queue(now time.Time, minInterval time.Duration) []QueueEntry { if s.Account.BlockedUntil != nil && s.Account.BlockedUntil.After(now) { blocked = s.Account.BlockedUntil.UTC() } - slotBusy := s.SlotRound() != nil + slotBusy := s.SlotHeld(now) // The pacing gate applies to whichever round fires next, so it bounds every // entry's earliest possible start. var paced time.Time @@ -956,7 +989,7 @@ func (s *State) Queue(now time.Time, minInterval time.Duration) []QueueEntry { // Normalize repairs invariants after load: map init, expired retry windows // (awaiting_retry with a passed RetryAt is simply fire-eligible; nothing to -// do), and a FireSlot pointing at a round that no longer holds it. +// do), and a FireSlot no round holds and no orphaned hold keeps alive. func (s *State) Normalize(now time.Time) { if s.Rounds == nil { s.Rounds = map[string]Round{} @@ -964,7 +997,7 @@ func (s *State) Normalize(now time.Time) { if s.Version == 0 { s.Version = SchemaVersion } - if s.FireSlot != nil && s.SlotRound() == nil { + if s.FireSlot != nil && !s.SlotHeld(now) { s.FireSlot = nil } for key, r := range s.Rounds { From ceb60ad6a8aa72b6d90830bc57efcbea1593e63d Mon Sep 17 00:00:00 2001 From: kristofferR Date: Mon, 27 Jul 2026 06:02:04 +0200 Subject: [PATCH 12/37] Preserve review state across configuration changes --- internal/crq/auto.go | 2 +- internal/crq/repoconfig.go | 22 ++++++++++------- internal/crq/repoconfig_test.go | 26 ++++++++++++++++++++ internal/state/state.go | 19 +++++++++------ internal/state/tolerant.go | 27 +++++++++++++++++++-- internal/state/tolerant_test.go | 42 +++++++++++++++++++++++++++++++++ 6 files changed, 119 insertions(+), 19 deletions(-) diff --git a/internal/crq/auto.go b/internal/crq/auto.go index b73456b7..2a3b7e68 100644 --- a/internal/crq/auto.go +++ b/internal/crq/auto.go @@ -308,7 +308,7 @@ func (s *Service) needsReview(ctx context.Context, state State, repo string, pr if !r.ReviewersChanged { return false, head, nil } - s.logEnqueue(repo, pr, head, "reviewer requirements changed while the pr was closed") + s.logEnqueue(repo, pr, head, "reviewer configuration changed while the pr was closed") return true, head, nil } reviews, err := s.gh.ListReviews(ctx, repo, pr) diff --git a/internal/crq/repoconfig.go b/internal/crq/repoconfig.go index 162f33a8..1f635eba 100644 --- a/internal/crq/repoconfig.go +++ b/internal/crq/repoconfig.go @@ -252,7 +252,7 @@ func mustOverride(st *State, repo string) RepoReviewers { } // reopenForChangedReviewers requeues this repository's completed rounds when the -// set of reviewers that gates them changed. +// effective reviewer set changed. // // A completed round is the "this head was reviewed" dedup marker, so adding a // required reviewer would otherwise strand the PR: Feedback reports the new bot @@ -260,12 +260,14 @@ func mustOverride(st *State, repo string) RepoReviewers { // still there. No eligible round exists to trigger it, and `crq next` waits for // a push that has no reason to come. // -// Only rounds whose required set actually changed are touched, only completed -// ones — an in-flight round is already going to answer — and only those whose -// pull request is still open. Rounds are never deleted, so a repository's merged -// and closed PRs stay behind as completed dedup markers: requeueing those would -// hand Pump hundreds of dead rounds to observe and drop one per tick, ahead of -// every real one, and a stranded PR is by definition an open one. +// Optional co-reviewers count too: once one has participation evidence, +// Completion waits for it, and its trigger/self-heal needs an active round to +// run. Only completed rounds are touched — an in-flight round is already going +// to answer — and only those whose pull request is still open. Rounds are never +// deleted, so a repository's merged and closed PRs stay behind as completed +// dedup markers: requeueing those would hand Pump hundreds of dead rounds to +// observe and drop one per tick, ahead of every real one, and a stranded PR is +// by definition an open one. // // A closed PR's round is marked instead of requeued, because closed is not // final: reopened at the same head, its completed round would be the dedup @@ -277,7 +279,9 @@ func mustOverride(st *State, repo string) RepoReviewers { // object, so a reopened round that the primary already answered dedupes instead // of buying a second review. func (s *Service) reopenForChangedReviewers(st *State, repo string, before, after Config, open map[int]bool) { - if sameLogins(before.RequiredBots, after.RequiredBots) { + beforeCo := before.reviewerLogins(func(r Reviewer) bool { return !r.Metered() }) + afterCo := after.reviewerLogins(func(r Reviewer) bool { return !r.Metered() }) + if sameLogins(before.RequiredBots, after.RequiredBots) && sameLogins(beforeCo, afterCo) { return } for _, round := range st.Rounds { @@ -298,7 +302,7 @@ func (s *Service) reopenForChangedReviewers(st *State, repo string, before, afte } st.PutRound(reopened) if s.log != nil { - s.log.Printf("reviewers: requeued %s#%d@%s — the required set changed", round.Repo, round.PR, round.Head) + s.log.Printf("reviewers: requeued %s#%d@%s — the reviewer set changed", round.Repo, round.PR, round.Head) } } } diff --git a/internal/crq/repoconfig_test.go b/internal/crq/repoconfig_test.go index 2c54571f..0a5a6f8b 100644 --- a/internal/crq/repoconfig_test.go +++ b/internal/crq/repoconfig_test.go @@ -244,6 +244,32 @@ func TestChangingRequirementsReopensACompletedRound(t *testing.T) { } } +// Optional co-reviewers can become convergence gates after participation +// evidence appears. Enabling one therefore needs the same active round as +// changing the statically required set, so its trigger and bounded wait run. +func TestChangingEnabledCoReviewersReopensACompletedRound(t *testing.T) { + ctx := context.Background() + cfg := isolatedConfig(t, map[string]string{"CRQ_COBOTS": ""}) + store := NewMemoryStore(cfg) + gh := newFakeGitHub() + repo, pr, head := "o/r", 4, "aaaaaaaa1" + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} + svc := NewService(cfg, gh, store, nil) + seedRound(t, store, cfg, repo, pr, head, PhaseCompleted, time.Now().UTC(), 11) + + if _, err := svc.SetReviewers(ctx, repo, []string{"bugbot"}, nil); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + round := st.Round(repo, pr) + if round == nil || round.Phase != PhaseQueued { + t.Fatalf("round = %#v, want it requeued so the newly enabled co-reviewer can run", round) + } +} + // Dedupe writes the "every required reviewer answered this head" marker, so a // required reviewer added between the decision and the write must void it. The // round is still queued when the operator's write lands — nothing to requeue — diff --git a/internal/state/state.go b/internal/state/state.go index fc61972c..28c26e79 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -103,7 +103,7 @@ type Round struct { // the round is retried or surfaced as timed out. WaitDeadline *time.Time `json:"wait_deadline,omitempty"` - // ReviewersChanged marks a completed round whose required reviewer set + // ReviewersChanged marks a completed round whose effective reviewer set // changed while its pull request was closed. Requeueing a closed PR's round // would hand Pump dead work ahead of every live round, so the change is // recorded on the marker instead: if the PR is ever reopened, enqueue reopens @@ -255,6 +255,10 @@ type FireSlot struct { // that. Set to the end of that command's in-flight window, so the hold is // bounded by the same deadline Progress would have applied. HoldUntil *time.Time `json:"hold_until,omitempty"` + + // unknown carries JSON members this binary has no field for, so a newer + // binary's additions survive being read and rewritten here. See tolerant.go. + unknown unknownFields } // AccountQuota is the CodeRabbit account-wide review quota (NOT the GitHub @@ -520,15 +524,16 @@ func (r *Round) ReleaseToQueue(reason string, now time.Time) error { return nil } -// Reopen puts a completed round back in the queue because the set of reviewers -// that has to answer for its head changed. +// Reopen puts a completed round back in the queue because its effective +// reviewer set changed. // // A completed round is the "this head was reviewed" dedup marker, so a newly // required reviewer would otherwise strand the PR: convergence reports it // pending while enqueue keeps skipping the head, and no eligible round exists to -// trigger it. This is the one transition that reopens a finished round, and it -// keeps the head, the attempts and the co-reviewer bookkeeping — what changed is -// who still has to answer, not what happened. +// trigger it. An optional reviewer also needs an active round for its trigger, +// self-heal and bounded participation wait. This is the one transition that +// reopens a finished round, and it keeps the head, the attempts and the +// co-reviewer bookkeeping — what changed is who runs, not what happened. // // LastAttemptAt is deliberately left alone: it is the adoption floor for a // FAILED attempt, and moving it would discard a newly required co-reviewer's own @@ -544,7 +549,7 @@ func (r *Round) Reopen() error { r.WaitDeadline = nil r.RetryAt = nil r.ReviewersChanged = false - r.Note = "reviewer requirements changed" + r.Note = "reviewer configuration changed" return nil } diff --git a/internal/state/tolerant.go b/internal/state/tolerant.go index 957d9a8d..db4fefc9 100644 --- a/internal/state/tolerant.go +++ b/internal/state/tolerant.go @@ -28,10 +28,33 @@ import ( type unknownFields map[string]json.RawMessage var ( - roundFields = jsonFieldNames(reflect.TypeOf(Round{})) - stateFields = jsonFieldNames(reflect.TypeOf(State{})) + fireSlotFields = jsonFieldNames(reflect.TypeOf(FireSlot{})) + roundFields = jsonFieldNames(reflect.TypeOf(Round{})) + stateFields = jsonFieldNames(reflect.TypeOf(State{})) ) +// UnmarshalJSON decodes a fire slot and remembers anything it did not recognise. +func (s *FireSlot) UnmarshalJSON(raw []byte) error { + type plain FireSlot + var decoded plain + if err := json.Unmarshal(raw, &decoded); err != nil { + return err + } + unknown, err := captureUnknown(raw, fireSlotFields) + if err != nil { + return err + } + *s = FireSlot(decoded) + s.unknown = unknown + return nil +} + +// MarshalJSON writes a fire slot back with the members it did not recognise intact. +func (s FireSlot) MarshalJSON() ([]byte, error) { + type plain FireSlot + return mergeUnknown(plain(s), s.unknown) +} + // UnmarshalJSON decodes a round and remembers anything it did not recognise. func (r *Round) UnmarshalJSON(raw []byte) error { // A distinct type with the same layout: without it, json would call this diff --git a/internal/state/tolerant_test.go b/internal/state/tolerant_test.go index b80b3c85..6e151313 100644 --- a/internal/state/tolerant_test.go +++ b/internal/state/tolerant_test.go @@ -68,6 +68,48 @@ func TestUnknownRoundFieldsSurviveARewrite(t *testing.T) { } } +// FireSlot is nested beneath a known State field, so top-level tolerance cannot +// carry additions made to the slot itself. +func TestUnknownFireSlotFieldsSurviveARewrite(t *testing.T) { + foreign := `{ + "v": 3, "rev": 7, "next_seq": 9, + "rounds": { + "owner/repo#1": { + "repo": "owner/repo", "pr": 1, "head": "abcdef123", "seq": 1, + "phase": "reserved", "enqueued_at": "2026-07-26T12:00:00Z", + "token": "slot-token" + } + }, + "fire_slot": { + "key": "owner/repo#1", "token": "slot-token", + "since": "2026-07-26T12:01:00Z", + "future_hold": {"until": "2026-07-26T12:05:00Z"} + }, + "account": {"scope": "owner"} + }` + + var st State + if err := json.Unmarshal([]byte(foreign), &st); err != nil { + t.Fatal(err) + } + out, err := json.Marshal(st) + if err != nil { + t.Fatal(err) + } + var back map[string]any + if err := json.Unmarshal(out, &back); err != nil { + t.Fatal(err) + } + slot, _ := back["fire_slot"].(map[string]any) + if slot == nil { + t.Fatalf("the fire slot vanished:\n%s", out) + } + hold, _ := slot["future_hold"].(map[string]any) + if hold == nil || hold["until"] != "2026-07-26T12:05:00Z" { + t.Errorf("carried fire-slot member lost its content: %#v", slot["future_hold"]) + } +} + // A carried member must never win over a field this binary owns: what this build // computes now is the current truth, and a stale copy from a foreign write would // silently override it. From 942d68433b092b33fed7fbaf22ba1d9a55c33938 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Mon, 27 Jul 2026 07:06:24 +0200 Subject: [PATCH 13/37] Fix review state compatibility races --- internal/crq/feedback.go | 18 ++++++--- internal/crq/feedback_test.go | 6 +-- internal/crq/repoconfig.go | 23 ++++++++++- internal/crq/repoconfig_test.go | 72 +++++++++++++++++++++++++++++++++ internal/crq/reviewers.go | 9 ++--- internal/crq/service.go | 1 + internal/engine/completion.go | 25 ++++++++---- internal/engine/engine_test.go | 11 +++++ internal/engine/fire.go | 3 ++ internal/state/state.go | 59 ++++++++++++++++++++++----- internal/state/tolerant_test.go | 45 +++++++++++++++++++++ 11 files changed, 241 insertions(+), 31 deletions(-) diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index 97d7dbfc..70f5afac 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -64,6 +64,10 @@ type FeedbackReport struct { // permission to release the slot. Not serialized: the feedback JSON contract // is frozen. PrimaryAckPending bool `json:"-"` + // config is the reviewer configuration identity that produced this report. + // A completion write revalidates it under CAS so an override committed after + // observation cannot turn stale convergence into a permanent dedup marker. + config Config } // CoReviewerStatus is one co-reviewer's observed state for the current head. @@ -113,6 +117,7 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe HeadRef: pull.Head.Ref, HeadRepo: NormalizeRepo(pull.Head.Repo.FullName), ReviewedBy: map[string]bool{}, + config: cfg, Findings: []dialect.Finding{}, CheckedAt: now, } @@ -547,7 +552,7 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport // timeout so the caller retries later instead. A skipped wait result is // terminal, not retryable, so preserve it as a skipped report. if waitResult.Action == "skipped" { - s.completeWaitRound(ctx, repo, pr, "", false) + s.completeWaitRound(ctx, repo, pr, "", false, nil) } return FeedbackReport{Status: status, Repo: NormalizeRepo(repo), PR: pr, Head: waitResult.Head, Reason: waitResult.Reason, ReviewedBy: map[string]bool{}, Findings: []dialect.Finding{}}, code, nil } @@ -600,7 +605,7 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport report.Status = "feedback" if allReviewed(report.ReviewedBy) { report.Reason = "all required reviewers finished; address findings, push once, and resolve threads" - s.completeWaitRound(ctx, repo, pr, head, report.PrimaryAckPending) + s.completeWaitRound(ctx, repo, pr, head, report.PrimaryAckPending, &report.config) } else if report.CodeRabbitDeferred && engine.DoneExceptWithEvidence(report.ReviewedBy, s.cfg.Bot, dialect.CodexBotLogin) { // Degraded round: every required bot except the rate-limited // CodeRabbit has finished. These findings are this round's work — @@ -638,7 +643,7 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport } if s.cfg.SettleWindow <= 0 || s.clock().Sub(settledAt) >= s.cfg.SettleWindow { if report.Converged { - s.completeWaitRound(ctx, repo, pr, head, report.PrimaryAckPending) + s.completeWaitRound(ctx, repo, pr, head, report.PrimaryAckPending, &report.config) } return report, 0, nil } @@ -693,7 +698,7 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport // A degraded round must not be completed on timeout: marking the head // reviewed would silently cancel the still-owed CodeRabbit review. if !report.CodeRabbitDeferred { - s.completeWaitRound(ctx, repo, pr, head, false) + s.completeWaitRound(ctx, repo, pr, head, false, &report.config) } if len(report.Findings) > 0 { report.Status = "feedback" @@ -811,7 +816,7 @@ func (s *Service) pushWaitDeadline(ctx context.Context, repo string, pr int, hea // and the hold is stamped on the slot so the push this success invites cannot // drop it along with the superseded round. Only the convergence callers pass it; // a timed-out or never fired wait must still end. -func (s *Service) completeWaitRound(ctx context.Context, repo string, pr int, head string, holdUnacked bool) { +func (s *Service) completeWaitRound(ctx context.Context, repo string, pr int, head string, holdUnacked bool, cfg *Config) { repo = NormalizeRepo(repo) changed := false state, err := s.store.Update(ctx, func(st *State) error { @@ -823,6 +828,9 @@ func (s *Service) completeWaitRound(ctx context.Context, repo string, pr int, he if head != "" && r.Head != head { return ErrNoChange } + if cfg != nil && overrideChanged(st, repo, *cfg) { + return ErrNoChange + } if holdUnacked && st.FireSlot != nil && st.FireSlot.Key == QueueKey(repo, pr) { // Leaving the round fired keeps the slot only while the round exists, // and converging is precisely the loop's signal to push: the head diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index 054e341c..eba01edc 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -2216,7 +2216,7 @@ func TestCompleteWaitRoundHoldsAnUnacknowledgedFireSlot(t *testing.T) { repo, pr, head := "o/r", 4, "aaaaaaaa1" seedRound(t, store, cfg, repo, pr, head, PhaseFired, now, 12) - svc.completeWaitRound(ctx, repo, pr, head, true) + svc.completeWaitRound(ctx, repo, pr, head, true, &cfg) if got := roundPhase(t, store, repo, pr); got != PhaseFired { t.Fatalf("phase = %s, want the round left fired while the primary is silent", got) } @@ -2228,7 +2228,7 @@ func TestCompleteWaitRoundHoldsAnUnacknowledgedFireSlot(t *testing.T) { t.Fatal("the fire slot must stay held until the primary acknowledges") } - svc.completeWaitRound(ctx, repo, pr, head, false) + svc.completeWaitRound(ctx, repo, pr, head, false, &cfg) if got := roundPhase(t, store, repo, pr); got != PhaseCompleted { t.Fatalf("phase = %s, want an acknowledged round completed as before", got) } @@ -2255,7 +2255,7 @@ func TestAHeldFireSlotSurvivesTheHeadAdvanceItInvites(t *testing.T) { now := time.Now().UTC() repo, pr, head := "o/r", 5, "aaaaaaaa1" seedRound(t, store, cfg, repo, pr, head, PhaseFired, now, 12) - svc.completeWaitRound(ctx, repo, pr, head, true) + svc.completeWaitRound(ctx, repo, pr, head, true, &cfg) // The push: the agent acted on the success the loop just reported. if _, err := store.Update(ctx, func(st *State) error { diff --git a/internal/crq/repoconfig.go b/internal/crq/repoconfig.go index 1f635eba..f069cdf1 100644 --- a/internal/crq/repoconfig.go +++ b/internal/crq/repoconfig.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/engine" ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) @@ -288,10 +289,12 @@ func (s *Service) reopenForChangedReviewers(st *State, repo string, before, afte if NormalizeRepo(round.Repo) != NormalizeRepo(repo) || round.Phase != PhaseCompleted { continue } + forced := forcedCoReviewers(round.ForceCoReviewers, before, after) if !open[round.PR] { - if !round.ReviewersChanged { + if !round.ReviewersChanged || !sameLogins(round.ForceCoReviewers, forced) { marked := round marked.ReviewersChanged = true + marked.ForceCoReviewers = forced st.PutRound(marked) } continue @@ -300,6 +303,7 @@ func (s *Service) reopenForChangedReviewers(st *State, repo string, before, afte if err := reopened.Reopen(); err != nil { continue } + reopened.ForceCoReviewers = forced st.PutRound(reopened) if s.log != nil { s.log.Printf("reviewers: requeued %s#%d@%s — the reviewer set changed", round.Repo, round.PR, round.Head) @@ -307,6 +311,23 @@ func (s *Service) reopenForChangedReviewers(st *State, repo string, before, afte } } +// forcedCoReviewers carries the one exceptional trigger a completed-head reopen +// needs. A newly required self-heal bot has no activity on that old head, so its +// normal mode cannot decide it missed anything; force it once without changing +// the repository's steady-state trigger policy. +func forcedCoReviewers(existing []string, before, after Config) []string { + var out []string + for _, cb := range after.CoBots { + if !cb.Required || cb.Trigger != engine.TriggerSelfHeal || cb.Command == "" { + continue + } + if containsBot(existing, cb.Login) || !containsBot(before.RequiredBots, cb.Login) { + out = append(out, cb.Login) + } + } + return out +} + // requeueIfReviewersChanged reopens a completed round that a reviewer change // marked while its pull request was closed, and reports whether it did. This is // the other half of reopenForChangedReviewers: the enqueue paths call it when diff --git a/internal/crq/repoconfig_test.go b/internal/crq/repoconfig_test.go index 0a5a6f8b..6b59e91d 100644 --- a/internal/crq/repoconfig_test.go +++ b/internal/crq/repoconfig_test.go @@ -174,6 +174,46 @@ func TestOverrideNeverGatesOnAReviewerItCannotTrigger(t *testing.T) { } }) + t.Run("a newly required self-heal bot gets an immediate trigger", func(t *testing.T) { + // Bugbot normally self-heals only after activity proves it missed a head. + // On a completed head reopened by this override it has no activity yet, so + // preserving selfheal would complete the round without ever asking it. + fleet := isolatedConfig(t, map[string]string{"CRQ_COBOTS": "bugbot"}) + got := fleet.ForRepo(RepoReviewers{ + CoBots: []string{"bugbot"}, SetCoBots: true, + Required: []string{"bugbot"}, SetRequired: true, + }) + for _, cb := range got.CoBots { + if cb.Name != "bugbot" { + continue + } + if cb.Trigger != engine.TriggerSelfHeal { + t.Errorf("bugbot = %+v: the repository's steady-state policy must stay selfheal", cb) + } + forced := forcedCoReviewers(nil, fleet, got) + if len(forced) != 1 || !sameBot(forced[0], cb.Login) { + t.Fatalf("forced reviewers = %v, want newly required bugbot", forced) + } + round := Round{ + Repo: "o/r", PR: 3, Head: "aaaaaaaa1", Phase: PhaseQueued, + ForceCoReviewers: forced, + } + obs := engine.Observation{ + Head: "aaaaaaaa1", Open: true, + Reviews: []engine.ReviewSeen{{ + Bot: got.Bot, Commit: "aaaaaaaa1", + }}, + } + decision := engine.DecideFire(engine.Global{SlotFree: true}, round, obs, time.Now().UTC(), got.policy()) + if decision.Verdict != engine.FireCoOnly || len(decision.PostCo) != 1 || + !sameBot(decision.PostCo[0], cb.Login) { + t.Errorf("decision = %+v, want one immediate bugbot trigger", decision) + } + return + } + t.Fatalf("CoBots = %+v, want bugbot enabled", got.CoBots) + }) + t.Run("an explicit feedback set survives", func(t *testing.T) { // CRQ_FEEDBACK_BOTS is the one list an operator may widen beyond who // reviews. Deriving over it would silently stop surfacing those findings @@ -627,6 +667,38 @@ func TestCompletionIsRevalidatedInsideItsWrite(t *testing.T) { } } +// Feedback and its completion write have the same decision/write window as +// Progress. A reviewer override landing in that window must leave the in-flight +// round open so the next observation can ask the newly required reviewer. +func TestFeedbackCompletionIsRevalidatedInsideItsWrite(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.CoBots = codexCoBots(nil) + store := NewMemoryStore(cfg) + hooked := &hookedStore{StateStore: store} + gh := newFakeGitHub() + repo, pr, head := "o/r", 13, "aaaaaaaa1" + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} + svc := NewService(cfg, gh, hooked, nil) + now := time.Now().UTC() + seedRound(t, store, cfg, repo, pr, head, PhaseFired, now, 21) + + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + stale := svc.cfgFor(st, repo) + hooked.hook = func() { + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}); err != nil { + t.Error(err) + } + } + svc.completeWaitRound(ctx, repo, pr, head, false, &stale) + if got := roundPhase(t, store, repo, pr); got != PhaseFired { + t.Fatalf("phase = %s, want stale feedback completion dropped", got) + } +} + // The self-heal sweep claims a co-reviewer's trigger post under CAS and then // posts it. The claim is what authorizes the post, so — as in fireCoOnly — the // reviewer set that chose the bot has to still be the configured one when the diff --git a/internal/crq/reviewers.go b/internal/crq/reviewers.go index 95dcdb12..5c60f225 100644 --- a/internal/crq/reviewers.go +++ b/internal/crq/reviewers.go @@ -283,11 +283,10 @@ func (c Config) ForRepo(ov RepoReviewers) Config { // A fleet entry carries the trigger its OWN required-ness produced: Codex // defaults to never and only becomes always when required. Retaining that entry // while making the bot required leaves the engine waiting for evidence no -// command was ever posted for. Only a never trigger is promoted, so an operator -// who deliberately configured selfheal keeps it — and only one that FELL OUT of -// the registry defaults, since CRQ_COBOT__TRIGGER=never already wins over -// the required trigger fleet-wide and posting the command a repository override -// away from that would be crq overruling the operator. +// command was ever posted for. Only a never trigger is promoted; a self-heal +// trigger remains the bot's normal mode. Reviewer-change reopens carry their +// one-shot force on the Round instead, so the override does not permanently +// change how that bot runs on later heads. func promoteTrigger(cb CoBotConfig) CoBotConfig { if !cb.Required || cb.Trigger != engine.TriggerNever || cb.Command == "" || cb.TriggerExplicit { return cb diff --git a/internal/crq/service.go b/internal/crq/service.go index 0bd90745..4b098caa 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -587,6 +587,7 @@ func (s *Service) applyTransition(st *State, r *Round, tr engine.Transition, now func releaseSlot(st *State, key string) { if st.FireSlot != nil && st.FireSlot.Key == key { st.FireSlot = nil + st.FireSlotHoldUntil = nil } } diff --git a/internal/engine/completion.go b/internal/engine/completion.go index a60e7d1b..42f93280 100644 --- a/internal/engine/completion.go +++ b/internal/engine/completion.go @@ -247,22 +247,33 @@ func CommandHasCompletionReply(obs Observation, p Policy, commandID int64) bool return false } -// PrimaryCompletedRound reports whether the primary answered THIS round's own -// command with a completion reply that counts as its review of the head. +// PrimaryCompletedRound reports whether the primary completed THIS round with +// evidence that counts as its review of the head: either the clean-summary +// verdict Completion accepts, or a completion reply to the round's own command. // // It is the gate that lets a reopened round skip re-asking a primary that -// already answered, so it holds to Completion's rule 4 rather than to the weaker -// adoption question: CommandHasCompletionReply deliberately omits the -// prior-review requirement and the failed-summary guard, and a reply failing +// already answered, so it holds to Completion's evidence rules rather than to +// the weaker adoption question: CommandHasCompletionReply deliberately omits +// the prior-review requirement and the failed-summary guard, and a reply failing // either is not a review of this head. Deduping on it writes a completed marker // that every later same-head check skips, so a wrong yes here is one nothing // recovers from. func PrimaryCompletedRound(r state.Round, obs Observation, p Policy) bool { - if r.CommandID == 0 || r.FiredAt == nil { + if r.FiredAt == nil { + return false + } + cutoff := r.FiredAt.UTC() + for _, ev := range obs.Events { + if ev.Kind == dialect.EvNoAction && sameBot(ev.Bot, p.Bot) && + notBefore(ev.ObservedTime(), cutoff) { + return true + } + } + if r.CommandID == 0 { return false } return CommandHasCompletionReply(obs, p, r.CommandID) && - completionReplyForRound(obs, p, r.FiredAt.UTC()) + completionReplyForRound(obs, p, cutoff) } func botHasAnyReview(reviews []ReviewSeen, bot string) bool { diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 34a5a9d3..dac658b8 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -1022,6 +1022,17 @@ func TestReopenedRoundDedupesOnlyOnCompletionEvidence(t *testing.T) { Events: []dialect.BotEvent{command, completion}}, want: FireDedupe, }, + { + // A clean-summary verdict is Completion's other no-Review evidence. + // Reopening the marker for a reviewer change must not buy the same + // primary review again. + name: "clean summary for this round", + obs: Observation{Head: head, Open: true, Events: []dialect.BotEvent{{ + Kind: dialect.EvNoAction, Bot: "coderabbitai[bot]", CommentID: 1003, + CreatedAt: t0.Add(time.Minute), UpdatedAt: t0.Add(time.Minute), + }}}, + want: FireDedupe, + }, { // Nothing to stand in for: the bot has never submitted a review, so // the head has not been reviewed and the round must still fire. diff --git a/internal/engine/fire.go b/internal/engine/fire.go index bff99281..f283fc4c 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -315,6 +315,9 @@ func coAwareDedupe(r state.Round, obs Observation, p Policy, now time.Time, prim if !gates || coReviewedHead(obs, cp.Login) { continue } + if cp.Trigger == TriggerSelfHeal && r.ForceCoReviewer(cp.Login) { + cp.Trigger = TriggerAlways + } if DecideCoPost(r, obs, cp, len(co.Commands) > 0, anchor, now) { post = append(post, cp.Login) continue diff --git a/internal/state/state.go b/internal/state/state.go index 28c26e79..655d039b 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -110,6 +110,10 @@ type Round struct { // the round rather than treating it as "this head was reviewed". Reopen // clears it — the reopened round answers under the current requirements. ReviewersChanged bool `json:"reviewers_changed,omitempty"` + // ForceCoReviewers names newly required self-heal reviewers that need one + // immediate trigger when a completed head is reopened. Once their command is + // recorded, normal per-bot dedupe makes the force harmless. + ForceCoReviewers []string `json:"force_co_reviewers,omitempty"` Token string `json:"token,omitempty"` // reservation token (CAS race detection) // ByHost identifies the PROCESS that reserved this round, in the writer form @@ -296,11 +300,17 @@ type State struct { Rev int64 `json:"rev"` NextSeq int64 `json:"next_seq"` - Rounds map[string]Round `json:"rounds"` - FireSlot *FireSlot `json:"fire_slot,omitempty"` - LastFired *time.Time `json:"last_fired,omitempty"` - Account AccountQuota `json:"account"` - Leader *LeaderLease `json:"leader,omitempty"` + Rounds map[string]Round `json:"rounds"` + FireSlot *FireSlot `json:"fire_slot,omitempty"` + // FireSlotHoldUntil is the top-level compatibility mirror of + // FireSlot.HoldUntil. Binaries predating nested FireSlot tolerance discard + // hold_until and clear an orphaned slot during Normalize, but their State + // tolerance carries this unknown top-level member. New binaries can therefore + // still recover the hold after an older writer rewrites the shared state. + FireSlotHoldUntil *time.Time `json:"fire_slot_hold_until,omitempty"` + LastFired *time.Time `json:"last_fired,omitempty"` + Account AccountQuota `json:"account"` + Leader *LeaderLease `json:"leader,omitempty"` // CalibrationIssue overrides the configured calibration PR/issue when the // original hit GitHub's hard 2500-comment cap and crq rotated to a fresh @@ -553,6 +563,18 @@ func (r *Round) Reopen() error { return nil } +// ForceCoReviewer reports whether a reviewer-change reopen granted login its +// one immediate trigger. Bot names are normalized like CoBots keys. +func (r *Round) ForceCoReviewer(login string) bool { + key := coBotKey(login) + for _, candidate := range r.ForceCoReviewers { + if coBotKey(candidate) == key { + return true + } + } + return false +} + // Acknowledge records that the bot has seen the fired command (reaction, // in-progress summary, or other non-terminal reply): fired → reviewing. The // fire slot may be released; the round itself stays open until Complete. @@ -627,6 +649,7 @@ func (r *Round) Complete() error { return r.illegal(PhaseCompleted) } r.Phase = PhaseCompleted + r.ForceCoReviewers = nil r.Note = "" return nil } @@ -643,6 +666,7 @@ func (r *Round) Dedupe(now time.Time) error { r.Phase = PhaseCompleted r.Token = "" r.ReservedAt = nil + r.ForceCoReviewers = nil r.Note = "bot already reviewed head" return nil } @@ -765,13 +789,13 @@ func (s *State) SlotRound() *Round { // round that converged without its primary would free the slot for a second // concurrent metered command. func (s *State) SlotHeld(now time.Time) bool { - if s.FireSlot == nil { - return false + if s.FireSlot != nil && s.SlotRound() != nil { + return true } - if s.SlotRound() != nil { + if s.FireSlotHoldUntil != nil && s.FireSlotHoldUntil.After(now) { return true } - return s.FireSlot.HoldUntil != nil && s.FireSlot.HoldUntil.After(now) + return s.FireSlot != nil && s.FireSlot.HoldUntil != nil && s.FireSlot.HoldUntil.After(now) } // HoldSlotUntil keeps the current fire slot held past the round that owns it. @@ -783,6 +807,7 @@ func (s *State) HoldSlotUntil(until time.Time) { } u := until.UTC() s.FireSlot.HoldUntil = &u + s.FireSlotHoldUntil = &u } // NextEligible returns the fire-eligible round with the lowest Seq, or nil. @@ -1002,8 +1027,22 @@ func (s *State) Normalize(now time.Time) { if s.Version == 0 { s.Version = SchemaVersion } - if s.FireSlot != nil && !s.SlotHeld(now) { + // Fold both hold representations before repairing the slot. The top-level + // mirror may be the only copy left after a pre-FireSlot-tolerance binary + // normalized and rewrote an orphaned hold. + if s.FireSlot != nil && s.FireSlot.HoldUntil != nil && + (s.FireSlotHoldUntil == nil || s.FireSlotHoldUntil.Before(*s.FireSlot.HoldUntil)) { + u := s.FireSlot.HoldUntil.UTC() + s.FireSlotHoldUntil = &u + } + if s.FireSlotHoldUntil != nil && s.FireSlot != nil && + (s.FireSlot.HoldUntil == nil || s.FireSlot.HoldUntil.Before(*s.FireSlotHoldUntil)) { + u := s.FireSlotHoldUntil.UTC() + s.FireSlot.HoldUntil = &u + } + if !s.SlotHeld(now) { s.FireSlot = nil + s.FireSlotHoldUntil = nil } for key, r := range s.Rounds { r.foldLegacyCodex() diff --git a/internal/state/tolerant_test.go b/internal/state/tolerant_test.go index 6e151313..c0ba8e85 100644 --- a/internal/state/tolerant_test.go +++ b/internal/state/tolerant_test.go @@ -110,6 +110,51 @@ func TestUnknownFireSlotFieldsSurviveARewrite(t *testing.T) { } } +// A binary from before FireSlot had its own tolerant marshal drops nested +// hold_until and then clears the orphaned slot in Normalize. The top-level +// mirror is unknown to that binary, so State's existing tolerance carries it +// and a current reader must still honor the in-flight command window. +func TestOrphanedHoldSurvivesALegacyRewrite(t *testing.T) { + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + until := now.Add(15 * time.Minute) + st := State{ + Version: SchemaVersion, + Rounds: map[string]Round{}, + FireSlot: &FireSlot{ + Key: "owner/repo#1", Token: "slot-token", Since: now, + }, + } + st.HoldSlotUntil(until) + raw, err := json.Marshal(st) + if err != nil { + t.Fatal(err) + } + + // Model the legacy rewrite: its Normalize removes fire_slot because no live + // round owns it, while its top-level unknown-field carrier leaves the mirror. + var legacy map[string]any + if err := json.Unmarshal(raw, &legacy); err != nil { + t.Fatal(err) + } + delete(legacy, "fire_slot") + rewritten, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + + var back State + if err := json.Unmarshal(rewritten, &back); err != nil { + t.Fatal(err) + } + back.Normalize(now) + if !back.SlotHeld(now) { + t.Fatalf("legacy rewrite lost the orphaned hold: %+v", back) + } + if back.SlotHeld(until.Add(time.Second)) { + t.Fatal("the recovered compatibility hold must still expire") + } +} + // A carried member must never win over a field this binary owns: what this build // computes now is the current truth, and a stale copy from a foreign write would // silently override it. From 770df01b1a5039670e924eabe8b0ad0b24347eca Mon Sep 17 00:00:00 2001 From: kristofferR Date: Mon, 27 Jul 2026 07:14:39 +0200 Subject: [PATCH 14/37] Keep legacy writers behind orphaned holds --- AGENTS.md | 4 ++++ internal/crq/service.go | 2 +- internal/state/state.go | 39 ++++++++++++++++++++++++++++----- internal/state/tolerant_test.go | 11 ++++++++-- 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 209b67f5..3d184f87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,10 @@ Dependency rule (Go-enforced, no cycles): `dialect ← engine ← crq`, `state added survives being read and rewritten by an older one — which is what makes adding one safe without another dual-write, and without a schema bump (an unknown version auto-reinitialises and would erase the fleet's rounds). + `FireSlot.HoldUntil` is the compatibility exception for the binary immediately + before nested slot tolerance: its deadline is mirrored at the tolerant top + level and in the legacy pacing anchor so that writer both preserves and + honours it during a rolling deployment. - `internal/engine/` — PURE decision logic, `now` passed in, no ctx/gh: `DecideFire` (the single fire owner), `Progress` (fired/reviewing round transitions), `Completion` (the one "is the round done?"), `BlockingFindings` diff --git a/internal/crq/service.go b/internal/crq/service.go index 4b098caa..0a37f04a 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -587,7 +587,7 @@ func (s *Service) applyTransition(st *State, r *Round, tr engine.Transition, now func releaseSlot(st *State, key string) { if st.FireSlot != nil && st.FireSlot.Key == key { st.FireSlot = nil - st.FireSlotHoldUntil = nil + st.ClearSlotHold() } } diff --git a/internal/state/state.go b/internal/state/state.go index 655d039b..10ce6ba0 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -307,10 +307,14 @@ type State struct { // hold_until and clear an orphaned slot during Normalize, but their State // tolerance carries this unknown top-level member. New binaries can therefore // still recover the hold after an older writer rewrites the shared state. - FireSlotHoldUntil *time.Time `json:"fire_slot_hold_until,omitempty"` - LastFired *time.Time `json:"last_fired,omitempty"` - Account AccountQuota `json:"account"` - Leader *LeaderLease `json:"leader,omitempty"` + FireSlotHoldUntil *time.Time `json:"fire_slot_hold_until,omitempty"` + // FireSlotHoldLastFired is the real pacing anchor replaced while the hold + // is dual-written into LastFired for binaries that do not understand the + // top-level mirror. Current binaries restore it when the hold ends. + FireSlotHoldLastFired *time.Time `json:"fire_slot_hold_last_fired,omitempty"` + LastFired *time.Time `json:"last_fired,omitempty"` + Account AccountQuota `json:"account"` + Leader *LeaderLease `json:"leader,omitempty"` // CalibrationIssue overrides the configured calibration PR/issue when the // original hit GitHub's hard 2500-comment cap and crq rotated to a fresh @@ -806,8 +810,33 @@ func (s *State) HoldSlotUntil(until time.Time) { return } u := until.UTC() + before := s.LastFired + if s.FireSlotHoldUntil != nil && s.LastFired != nil && + s.LastFired.Equal(*s.FireSlotHoldUntil) { + before = s.FireSlotHoldLastFired + } s.FireSlot.HoldUntil = &u s.FireSlotHoldUntil = &u + s.FireSlotHoldLastFired = before + // The previous binary preserves the top-level mirror but does not read it. + // It does read LastFired, so a future pacing anchor keeps its Pump from + // posting another metered review. Its MinInterval may conservatively extend + // the wait; a current binary restores the real anchor below. + if s.LastFired == nil || s.LastFired.Before(u) { + s.LastFired = &u + } +} + +// ClearSlotHold removes the compatibility hold and restores the pacing anchor +// it temporarily replaced. If another writer fired after the deadline, +// LastFired no longer equals the synthetic value and is left untouched. +func (s *State) ClearSlotHold() { + if s.FireSlotHoldUntil != nil && s.LastFired != nil && + s.LastFired.Equal(*s.FireSlotHoldUntil) { + s.LastFired = s.FireSlotHoldLastFired + } + s.FireSlotHoldUntil = nil + s.FireSlotHoldLastFired = nil } // NextEligible returns the fire-eligible round with the lowest Seq, or nil. @@ -1042,7 +1071,7 @@ func (s *State) Normalize(now time.Time) { } if !s.SlotHeld(now) { s.FireSlot = nil - s.FireSlotHoldUntil = nil + s.ClearSlotHold() } for key, r := range s.Rounds { r.foldLegacyCodex() diff --git a/internal/state/tolerant_test.go b/internal/state/tolerant_test.go index c0ba8e85..1aa628a8 100644 --- a/internal/state/tolerant_test.go +++ b/internal/state/tolerant_test.go @@ -136,6 +136,9 @@ func TestOrphanedHoldSurvivesALegacyRewrite(t *testing.T) { if err := json.Unmarshal(raw, &legacy); err != nil { t.Fatal(err) } + if legacy["last_fired"] != until.Format(time.RFC3339) { + t.Fatalf("legacy pacing anchor = %v, want hold deadline %s", legacy["last_fired"], until) + } delete(legacy, "fire_slot") rewritten, err := json.Marshal(legacy) if err != nil { @@ -150,8 +153,12 @@ func TestOrphanedHoldSurvivesALegacyRewrite(t *testing.T) { if !back.SlotHeld(now) { t.Fatalf("legacy rewrite lost the orphaned hold: %+v", back) } - if back.SlotHeld(until.Add(time.Second)) { - t.Fatal("the recovered compatibility hold must still expire") + back.Normalize(until.Add(time.Second)) + if back.SlotHeld(until.Add(time.Second)) || back.FireSlotHoldUntil != nil { + t.Fatalf("the recovered compatibility hold did not expire: %+v", back) + } + if back.LastFired != nil { + t.Fatalf("current writer did not restore the pre-hold pacing anchor: %s", back.LastFired) } } From 330cf83e3fb6ef9620153e653357067062888c8b Mon Sep 17 00:00:00 2001 From: kristofferR Date: Mon, 27 Jul 2026 07:48:55 +0200 Subject: [PATCH 15/37] Fix reviewer reconfiguration races --- internal/crq/repoconfig.go | 21 ++++++++++---- internal/crq/repoconfig_test.go | 50 +++++++++++++++++++++++++++++++- internal/crq/service.go | 4 +-- internal/crq/service_test.go | 51 ++++++++++++++++++++++++++++++++- internal/crq/state.go | 6 ++++ internal/engine/engine_test.go | 24 ++++++++++++++++ internal/engine/fire.go | 2 +- internal/engine/progress.go | 3 ++ 8 files changed, 151 insertions(+), 10 deletions(-) diff --git a/internal/crq/repoconfig.go b/internal/crq/repoconfig.go index f069cdf1..bdb27a5b 100644 --- a/internal/crq/repoconfig.go +++ b/internal/crq/repoconfig.go @@ -312,22 +312,33 @@ func (s *Service) reopenForChangedReviewers(st *State, repo string, before, afte } // forcedCoReviewers carries the one exceptional trigger a completed-head reopen -// needs. A newly required self-heal bot has no activity on that old head, so its -// normal mode cannot decide it missed anything; force it once without changing -// the repository's steady-state trigger policy. +// needs. A newly enabled or required self-heal bot has no activity on that old +// head, so its normal mode cannot decide it missed anything; force it once +// without changing the repository's steady-state trigger policy. func forcedCoReviewers(existing []string, before, after Config) []string { var out []string for _, cb := range after.CoBots { - if !cb.Required || cb.Trigger != engine.TriggerSelfHeal || cb.Command == "" { + if cb.Trigger != engine.TriggerSelfHeal || cb.Command == "" { continue } - if containsBot(existing, cb.Login) || !containsBot(before.RequiredBots, cb.Login) { + newlyEnabled := !containsCoBot(before.CoBots, cb.Login) + newlyRequired := cb.Required && !containsBot(before.RequiredBots, cb.Login) + if containsBot(existing, cb.Login) || newlyEnabled || newlyRequired { out = append(out, cb.Login) } } return out } +func containsCoBot(bots []CoBotConfig, login string) bool { + for _, cb := range bots { + if sameBot(cb.Login, login) { + return true + } + } + return false +} + // requeueIfReviewersChanged reopens a completed round that a reviewer change // marked while its pull request was closed, and reports whether it did. This is // the other half of reopenForChangedReviewers: the enqueue paths call it when diff --git a/internal/crq/repoconfig_test.go b/internal/crq/repoconfig_test.go index 6b59e91d..b396550d 100644 --- a/internal/crq/repoconfig_test.go +++ b/internal/crq/repoconfig_test.go @@ -126,7 +126,16 @@ func TestOverrideKeepsTheSilencedPrimaryEntry(t *testing.T) { t.Errorf("%s: CoBots = %+v lost the primary's registry entry", when, cfg.CoBots) } primaryPresent(fleet, "fleet default") - primaryPresent(fleet.ForRepo(RepoReviewers{CoBots: []string{"codex"}, SetCoBots: true}), "override naming only codex") + got := fleet.ForRepo(RepoReviewers{ + CoBots: []string{"codex"}, SetCoBots: true, + Required: []string{dialect.CodexBotLogin}, SetRequired: true, + }) + primaryPresent(got, "override naming only codex") + for _, cp := range got.policy().CoReviewerPolicies() { + if sameBot(cp.Login, got.Bot) { + t.Errorf("primary %q leaked into the dynamic co-review policies", got.Bot) + } + } } // Four defects that all end the same way: a round waiting on a reviewer crq @@ -214,6 +223,45 @@ func TestOverrideNeverGatesOnAReviewerItCannotTrigger(t *testing.T) { t.Fatalf("CoBots = %+v, want bugbot enabled", got.CoBots) }) + t.Run("a newly enabled optional self-heal bot gets an immediate trigger", func(t *testing.T) { + fleet := isolatedConfig(t, map[string]string{ + "CRQ_COBOTS": "", + "CRQ_COBOT_CODEX_REQUIRED": "false", + "CRQ_COBOT_BUGBOT_REQUIRED": "false", + "CRQ_COBOT_MACROSCOPE_REQUIRED": "false", + }) + got := fleet.ForRepo(RepoReviewers{CoBots: []string{"bugbot"}, SetCoBots: true}) + for _, cb := range got.CoBots { + if cb.Name != "bugbot" { + continue + } + if cb.Required { + t.Fatalf("bugbot = %+v, want an optional reviewer", cb) + } + forced := forcedCoReviewers(nil, fleet, got) + if len(forced) != 1 || !sameBot(forced[0], cb.Login) { + t.Fatalf("forced reviewers = %v, want newly enabled bugbot", forced) + } + round := Round{ + Repo: "o/r", PR: 4, Head: "aaaaaaaa1", Phase: PhaseQueued, + ForceCoReviewers: forced, + } + obs := engine.Observation{ + Head: "aaaaaaaa1", Open: true, + Reviews: []engine.ReviewSeen{{ + Bot: got.Bot, Commit: "aaaaaaaa1", + }}, + } + decision := engine.DecideFire(engine.Global{SlotFree: true}, round, obs, time.Now().UTC(), got.policy()) + if decision.Verdict != engine.FireCoOnly || len(decision.PostCo) != 1 || + !sameBot(decision.PostCo[0], cb.Login) { + t.Errorf("decision = %+v, want one immediate optional bugbot trigger", decision) + } + return + } + t.Fatalf("CoBots = %+v, want bugbot enabled", got.CoBots) + }) + t.Run("an explicit feedback set survives", func(t *testing.T) { // CRQ_FEEDBACK_BOTS is the one list an operator may widen beyond who // reviews. Deriving over it would silently stop surfacing those findings diff --git a/internal/crq/service.go b/internal/crq/service.go index 0a37f04a..fbeea73b 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -848,7 +848,7 @@ func (s *Service) fireRound(ctx context.Context, cfg Config, round Round, obs en recorded := false updated, err := s.store.Update(ctx, func(st *State) error { recorded = false - if st.FireSlot != nil { + if st.SlotHeld(now) { return ErrNoChange } r := st.Round(round.Repo, round.PR) @@ -914,7 +914,7 @@ func (s *Service) fireRound(ctx context.Context, cfg Config, round Round, obs en // commit point — nothing is posted before it — so it is where the reviewer // configuration the decision used is revalidated. reserved, err := s.store.Update(ctx, func(st *State) error { - if st.FireSlot != nil { + if st.SlotHeld(now) { return ErrNoChange } r := st.Round(round.Repo, round.PR) diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index e5594d0b..eebe4691 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -373,7 +373,16 @@ func (s *adoptionRaceStore) Load(context.Context) (State, Revision, error) { func (s *adoptionRaceStore) Update(_ context.Context, mutate func(*State) error) (State, error) { state := cloneState(s.loadState) - state.FireSlot = &FireSlot{Key: "owner/repo#99", Token: "other", Since: time.Now().UTC()} + now := time.Now().UTC() + other, err := state.NewRound("owner/repo", 99, "999999999", now) + if err != nil { + return State{}, err + } + if err := other.Reserve("other", "other-host", now); err != nil { + return State{}, err + } + state.PutRound(*other) + state.FireSlot = &FireSlot{Key: "owner/repo#99", Token: "other", Since: now} if err := mutate(&state); err != nil { if errors.Is(err, ErrNoChange) { return state, nil @@ -1421,6 +1430,46 @@ func TestPumpTreatsExistingReviewAdoptionRaceAsLostRace(t *testing.T) { } } +func TestFireRoundRechecksOrphanedSlotHold(t *testing.T) { + for _, post := range []bool{false, true} { + name := "adopt" + if post { + name = "post" + } + t.Run(name, func(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + gh := newFakeGitHub() + store := NewMemoryStore(cfg) + now := time.Now().UTC() + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseQueued, now, 0) + if _, err := store.Update(ctx, func(st *State) error { + until := now.Add(cfg.InflightTimeout) + st.FireSlotHoldUntil = &until + return nil + }); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + round := *st.Round("owner/repo", 12) + svc := NewService(cfg, gh, store, nil) + got, err := svc.fireRound(ctx, cfg, round, engine.Observation{}, post, 77, now.Add(-time.Minute), "test", nil, now) + if err != nil { + t.Fatal(err) + } + if got.Action != "lost_race" { + t.Fatalf("action = %q, want lost_race while an orphaned hold is active", got.Action) + } + if len(gh.posted) != 0 { + t.Fatalf("posted %d review commands while an orphaned hold is active", len(gh.posted)) + } + }) + } +} + func TestRecordFireResetsRecordedAcrossRetry(t *testing.T) { cfg := firingConfig() svc := NewService(cfg, newFakeGitHub(), retryNoChangeStore{cfg: cfg}, nil) diff --git a/internal/crq/state.go b/internal/crq/state.go index cd422b10..9a70bc3e 100644 --- a/internal/crq/state.go +++ b/internal/crq/state.go @@ -127,6 +127,12 @@ func (c Config) policy() engine.Policy { RateLimitCoDegrade: c.RateLimitCoDegrade, } for _, cb := range c.CoBots { + // A registry-backed primary keeps a silenced CoBots entry so observation + // can use that registry's wording and check hooks. It is still the + // primary, not a dynamic co-reviewer convergence gate. + if sameBot(cb.Login, c.Bot) { + continue + } p.CoReviewers = append(p.CoReviewers, engine.CoReviewerPolicy{ Login: cb.Login, Command: cb.Command, diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index dac658b8..fff25980 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -150,6 +150,30 @@ func TestFailedSummaryParksTheRound(t *testing.T) { } } +func TestFailedOptionalPrimaryCompletesConvergedRound(t *testing.T) { + r := firedRound(t, "abcdef123") + now := t0.Add(time.Minute) + p := withCodex(policy, "@codex review") + p.RequiredBots = []string{dialect.CodexBotLogin} + obs := Observation{ + Head: "abcdef123", Open: true, + Reviews: []ReviewSeen{{ + Bot: dialect.CodexBotLogin, ReviewID: 4, + Commit: "abcdef1234567890", SubmittedAt: t0.Add(30 * time.Second), + }}, + Events: []dialect.BotEvent{{ + Kind: dialect.EvFailed, Bot: "coderabbitai[bot]", CommentID: 900, + CreatedAt: t0.Add(-time.Hour), UpdatedAt: t0.Add(9 * time.Second), + }}, + } + if got := Completion(r, obs, p); !got.Done { + t.Fatalf("the only required reviewer answered; want convergence, got %+v", got) + } + if tr := Progress(r, state.AccountQuota{}, obs, now, p); tr.Outcome != OutComplete { + t.Fatalf("an optional primary failure must finish a converged round, got %+v", tr) + } +} + func TestReviewAtHeadCompletesRound(t *testing.T) { r := firedRound(t, "abcdef123") obs := Observation{Head: "abcdef123", Open: true, diff --git a/internal/engine/fire.go b/internal/engine/fire.go index f283fc4c..04e287d7 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -311,7 +311,7 @@ func coAwareDedupe(r state.Round, obs Observation, p Policy, now time.Time, prim anchor := selfHealAnchor(r, obs, primaryUnavailable) for _, cp := range p.coReviewers() { co := obs.co(cp.Login) - gates := requiredBot(p, cp.Login) || co.AutoActive || primaryUnavailable + gates := requiredBot(p, cp.Login) || co.AutoActive || primaryUnavailable || r.ForceCoReviewer(cp.Login) if !gates || coReviewedHead(obs, cp.Login) { continue } diff --git a/internal/engine/progress.go b/internal/engine/progress.go index d206b1a2..0d786df5 100644 --- a/internal/engine/progress.go +++ b/internal/engine/progress.go @@ -122,6 +122,9 @@ func Progress(r state.Round, q state.AccountQuota, obs Observation, now time.Tim // treated this as a normal bot comment and released the slot with the wait // still pending; parking with a bounded cooldown retries it instead. if r.Phase == state.PhaseFired && stateSince(obs, p, firedAt, dialect.EvFailed) { + if completion.Done { + return Transition{Outcome: OutComplete, Reason: "feedback complete; optional primary review failed"} + } return Transition{Outcome: OutRetry, Reason: "review failed", RetryAt: now.Add(p.retryBackoff())} } From ae9badb9e59c6c5fad318c74af00c6c82eb703f2 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Mon, 27 Jul 2026 08:40:06 +0200 Subject: [PATCH 16/37] Fix reviewer reconfiguration edge cases --- internal/crq/repoconfig.go | 37 ++++++++++++------ internal/crq/repoconfig_test.go | 47 +++++++++++++++++++++++ internal/crq/service.go | 8 ++-- internal/crq/service_test.go | 64 ++++++++++++++++++++++++++++++++ internal/engine/completion.go | 13 ++++++- internal/engine/coreview.go | 3 ++ internal/engine/coreview_test.go | 12 ++++++ internal/engine/engine_test.go | 18 +++++++++ internal/engine/fire.go | 3 -- internal/state/state.go | 4 +- 10 files changed, 186 insertions(+), 23 deletions(-) diff --git a/internal/crq/repoconfig.go b/internal/crq/repoconfig.go index bdb27a5b..50d3896f 100644 --- a/internal/crq/repoconfig.go +++ b/internal/crq/repoconfig.go @@ -252,7 +252,7 @@ func mustOverride(st *State, repo string) RepoReviewers { return ov } -// reopenForChangedReviewers requeues this repository's completed rounds when the +// reopenForChangedReviewers updates this repository's live rounds when the // effective reviewer set changed. // // A completed round is the "this head was reviewed" dedup marker, so adding a @@ -263,12 +263,13 @@ func mustOverride(st *State, repo string) RepoReviewers { // // Optional co-reviewers count too: once one has participation evidence, // Completion waits for it, and its trigger/self-heal needs an active round to -// run. Only completed rounds are touched — an in-flight round is already going -// to answer — and only those whose pull request is still open. Rounds are never -// deleted, so a repository's merged and closed PRs stay behind as completed -// dedup markers: requeueing those would hand Pump hundreds of dead rounds to -// observe and drop one per tick, ahead of every real one, and a stranded PR is -// by definition an open one. +// run. Existing active rounds receive the same one-shot force as reopened ones: +// an in-flight self-heal reviewer with no activity cannot otherwise know it was +// just required. Completed rounds are reopened only when their pull request is +// still open. Rounds are never deleted, so a repository's merged and closed PRs +// stay behind as completed dedup markers: requeueing those would hand Pump +// hundreds of dead rounds to observe and drop one per tick, ahead of every real +// one, and a stranded PR is by definition an open one. // // A closed PR's round is marked instead of requeued, because closed is not // final: reopened at the same head, its completed round would be the dedup @@ -286,10 +287,22 @@ func (s *Service) reopenForChangedReviewers(st *State, repo string, before, afte return } for _, round := range st.Rounds { - if NormalizeRepo(round.Repo) != NormalizeRepo(repo) || round.Phase != PhaseCompleted { + if NormalizeRepo(round.Repo) != NormalizeRepo(repo) { continue } forced := forcedCoReviewers(round.ForceCoReviewers, before, after) + switch round.Phase { + case PhaseQueued, PhaseReserved, PhaseFired, PhaseReviewing, PhaseAwaitingRetry: + if !sameLogins(round.ForceCoReviewers, forced) { + updated := round + updated.ForceCoReviewers = forced + st.PutRound(updated) + } + continue + case PhaseCompleted: + default: + continue + } if !open[round.PR] { if !round.ReviewersChanged || !sameLogins(round.ForceCoReviewers, forced) { marked := round @@ -311,10 +324,10 @@ func (s *Service) reopenForChangedReviewers(st *State, repo string, before, afte } } -// forcedCoReviewers carries the one exceptional trigger a completed-head reopen -// needs. A newly enabled or required self-heal bot has no activity on that old -// head, so its normal mode cannot decide it missed anything; force it once -// without changing the repository's steady-state trigger policy. +// forcedCoReviewers carries the one exceptional trigger an existing round +// needs. A newly enabled or required self-heal bot has no activity on that head, +// so its normal mode cannot decide it missed anything; force it once without +// changing the repository's steady-state trigger policy. func forcedCoReviewers(existing []string, before, after Config) []string { var out []string for _, cb := range after.CoBots { diff --git a/internal/crq/repoconfig_test.go b/internal/crq/repoconfig_test.go index b396550d..27e06d1c 100644 --- a/internal/crq/repoconfig_test.go +++ b/internal/crq/repoconfig_test.go @@ -358,6 +358,53 @@ func TestChangingEnabledCoReviewersReopensACompletedRound(t *testing.T) { } } +func TestChangingReviewersForcesExistingActiveRounds(t *testing.T) { + for _, phase := range []Phase{PhaseQueued, PhaseFired, PhaseReviewing, PhaseAwaitingRetry} { + t.Run(string(phase), func(t *testing.T) { + ctx := context.Background() + cfg := isolatedConfig(t, map[string]string{"CRQ_COBOTS": ""}) + store := NewMemoryStore(cfg) + gh := newFakeGitHub() + repo, pr, head := "o/r", 4, "aaaaaaaa1" + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} + svc := NewService(cfg, gh, store, nil) + seedPhase := phase + if phase == PhaseAwaitingRetry { + seedPhase = PhaseReviewing + } + seedRound(t, store, cfg, repo, pr, head, seedPhase, time.Now().UTC(), 11) + if phase == PhaseAwaitingRetry { + if _, err := store.Update(ctx, func(st *State) error { + r := st.Round(repo, pr) + retryAt := time.Now().UTC().Add(time.Hour) + if err := r.AwaitRetry(retryAt, "test", time.Now().UTC()); err != nil { + return err + } + st.PutRound(*r) + return nil + }); err != nil { + t.Fatal(err) + } + } + + if _, err := svc.SetReviewers(ctx, repo, []string{"bugbot"}, []string{"bugbot"}); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + round := st.Round(repo, pr) + if round == nil || !round.ForceCoReviewer(dialect.BugbotLogin) { + t.Fatalf("round = %#v, want newly required bugbot forced on the active round", round) + } + if round.Phase != phase { + t.Fatalf("phase = %s, want active round to stay %s", round.Phase, phase) + } + }) + } +} + // Dedupe writes the "every required reviewer answered this head" marker, so a // required reviewer added between the decision and the write must void it. The // round is still queued when the operator's write lands — nothing to requeue — diff --git a/internal/crq/service.go b/internal/crq/service.go index fbeea73b..aa6471c9 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -320,11 +320,11 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { if err != nil { return result, err } - // A blocked front of the queue must not starve later PRs of resolutions that - // spend NO CodeRabbit quota — a co-reviewer defer, or a summary-only round - // whose review is never coming from CodeRabbit at all. + // A blocked or orphaned-slot-held front of the queue must not starve later + // PRs of resolutions that spend NO CodeRabbit quota — a co-reviewer defer, + // or a summary-only round whose review is never coming from CodeRabbit at all. accountBlocked := global.BlockedUntil != nil && global.BlockedUntil.After(now) - if decision.Verdict == engine.FireNo && accountBlocked { + if decision.Verdict == engine.FireNo && (accountBlocked || st.SlotHeld(now)) { if free, handled, err := s.sweepQuotaFree(ctx, st, now, next.Repo, next.PR); err != nil { return PumpResult{}, err } else if handled { diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index eebe4691..b2e1ab6e 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -2627,6 +2627,70 @@ func TestPumpSweepsQuotaFreeRoundWhileTheSlotIsHeld(t *testing.T) { } } +func TestPumpSweepsQuotaFreeRoundWhileAnOrphanedHoldIsActive(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.RequiredBots = []string{"coderabbitai[bot]", dialect.CodexBotLogin} + cfg.CoBots = codexCoBots(cfg.RequiredBots) + cfg.FeedbackBots = cfg.RequiredBots + gh := newFakeGitHub() + gh.graphQL = noForcePush + now := time.Now().UTC() + + frontSHA := "111111111" + front := ghapi.Pull{State: "open"} + front.Head.SHA = frontSHA + "abcdef0" + gh.pulls[fakeKey("o/front", 10)] = front + + backSHA := "2222222222222222" + back := ghapi.Pull{State: "open"} + back.Head.SHA = backSHA + gh.pulls[fakeKey("o/back", 20)] = back + walkthrough := ghapi.IssueComment{ + ID: 900, + Body: corpusMessage(t, "coderabbit/summary-only-free-plan.md"), + CreatedAt: now.Add(-time.Minute), + UpdatedAt: now.Add(-time.Minute), + } + walkthrough.User.Login = "coderabbitai[bot]" + gh.comments[fakeKey("o/back", 20)] = []ghapi.IssueComment{walkthrough} + + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + svc.now = func() time.Time { return now } + seedRound(t, store, cfg, "o/front", 10, frontSHA, PhaseQueued, now.Add(-time.Minute), 0) + if _, err := svc.Enqueue(ctx, "o/back", 20); err != nil { + t.Fatal(err) + } + if _, err := store.Update(ctx, func(st *State) error { + holdUntil := now.Add(cfg.InflightTimeout) + st.FireSlotHoldUntil = &holdUntil + frontRound := st.Round("o/front", 10) + frontRound.SetCoCommand(dialect.CodexBotLogin, 701, now.Add(-time.Minute)) + st.PutRound(*frontRound) + return nil + }); err != nil { + t.Fatal(err) + } + + res, err := svc.Pump(ctx) + if err != nil { + t.Fatal(err) + } + if res.Repo != "o/back" || res.PR != 20 { + t.Fatalf("the quota-free round behind an orphaned hold must be rescued, got %#v", res) + } + st, _, _ := store.Load(ctx) + if round := st.Round("o/back", 20); round == nil || round.Phase == PhaseQueued { + t.Fatalf("the quota-free round must leave the queue, got %#v", round) + } + for _, posted := range gh.posted { + if strings.Contains(posted, cfg.ReviewCommand) { + t.Fatalf("the rescue must not spend primary review quota, posted=%v", gh.posted) + } + } +} + // TestWaitResolvesSummaryOnlyWithoutTheQueue pins the architectural rule the // dogfood exposed: a summary-only round is NOT a queue citizen. The Seq FIFO and // the FireSlot exist solely to serialize CodeRabbit's account-wide review limit, diff --git a/internal/engine/completion.go b/internal/engine/completion.go index 42f93280..f337fcf9 100644 --- a/internal/engine/completion.go +++ b/internal/engine/completion.go @@ -272,8 +272,17 @@ func PrimaryCompletedRound(r state.Round, obs Observation, p Policy) bool { if r.CommandID == 0 { return false } - return CommandHasCompletionReply(obs, p, r.CommandID) && - completionReplyForRound(obs, p, cutoff) + if !botHasAnyReview(obs.Reviews, p.Bot) { + return false + } + for _, reply := range commandReplies(obs, p) { + if reply.commandID == r.CommandID && reply.completion && + notBefore(reply.commandAt, cutoff) && + !stateSince(obs, p, reply.commandAt, dialect.EvInProgress, dialect.EvRateLimited, dialect.EvPaused, dialect.EvFailed) { + return true + } + } + return false } func botHasAnyReview(reviews []ReviewSeen, bot string) bool { diff --git a/internal/engine/coreview.go b/internal/engine/coreview.go index ca7f63c3..44ff5d1e 100644 --- a/internal/engine/coreview.go +++ b/internal/engine/coreview.go @@ -419,6 +419,9 @@ func DecideCoPost(r state.Round, obs Observation, cp CoReviewerPolicy, commandPr case TriggerAlways: return !obs.co(cp.Login).AutoActive case TriggerSelfHeal: + if r.ForceCoReviewer(cp.Login) { + return true + } co := obs.co(cp.Login) if !co.AutoActive && !co.ActiveThisRound { return false diff --git a/internal/engine/coreview_test.go b/internal/engine/coreview_test.go index 2d154bbd..5fa0d028 100644 --- a/internal/engine/coreview_test.go +++ b/internal/engine/coreview_test.go @@ -99,6 +99,18 @@ func TestDecideCoPostTriggerMatrix(t *testing.T) { } } +func TestDecideCoPostHonorsReviewerChangeForce(t *testing.T) { + cp := CoReviewerPolicy{ + Login: "cursor[bot]", + Command: "@cursor review", + Trigger: TriggerSelfHeal, + } + round := state.Round{ForceCoReviewers: []string{"cursor[bot]"}} + if !DecideCoPost(round, Observation{}, cp, false, time.Now().Add(-time.Minute), time.Now()) { + t.Fatal("a newly required self-heal reviewer must be triggered without prior activity") + } +} + // TestCompletionCheckEvidence pins step 2b and the generic dynamic gate: a // required check-bearing bot converges on its completed check run alone (the // silent-clean Bugbot round), an in-progress check does not, and a diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index fff25980..b0da01c3 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -1075,6 +1075,24 @@ func TestReopenedRoundDedupesOnlyOnCompletionEvidence(t *testing.T) { CreatedAt: t0, UpdatedAt: t0.Add(2 * time.Minute)}}}, want: FirePost, }, + { + // Each half used to find its own reply: the round's failed command + // satisfied the command-id check, while a later successful command + // satisfied the completion-evidence check. + name: "later command cannot repair this round's failed reply", + obs: Observation{Head: head, Open: true, Reviews: []ReviewSeen{priorReview}, + Events: []dialect.BotEvent{ + command, + completion, + {Kind: dialect.EvFailed, Bot: "coderabbitai[bot]", CommentID: 900, + CreatedAt: t0, UpdatedAt: t0.Add(2 * time.Minute)}, + {Kind: dialect.EvCommand, Bot: "kristofferR", CommentID: 2001, + CreatedAt: t0.Add(3 * time.Minute), UpdatedAt: t0.Add(3 * time.Minute)}, + {Kind: dialect.EvCompletion, Bot: "coderabbitai[bot]", CommentID: 2002, + AutoReply: true, CreatedAt: t0.Add(4 * time.Minute), UpdatedAt: t0.Add(4 * time.Minute)}, + }}, + want: FirePost, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/engine/fire.go b/internal/engine/fire.go index 04e287d7..51cd4616 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -315,9 +315,6 @@ func coAwareDedupe(r state.Round, obs Observation, p Policy, now time.Time, prim if !gates || coReviewedHead(obs, cp.Login) { continue } - if cp.Trigger == TriggerSelfHeal && r.ForceCoReviewer(cp.Login) { - cp.Trigger = TriggerAlways - } if DecideCoPost(r, obs, cp, len(co.Commands) > 0, anchor, now) { post = append(post, cp.Login) continue diff --git a/internal/state/state.go b/internal/state/state.go index 10ce6ba0..fdc67ef6 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -110,8 +110,8 @@ type Round struct { // the round rather than treating it as "this head was reviewed". Reopen // clears it — the reopened round answers under the current requirements. ReviewersChanged bool `json:"reviewers_changed,omitempty"` - // ForceCoReviewers names newly required self-heal reviewers that need one - // immediate trigger when a completed head is reopened. Once their command is + // ForceCoReviewers names newly enabled or required self-heal reviewers that + // need one immediate trigger on an existing round. Once their command is // recorded, normal per-bot dedupe makes the force harmless. ForceCoReviewers []string `json:"force_co_reviewers,omitempty"` From a454a83f637ba50a70d05e42a45db592cdaf24e0 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Mon, 27 Jul 2026 14:29:34 +0200 Subject: [PATCH 17/37] Address reviewer configuration feedback --- internal/crq/repoconfig.go | 7 +++++++ internal/crq/repoconfig_test.go | 37 +++++++++++++++++++++++++++++++++ internal/crq/service.go | 7 +++++-- internal/crq/service_test.go | 8 +++++++ internal/engine/engine_test.go | 36 ++++++++++++++++++++++++++++++++ internal/state/writers_test.go | 4 +++- 6 files changed, 96 insertions(+), 3 deletions(-) diff --git a/internal/crq/repoconfig.go b/internal/crq/repoconfig.go index 50d3896f..b79886cf 100644 --- a/internal/crq/repoconfig.go +++ b/internal/crq/repoconfig.go @@ -164,12 +164,19 @@ func (s *Service) SetReviewers(ctx context.Context, repo string, coBots, require now := s.clock().UTC() state, err := s.store.Update(ctx, func(st *State) error { ov, _ := st.RepoOverride(repo) + beforeOverride := ov if coBots != nil { ov.CoBots, ov.SetCoBots = setCoBots, true } if required != nil { ov.Required, ov.SetRequired = setRequired, true } + if ov.SetCoBots == beforeOverride.SetCoBots && + ov.SetRequired == beforeOverride.SetRequired && + sameLogins(ov.CoBots, beforeOverride.CoBots) && + sameLogins(ov.Required, beforeOverride.Required) { + return ErrNoChange + } ov.UpdatedAt, ov.By = &now, s.cfg.Host before := s.cfg.ForRepo(mustOverride(st, repo)) st.SetRepoOverride(repo, ov) diff --git a/internal/crq/repoconfig_test.go b/internal/crq/repoconfig_test.go index 27e06d1c..8ba63459 100644 --- a/internal/crq/repoconfig_test.go +++ b/internal/crq/repoconfig_test.go @@ -332,6 +332,43 @@ func TestChangingRequirementsReopensACompletedRound(t *testing.T) { } } +func TestSettingIdenticalReviewersPreservesOverrideIdentity(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + first := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + svc.now = func() time.Time { return first } + + if _, err := svc.SetReviewers(ctx, "o/r", []string{"codex"}, []string{"codex"}); err != nil { + t.Fatal(err) + } + before, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + original, ok := before.RepoOverride("o/r") + if !ok || original.UpdatedAt == nil { + t.Fatalf("override = %#v, want a timestamped override", original) + } + + svc.now = func() time.Time { return first.Add(time.Hour) } + if _, err := svc.SetReviewers(ctx, "o/r", []string{"codex"}, []string{"codex"}); err != nil { + t.Fatal(err) + } + after, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + unchanged, ok := after.RepoOverride("o/r") + if !ok || unchanged.UpdatedAt == nil || !unchanged.UpdatedAt.Equal(*original.UpdatedAt) { + t.Fatalf("UpdatedAt = %v, want the original identity %v", unchanged.UpdatedAt, original.UpdatedAt) + } + if after.Rev != before.Rev { + t.Fatalf("state revision = %d, want unchanged %d", after.Rev, before.Rev) + } +} + // Optional co-reviewers can become convergence gates after participation // evidence appears. Enabling one therefore needs the same active round as // changing the statically required set, so its trigger and bounded wait run. diff --git a/internal/crq/service.go b/internal/crq/service.go index aa6471c9..2077417e 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -9,7 +9,6 @@ import ( "io" "net/url" "sort" - "strconv" "strings" "time" @@ -1849,11 +1848,15 @@ func (s *Service) sync(ctx context.Context, state State) { func randomToken() string { var buf [16]byte if _, err := io.ReadFull(rand.Reader, buf[:]); err != nil { - return strconv.FormatInt(time.Now().UnixNano(), 16) + return fallbackToken(time.Now()) } return hex.EncodeToString(buf[:]) } +func fallbackToken(now time.Time) string { + return fmt.Sprintf("%016x", uint64(now.UnixNano())) +} + // isCommentCapError reports whether err is GitHub's hard cap of 2500 comments per // issue ("Commenting is disabled on issues with more than 2500 comments"). func isCommentCapError(err error) bool { diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index b2e1ab6e..4cfec43c 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -695,6 +695,14 @@ func firingConfig() Config { } } +func TestFallbackTokenIsSafeToShorten(t *testing.T) { + for _, now := range []time.Time{time.Unix(0, 0), time.Unix(0, 1)} { + if got := fallbackToken(now); len(got) < 8 { + t.Fatalf("fallbackToken(%v) = %q, want at least 8 characters", now, got) + } + } +} + func TestEnqueueIsIdempotentAndPumpFiresOnce(t *testing.T) { ctx := context.Background() cfg := firingConfig() diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index b0da01c3..10cae783 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -598,6 +598,42 @@ func TestDecideFireCodexDedupe(t *testing.T) { } } +func TestDecideFireForcedCoReviewerGate(t *testing.T) { + now := t0.Add(10 * time.Minute) + head := "abcdef123" + round := state.Round{Repo: "owner/repo", PR: 448, Head: head, Phase: state.PhaseQueued, Seq: 1} + obs := Observation{Head: head, Open: true, Reviews: []ReviewSeen{{ + Bot: policy.Bot, Commit: "abcdef1234567890", SubmittedAt: now, + }}} + selfHeal := policy + selfHeal.CoReviewers = []CoReviewerPolicy{{ + Login: dialect.CodexBotLogin, Command: "@codex review", Trigger: TriggerSelfHeal, + }} + + for _, tc := range []struct { + name string + forced bool + want FireVerdict + }{ + {name: "optional self-heal reviewer is not a gate", want: FireDedupe}, + {name: "forced optional self-heal reviewer gates", forced: true, want: FireCoOnly}, + } { + t.Run(tc.name, func(t *testing.T) { + candidate := round + if tc.forced { + candidate.ForceCoReviewers = []string{dialect.CodexBotLogin} + } + got := DecideFire(Global{SlotFree: true}, candidate, obs, now, selfHeal) + if got.Verdict != tc.want { + t.Fatalf("verdict = %v, want %v (%+v)", got.Verdict, tc.want, got) + } + if tc.forced && (len(got.PostCo) != 1 || !dialect.IsCodexBot(got.PostCo[0])) { + t.Fatalf("PostCo = %v, want the forced reviewer", got.PostCo) + } + }) + } +} + // TestDynamicCodexGate covers the dynamic completion gate: an observed-active // Codex gates a round it isn't configured-required for, a usage-limit notice // disengages that dynamic gate, and a configured-required Codex is left gating diff --git a/internal/state/writers_test.go b/internal/state/writers_test.go index 304f4d87..cc816eea 100644 --- a/internal/state/writers_test.go +++ b/internal/state/writers_test.go @@ -101,6 +101,8 @@ func TestReopenKeepsTheAdoptionFloor(t *testing.T) { now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) r := Round{Repo: "owner/repo", PR: 7, Head: "abcdef123", Phase: PhaseFired, FiredAt: &now, CommandID: 11} + floor := now.Add(-time.Hour) + r.LastAttemptAt = &floor if err := r.Complete(); err != nil { t.Fatal(err) } @@ -110,7 +112,7 @@ func TestReopenKeepsTheAdoptionFloor(t *testing.T) { if r.Phase != PhaseQueued { t.Fatalf("phase = %s, want the round requeued", r.Phase) } - if r.LastAttemptAt != nil { + if r.LastAttemptAt == nil || !r.LastAttemptAt.Equal(floor) { t.Errorf("LastAttemptAt = %v, want it untouched by a reopen", r.LastAttemptAt) } } From 90e78e910665cf51382d8abfdc5cfc28c1b9ce08 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Mon, 27 Jul 2026 14:55:42 +0200 Subject: [PATCH 18/37] Fix reviewer override and slot hold edge cases --- internal/crq/feedback.go | 6 ++++-- internal/crq/next.go | 10 ++++++++++ internal/crq/next_test.go | 34 ++++++++++++++++++++++++++++++++ internal/crq/repoconfig_test.go | 34 ++++++++++++++++++++++++++++++++ internal/crq/reviewers.go | 33 +++++++++++++++++++------------ internal/engine/engine_test.go | 11 +++++++++++ internal/engine/progress.go | 5 +++++ internal/state/dashboard_test.go | 30 ++++++++++++++++++++++++++++ internal/state/state.go | 14 ++++++++++--- 9 files changed, 159 insertions(+), 18 deletions(-) diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index 70f5afac..1530de66 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -816,7 +816,7 @@ func (s *Service) pushWaitDeadline(ctx context.Context, repo string, pr int, hea // and the hold is stamped on the slot so the push this success invites cannot // drop it along with the superseded round. Only the convergence callers pass it; // a timed-out or never fired wait must still end. -func (s *Service) completeWaitRound(ctx context.Context, repo string, pr int, head string, holdUnacked bool, cfg *Config) { +func (s *Service) completeWaitRound(ctx context.Context, repo string, pr int, head string, holdUnacked bool, cfg *Config) error { repo = NormalizeRepo(repo) changed := false state, err := s.store.Update(ctx, func(st *State) error { @@ -842,6 +842,7 @@ func (s *Service) completeWaitRound(ctx context.Context, repo string, pr int, he until := r.FiredAt.UTC().Add(s.cfg.InflightTimeout) if until.After(s.clock()) && (st.FireSlot.HoldUntil == nil || st.FireSlot.HoldUntil.Before(until)) { st.HoldSlotUntil(until) + changed = true return nil } } @@ -859,11 +860,12 @@ func (s *Service) completeWaitRound(ctx context.Context, repo string, pr int, he if s.log != nil { s.log.Printf("warning: failed to clear feedback wait for %s#%d: %v", repo, pr, err) } - return + return err } if changed { s.sync(ctx, state) } + return nil } // waitToFire runs Wait (enqueue + coordinated fire), riding out GitHub REST rate diff --git a/internal/crq/next.go b/internal/crq/next.go index d72155a7..cda6fc39 100644 --- a/internal/crq/next.go +++ b/internal/crq/next.go @@ -67,6 +67,16 @@ func (s *Service) Next(ctx context.Context, repo string, pr int) (NextReport, er if err != nil { return report, err } + // Convergence can release the head before the optional primary acknowledges + // the metered command. Preserve that slot before telling the caller to push: + // the push supersedes this round, so leaving the hold attached only to the + // live round would let Normalize release it while the review is still in + // flight. + if action.Kind == engine.ActionPush && feedback.PrimaryAckPending { + if err := s.completeWaitRound(ctx, repo, pr, report.Head, true, &feedback.config); err != nil { + return report, err + } + } // Undrained feedback for THIS head: publish nothing. Another review of the // same head would spend account quota to be told what the caller is already diff --git a/internal/crq/next_test.go b/internal/crq/next_test.go index 3599d7af..64b40c84 100644 --- a/internal/crq/next_test.go +++ b/internal/crq/next_test.go @@ -120,6 +120,40 @@ func TestNextDrivesAReviewRound(t *testing.T) { } } +func TestNextPreservesUnacknowledgedSlotBeforeReturningPush(t *testing.T) { + base := time.Date(2026, 7, 26, 9, 0, 0, 0, time.UTC) + f := newCodexReplayFixture(t, base, func(cfg *Config) { + cfg.RequiredBots = []string{dialect.CodexBotLogin} + cfg.FeedbackBots = cfg.RequiredBots + }) + repo, pr, head := "owner/repo", 506, "aaaaaaaa1" + f.openPull(repo, pr, head) + f.setCommitDate(head, base.Add(-time.Minute)) + f.setLocalWork(false, "") + f.next(repo, pr) + + f.clk.advance(time.Minute) + f.gh.mu.Lock() + review := ghapi.Review{ + ID: 900, CommitID: head, State: "COMMENTED", + SubmittedAt: f.clk.now(), Body: "[review body]", + } + review.User.Login = dialect.CodexBotLogin + f.gh.reviews[fakeKey(repo, pr)] = append(f.gh.reviews[fakeKey(repo, pr)], review) + f.gh.mu.Unlock() + f.setLocalWork(true, "uncommitted changes in the working tree") + + report := f.next(repo, pr) + f.wantAction(report, engine.ActionPush) + st, _, err := f.store.Load(f.ctx) + if err != nil { + t.Fatal(err) + } + if st.FireSlot == nil || st.FireSlot.HoldUntil == nil || !st.SlotHeld(f.clk.now()) { + t.Fatalf("push returned without preserving the unanswered primary slot: %+v", st.FireSlot) + } +} + // `next` advances the queue as a side effect, and that step owns the review // fire — so the two halves of drain-first are both properties of ONE decision: // undrained feedback for the current head must not buy another review of that diff --git a/internal/crq/repoconfig_test.go b/internal/crq/repoconfig_test.go index 8ba63459..e53cd8e2 100644 --- a/internal/crq/repoconfig_test.go +++ b/internal/crq/repoconfig_test.go @@ -104,6 +104,40 @@ func TestOverrideCanEnableABotTheFleetDoesNot(t *testing.T) { } } +func TestOverrideRecomputesImplicitTriggerAfterRemovingRequiredness(t *testing.T) { + fleet := isolatedConfig(t, map[string]string{ + "CRQ_COBOTS": "codex", + "CRQ_REQUIRED_BOTS": "coderabbitai[bot],codex", + }) + got := fleet.ForRepo(RepoReviewers{ + CoBots: []string{"codex"}, + SetCoBots: true, + Required: []string{"coderabbitai[bot]"}, + SetRequired: true, + }) + if len(got.CoBots) != 1 { + t.Fatalf("CoBots = %+v, want Codex still enabled", got.CoBots) + } + if got.CoBots[0].Required || got.CoBots[0].Trigger != engine.TriggerNever { + t.Fatalf("Codex = %+v, want optional with its implicit never trigger", got.CoBots[0]) + } + + explicit := isolatedConfig(t, map[string]string{ + "CRQ_COBOTS": "codex", + "CRQ_REQUIRED_BOTS": "coderabbitai[bot],codex", + "CRQ_COBOT_CODEX_TRIGGER": "always", + }) + got = explicit.ForRepo(RepoReviewers{ + CoBots: []string{"codex"}, + SetCoBots: true, + Required: []string{"coderabbitai[bot]"}, + SetRequired: true, + }) + if got.CoBots[0].Trigger != engine.TriggerAlways { + t.Fatalf("explicit Codex trigger = %q, want always preserved", got.CoBots[0].Trigger) + } +} + // A primary that is itself a registry bot keeps its silenced entry through an // override: that entry is where its wording and check-run hooks come from, so // dropping it costs the PRIMARY its evidence and the round waits for a bot diff --git a/internal/crq/reviewers.go b/internal/crq/reviewers.go index 5c60f225..c851f3c0 100644 --- a/internal/crq/reviewers.go +++ b/internal/crq/reviewers.go @@ -224,7 +224,7 @@ func (c Config) ForRepo(ov RepoReviewers) Config { // check-run hooks, and dropping it costs the PRIMARY its evidence. case containsBot(enabled, cb.Login): cb.Required = containsBot(required, cb.Login) - cb = promoteTrigger(cb) + cb = reconcileTrigger(cb) default: continue } @@ -261,7 +261,7 @@ func (c Config) ForRepo(ov RepoReviewers) Config { // one that was configured. if cb, ok := c.knownCoBot(login); ok { cb.Required = containsBot(required, login) - keep = append(keep, promoteTrigger(cb)) + keep = append(keep, reconcileTrigger(cb)) have[dialect.NormalizeBotName(login)] = true } } @@ -278,24 +278,31 @@ func (c Config) ForRepo(ov RepoReviewers) Config { return out } -// promoteTrigger stops a required co-reviewer from being one crq never asks. +// reconcileTrigger recomputes a co-reviewer's implicit mode after a repository +// override changes its requiredness. // // A fleet entry carries the trigger its OWN required-ness produced: Codex // defaults to never and only becomes always when required. Retaining that entry -// while making the bot required leaves the engine waiting for evidence no -// command was ever posted for. Only a never trigger is promoted; a self-heal -// trigger remains the bot's normal mode. Reviewer-change reopens carry their -// one-shot force on the Round instead, so the override does not permanently -// change how that bot runs on later heads. -func promoteTrigger(cb CoBotConfig) CoBotConfig { - if !cb.Required || cb.Trigger != engine.TriggerNever || cb.Command == "" || cb.TriggerExplicit { +// while changing requiredness must therefore handle both directions: promote a +// newly required Codex, and demote one a repository made optional. Explicit +// operator settings still win. Reviewer-change reopens carry their one-shot +// force on the Round instead, so the override does not permanently change how +// that bot runs on later heads. +func reconcileTrigger(cb CoBotConfig) CoBotConfig { + if cb.TriggerExplicit { return cb } - if co, ok := dialect.CoReviewerByName(cb.Name); ok && co.RequiredTrigger != "" { - cb.Trigger = triggerMode(co.RequiredTrigger, engine.TriggerAlways) + co, ok := dialect.CoReviewerByName(cb.Name) + if !ok { return cb } - cb.Trigger = engine.TriggerAlways + cb.Trigger = triggerMode(co.DefaultTrigger, engine.TriggerSelfHeal) + if cb.Required && co.RequiredTrigger != "" { + cb.Trigger = triggerMode(co.RequiredTrigger, cb.Trigger) + } + if cb.Command == "" { + cb.Trigger = engine.TriggerNever + } return cb } diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 10cae783..7d867072 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -229,6 +229,17 @@ func TestFireSlotHeldUntilPrimaryAcknowledges(t *testing.T) { } } +func TestSubmittedPrimaryReviewAcknowledgesTheRound(t *testing.T) { + firedAt := t0.Add(-time.Minute) + r := state.Round{Phase: state.PhaseFired, Head: "abcdef123", FiredAt: &firedAt} + obs := Observation{Reviews: []ReviewSeen{{ + Bot: "coderabbitai[bot]", Commit: "abcdef1234567890", SubmittedAt: t0, + }}} + if PrimaryAckPending(r, obs, policy) { + t.Fatal("a submitted primary review must acknowledge the command that produced it") + } +} + func TestInflightTimeoutCarriesCooldown(t *testing.T) { r := firedRound(t, "abcdef123") now := t0.Add(16 * time.Minute) diff --git a/internal/engine/progress.go b/internal/engine/progress.go index 0d786df5..df68c40d 100644 --- a/internal/engine/progress.go +++ b/internal/engine/progress.go @@ -181,6 +181,11 @@ func primaryAck(r state.Round, obs Observation, p Policy, firedAt time.Time) (st if obs.Reacted { return "bot reacted", true } + for _, review := range obs.Reviews { + if sameBot(review.Bot, p.Bot) && reviewMatchesRound(review, r.Head, firedAt) { + return "review submitted", true + } + } for _, ev := range obs.Events { if !sameBot(ev.Bot, p.Bot) || ev.CommentID == r.CommandID || ev.UpdatedAt.Before(firedAt) { continue diff --git a/internal/state/dashboard_test.go b/internal/state/dashboard_test.go index bd65af68..84f021f7 100644 --- a/internal/state/dashboard_test.go +++ b/internal/state/dashboard_test.go @@ -849,6 +849,36 @@ func TestHeldSlotStopsFreeRunningRoundsToo(t *testing.T) { } } +func TestOrphanedSlotHoldLeavesFreeRunningRoundsReady(t *testing.T) { + now := time.Now().UTC() + st := stateWith( + queuedRound("kristofferr/metered", 1, 1, now), + queuedRound("kristofferr/free", 2, 2, now), + ) + st.Rounds["kristofferr/free#2"] = func() Round { + r := st.Rounds["kristofferr/free#2"] + r.CoOnly = true + return r + }() + until := now.Add(time.Hour) + st.FireSlot = &FireSlot{ + Key: "kristofferr/gone#9", Token: "old", Since: now.Add(-time.Minute), + HoldUntil: &until, + } + st.FireSlotHoldUntil = &until + + q := st.Queue(now, 0) + if len(q) != 2 { + t.Fatalf("Queue = %d entries, want 2", len(q)) + } + if q[0].PR != 2 || q[0].Why != "" || !q[0].ReadyAt.IsZero() { + t.Fatalf("quota-free round = %+v, want it ready ahead of the orphaned hold", q[0]) + } + if q[1].PR != 1 || q[1].Why != WaitSlotBusy { + t.Fatalf("metered round = %+v, want it blocked by the orphaned hold", q[1]) + } +} + // The status line answers "is it still going?" continuously, so a session never // has to spend a tool call and a paragraph on it. Each state must read at a // glance, and it must never claim a next PR the queue itself will not name. diff --git a/internal/state/state.go b/internal/state/state.go index fdc67ef6..f6353a64 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -923,7 +923,8 @@ func (s *State) Queue(now time.Time, minInterval time.Duration) []QueueEntry { if s.Account.BlockedUntil != nil && s.Account.BlockedUntil.After(now) { blocked = s.Account.BlockedUntil.UTC() } - slotBusy := s.SlotHeld(now) + liveSlotBusy := s.SlotRound() != nil + orphanSlotBusy := !liveSlotBusy && s.SlotHeld(now) // The pacing gate applies to whichever round fires next, so it bounds every // entry's earliest possible start. var paced time.Time @@ -970,7 +971,7 @@ func (s *State) Queue(now time.Time, minInterval time.Duration) []QueueEntry { queued = append(queued, e) } - // A held slot stops EVERYTHING, free-running rounds included. + // A live slot stops EVERYTHING, free-running rounds included. // // Not because they need the slot — they do not — but because Pump returns as // soon as it sees a slot holder, so the quota-free path that would advance @@ -979,13 +980,20 @@ func (s *State) Queue(now time.Time, minInterval time.Duration) []QueueEntry { // ready here would promise action the daemon cannot take until the holder is // acknowledged. (An agent's own `crq next` can still resolve such a round // directly, which is why this describes the queue rather than forbidding it.) - if slotBusy { + if liveSlotBusy { for i := range queued { queued[i].ReadyAt, queued[i].Why = time.Time{}, WaitSlotBusy } for i := range freeRunning { freeRunning[i].ReadyAt, freeRunning[i].Why = time.Time{}, WaitSlotBusy } + } else if orphanSlotBusy { + // An orphaned bounded hold still blocks another metered fire, but Pump + // deliberately scans past it for quota-free work. Reflect that split: + // ordinary rounds wait on the slot while co-only rounds remain ready. + for i := range queued { + queued[i].ReadyAt, queued[i].Why = time.Time{}, WaitSlotBusy + } } // One list, ordered by readiness then Seq — which is NextEligible's own rule From 1aaab683bf10a49aa1d7714c2461b45b8cbb0b22 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Mon, 27 Jul 2026 15:38:16 +0200 Subject: [PATCH 19/37] Keep the fleet's policy in one place instead of on every machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-host configuration files diverge the moment somebody edits one, and nothing says so: each host is behaving correctly according to what it can see. Today one repository was excluded here and still reviewed by the Mac, because "which repositories crq covers" was an environment variable on two machines rather than a decision the fleet had made. This repository already argues the point, on the per-repository reviewer map: a daemon and an agent reading different configurations while writing one shared state ref is a new class of divergence, and both already read that ref. The same reasoning applies one level up, to the settings that say what the fleet does at all. So scope, repos, exclude, required-bots, min-interval, rate-limit-fallback, calibrate-ttl, settle and skip-marker move into the state ref, reachable through crq config. Three kinds of setting deliberately stay local: where the state lives — a host cannot read fleet policy until it knows where to look — credentials, and what a machine can physically do, which is genuinely not the same answer everywhere. Recorded wins over the environment, so adoption is one machine at a time: until something is recorded every host keeps its own, and crq config seed writes this host's answers for whatever the fleet has not decided. Seeding never overwrites a recorded value, or the last machine to run it would win — the divergence this is meant to end. A value this binary cannot read is refused where it is set, and one arriving from a newer crq leaves the host on its own value rather than acting on half a policy; crq doctor reports that, and any variable still set locally that the fleet overrides. Stored as a flat map so an older binary round-trips settings it does not know rather than dropping them. --- README.md | 24 ++++ cmd/crq/main.go | 114 ++++++++++++++++ internal/crq/auto.go | 31 +++-- internal/crq/fleetcmd.go | 139 ++++++++++++++++++++ internal/crq/fleetconfig.go | 216 +++++++++++++++++++++++++++++++ internal/crq/fleetconfig_test.go | 119 +++++++++++++++++ internal/crq/reviewers.go | 39 +++++- internal/crq/service.go | 6 + internal/state/fleet.go | 56 ++++++++ internal/state/state.go | 6 + 10 files changed, 734 insertions(+), 16 deletions(-) create mode 100644 internal/crq/fleetcmd.go create mode 100644 internal/crq/fleetconfig.go create mode 100644 internal/crq/fleetconfig_test.go create mode 100644 internal/state/fleet.go diff --git a/README.md b/README.md index b39c884f..458ba316 100644 --- a/README.md +++ b/README.md @@ -452,6 +452,30 @@ clears it. Ambiguous replies surface too — crq never buries a possible rebutta Set these in `~/.config/crq/env` (sourced automatically) or as environment variables: +### One configuration for the fleet, not one per machine + +These settings belong to the whole fleet and live in the state ref, not in the table below: +`scope`, `repos`, `exclude`, `required-bots`, `min-interval`, `rate-limit-fallback`, +`calibrate-ttl`, `settle`, `skip-marker`. + +```bash +crq config # what is in force, and where it came from +crq config set exclude owner/paused-repo +crq config unset exclude +crq config seed # adopt this host's current answers as the fleet's +``` + +Per-host files diverge the moment somebody edits one — a repository excluded on the laptop and +reviewed by the server — and nothing says so, because each host is behaving correctly according to +what it can see. A recorded setting wins over the matching environment variable, so you can adopt +this one machine at a time, and `crq doctor` names any variable still set locally that the fleet +overrides. + +What stays per host: where the state lives (`CRQ_REPO`, `CRQ_ISSUE`, `CRQ_STATE_REF`), credentials, +and what the machine can physically do (`CRQ_DISPATCH_CMD`, `CRQ_WORKSPACE`, +`CRQ_DISPATCH_CONCURRENCY`). + + | Variable | Default | What it does | |----------|---------|--------------| | `CRQ_REPO` | *(required)* | the gate repo (`owner/name`) holding the state ref, dashboard, calibration PR | diff --git a/cmd/crq/main.go b/cmd/crq/main.go index c9a0b8c3..9c0d1e86 100644 --- a/cmd/crq/main.go +++ b/cmd/crq/main.go @@ -49,6 +49,14 @@ func run(ctx context.Context, args []string) int { return 0 case "doctor": report := doctor(ctx) + // The fleet's own view needs the state ref, which needs credentials, so + // it is asked for only when the rest of the report says that will work. + // A doctor that cannot answer the basics has more urgent things to say. + if report.Ready { + if diverged := fleetDivergence(ctx); len(diverged) > 0 { + report.Recommendations = append(report.Recommendations, diverged...) + } + } printJSON(report) if report.Ready { return 0 @@ -263,6 +271,55 @@ func run(ctx context.Context, args []string) int { } printJSON(map[string]any{"status": "cancelled", "repo": crq.NormalizeRepo(repo), "pr": pr}) return 0 + case "config": + if err := cfg.RequireState(); err != nil { + fatal(err) + return 1 + } + rest := args[1:] + switch { + case len(rest) == 0 || rest[0] == "show": + settings, cerr := service.FleetConfig(ctx) + if cerr != nil { + fatal(cerr) + return 1 + } + printJSON(settings) + return 0 + case rest[0] == "set": + if len(rest) != 3 { + fatal(errors.New("usage: crq config set ")) + return 1 + } + if err := service.SetFleetConfig(ctx, rest[1], rest[2]); err != nil { + fatal(err) + return 1 + } + printJSON(map[string]any{"setting": rest[1], "value": rest[2], "scope": "fleet"}) + return 0 + case rest[0] == "unset": + if len(rest) != 2 { + fatal(errors.New("usage: crq config unset ")) + return 1 + } + dropped, uerr := service.UnsetFleetConfig(ctx, rest[1]) + if uerr != nil { + fatal(uerr) + return 1 + } + printJSON(map[string]any{"setting": rest[1], "cleared": dropped}) + return 0 + case rest[0] == "seed": + seeded, serr := service.SeedFleetConfig(ctx) + if serr != nil { + fatal(serr) + return 1 + } + printJSON(map[string]any{"seeded": seeded}) + return 0 + } + fatal(fmt.Errorf("unknown config subcommand %q (try: crq config, crq config set|unset [value], crq config seed)", rest[0])) + return 1 case "debug": return debug(ctx, service, store, cfg, args[1:]) default: @@ -370,6 +427,8 @@ USAGE keep open PRs reviewed, rate-coordinated crq preflight [--type all|committed|uncommitted] [--base ] local CodeRabbit CLI pre-push review as JSON + crq config [set|unset [value]] | seed + fleet-wide policy, shared by every host crq doctor emit JSON readiness report for agents and humans crq status [--line] print the dashboard, or one line for a status bar crq cancel [ ] remove queued/in-flight state for a PR @@ -603,6 +662,40 @@ Typical setup: crq init Save the printed exports to ~/.config/crq/env on every machine or agent host. +`) + case "config": + fmt.Print(`crq config +crq config set +crq config unset +crq config seed + +The policy every host shares, kept in the state ref rather than in each +machine's environment. + +Per-host configuration files diverge the moment somebody edits one — a +repository excluded on the laptop and reviewed by the server, a rate-limit +window one host respects and another does not — and nothing says so, because +each host is behaving correctly according to what it can see. These settings +have one answer for the fleet: + + scope, repos, exclude, required-bots, min-interval, rate-limit-fallback, + calibrate-ttl, settle, skip-marker + +Three kinds of setting deliberately stay local: where the state lives +(CRQ_REPO, CRQ_ISSUE, CRQ_STATE_REF — a host cannot read fleet policy until it +knows where to look), credentials, and what a machine can physically do (which +fix agent is installed, where its disk is, how many sessions it can take). + +A recorded setting wins over the environment variable it replaces, so adopting +this is one machine at a time: until something is recorded, every host keeps +using its own. "crq config seed" writes this host's current answers for +everything the fleet has not decided yet — run it once, on the machine whose +configuration is the one you mean. + +A value this crq cannot read is refused at "set", and one that arrives anyway +from a newer binary leaves the host on its own value rather than acting on half +a policy. "crq doctor" reports both that and any variable still set here that +the fleet overrides. `) case "doctor": fmt.Print(`crq doctor @@ -1201,3 +1294,24 @@ func configPath() string { } return home + "/.config/crq/env" } + +// fleetDivergence asks the state ref what the fleet's policy is and reports +// where this host disagrees. Errors are swallowed: doctor is a report, and one +// that fails because the network was down would be worse than one missing a +// section. +func fleetDivergence(ctx context.Context) []string { + cfg, err := crq.LoadConfig() + if err != nil || cfg.RequireState() != nil { + return nil + } + gh, err := ghapi.NewGitHub(ctx) + if err != nil { + return nil + } + store := crq.NewGitStateStore(cfg, gh, nil) + diverged, err := crq.NewService(cfg, gh, store, nil).FleetDivergence(ctx) + if err != nil { + return nil + } + return diverged +} diff --git a/internal/crq/auto.go b/internal/crq/auto.go index 2a3b7e68..13a555b5 100644 --- a/internal/crq/auto.go +++ b/internal/crq/auto.go @@ -3,6 +3,7 @@ package crq import ( "context" "errors" + "sort" "strings" "time" @@ -203,24 +204,30 @@ func (s *Service) renewLeader(ctx context.Context, owner, token string) (State, } func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, token string) error { - targets := s.cfg.Scope - byRepo := false - if len(s.cfg.AllowRepos) > 0 { - targets = make([]string, 0, len(s.cfg.AllowRepos)) - for repo := range s.cfg.AllowRepos { - targets = append(targets, repo) - } - byRepo = true - } // Load the queue snapshot once per pass and reuse it across candidates: a // git-backed Load is GetRef+GetCommit+GetTree+GetBlob, so reloading it per PR // would burn the shared REST quota on a large scan. The heartbeat refreshes it, // and enqueueBatch re-checks Contains under CAS, so a slightly stale snapshot // during collection is safe. + // + // It is loaded BEFORE the targets are chosen, because the state ref is where + // the fleet records which repositories those are. Reading this host's own + // list first is what let one machine scan a repository another had excluded. state, _, err := s.store.Load(ctx) if err != nil { return err } + cfg := s.fleetCfg(state) + targets := cfg.Scope + byRepo := false + if len(cfg.AllowRepos) > 0 { + targets = make([]string, 0, len(cfg.AllowRepos)) + for repo := range cfg.AllowRepos { + targets = append(targets, repo) + } + byRepo = true + } + sort.Strings(targets) // stable: a pass must not depend on map iteration var candidates []queueCandidate lastBeat := time.Now() for _, target := range targets { @@ -235,16 +242,16 @@ func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, t return true, nil } repo := NormalizeRepo(pr.Repo) - if repo == NormalizeRepo(s.cfg.GateRepo) || s.cfg.ExcludeRepos[repo] { + if repo == NormalizeRepo(s.cfg.GateRepo) || cfg.ExcludeRepos[repo] { return false, nil } - if len(s.cfg.AllowRepos) > 0 && !s.cfg.AllowRepos[repo] { + if len(cfg.AllowRepos) > 0 && !cfg.AllowRepos[repo] { return false, nil } if s.cfg.SkipAuthors[dialect.NormalizeBotName(strings.ToLower(pr.Author))] { return false, nil } - if s.cfg.SkipMarker != "" && strings.Contains(pr.Body, s.cfg.SkipMarker) { + if cfg.SkipMarker != "" && strings.Contains(pr.Body, cfg.SkipMarker) { return false, nil } scanned++ diff --git a/internal/crq/fleetcmd.go b/internal/crq/fleetcmd.go new file mode 100644 index 00000000..4d411a0e --- /dev/null +++ b/internal/crq/fleetcmd.go @@ -0,0 +1,139 @@ +package crq + +import ( + "context" + "fmt" + "os" +) + +// FleetSetting is one policy as `crq config` reports it: what the fleet +// records, what this host would use on its own, and which of the two is in +// effect. +type FleetSetting struct { + Key string `json:"key"` + Doc string `json:"doc"` + Env string `json:"env"` + Value string `json:"value"` + Source string `json:"source"` // "fleet" or "host" + // HostValue is what this machine's own environment says, reported only when + // it differs from what is in force. It is the divergence an operator needs + // to see: a setting they changed on this box that the fleet is overriding. + HostValue string `json:"host_value,omitempty"` + // Error is why a recorded value was not applied, when this binary could not + // read it. + Error string `json:"error,omitempty"` +} + +// FleetConfig reports every fleet setting, in force and as this host has it. +func (s *Service) FleetConfig(ctx context.Context) ([]FleetSetting, error) { + st, _, err := s.store.Load(ctx) + if err != nil { + return nil, err + } + settings := fleetSettings() + effective := s.fleetCfg(st) + out := make([]FleetSetting, 0, len(settings)) + for _, key := range FleetKeys() { + setting := settings[key] + item := FleetSetting{ + Key: key, Doc: setting.Doc, Env: setting.Env, + Value: setting.Show(effective), Source: "host", + } + if recorded, ok := st.FleetValue(key); ok { + item.Source = "fleet" + if err := ValidateFleetSetting(key, recorded); err != nil { + item.Source, item.Error = "host", err.Error() + } + } + if host := setting.Show(s.cfg); host != item.Value { + item.HostValue = host + } + out = append(out, item) + } + return out, nil +} + +// SetFleetConfig records one setting for every host, refusing a value this +// binary cannot read: the ref is shared, so a value only some hosts can parse +// breaks the fleet from the inside. +func (s *Service) SetFleetConfig(ctx context.Context, key, value string) error { + if err := ValidateFleetSetting(key, value); err != nil { + return err + } + _, err := s.store.Update(ctx, func(st *State) error { + st.SetFleetValue(key, value) + return nil + }) + return err +} + +// UnsetFleetConfig returns one setting to whatever each host says, reporting +// whether the fleet had an opinion to drop. +func (s *Service) UnsetFleetConfig(ctx context.Context, key string) (bool, error) { + if _, ok := fleetSettings()[key]; !ok { + return false, fmt.Errorf("unknown setting %q", key) + } + dropped := false + _, err := s.store.Update(ctx, func(st *State) error { + dropped = st.UnsetFleetValue(key) + if !dropped { + return ErrNoChange + } + return nil + }) + return dropped, err +} + +// SeedFleetConfig records this host's settings as the fleet's, for the ones the +// fleet has no answer for yet. +// +// It is how an existing setup adopts this without retyping it: run it once on +// the machine whose configuration is the one you mean. Settings the fleet has +// already recorded are left alone — seeding twice from two machines would +// otherwise make the last one to run the winner, which is the divergence this +// whole mechanism exists to end. +func (s *Service) SeedFleetConfig(ctx context.Context) ([]string, error) { + settings := fleetSettings() + var seeded []string + _, err := s.store.Update(ctx, func(st *State) error { + seeded = seeded[:0] + for _, key := range FleetKeys() { + if _, ok := st.FleetValue(key); ok { + continue + } + st.SetFleetValue(key, settings[key].Show(s.cfg)) + seeded = append(seeded, key) + } + if len(seeded) == 0 { + return ErrNoChange + } + return nil + }) + return seeded, err +} + +// FleetDivergence lists the settings whose value on this host differs from what +// the fleet records, for `crq doctor`. +// +// A host that quietly disagrees is the failure this is about: everything looks +// healthy on both machines, and a repository is excluded on one and reviewed by +// the other. Naming the variable is the point — the remedy is to delete it from +// this host's environment. +func (s *Service) FleetDivergence(ctx context.Context) ([]string, error) { + items, err := s.FleetConfig(ctx) + if err != nil { + return nil, err + } + var out []string + for _, item := range items { + switch { + case item.Error != "": + out = append(out, fmt.Sprintf("%s: the fleet's value is one this crq cannot read (%s); using this host's %q", + item.Key, item.Error, item.Value)) + case item.HostValue != "" && os.Getenv(item.Env) != "": + out = append(out, fmt.Sprintf("%s is %q for the fleet, but %s is set to %q on this host; remove it or run crq config set %s", + item.Key, item.Value, item.Env, item.HostValue, item.Key)) + } + } + return out, nil +} diff --git a/internal/crq/fleetconfig.go b/internal/crq/fleetconfig.go new file mode 100644 index 00000000..86fdc594 --- /dev/null +++ b/internal/crq/fleetconfig.go @@ -0,0 +1,216 @@ +package crq + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// fleetSetting is one policy the whole fleet shares: how to read it from the +// state ref onto a Config, and how to render what a host is currently using. +// +// The registry below is the definition of "fleet policy". A setting listed here +// stops being something each machine answers for itself; one that is not listed +// stays local, and the split is deliberate — see FleetKeys. +type fleetSetting struct { + // Doc is what the setting means, for `crq config`. + Doc string + // Env names the variable it used to come from, so an operator can find it. + Env string + // Apply writes value onto cfg, rejecting a value it cannot parse. A bad + // value must fail where it is SET, but it is validated here too: the state + // ref is shared, and a host reading a value it cannot use has to say so + // rather than silently keep its own. + Apply func(cfg *Config, value string) error + // Show renders what cfg is currently using, for comparison. + Show func(cfg Config) string +} + +// fleetSettings is every setting that belongs to the fleet rather than to a +// machine. +// +// What is NOT here matters as much. Three kinds stay local: where the state +// lives (CRQ_REPO, CRQ_ISSUE, CRQ_STATE_REF — a host cannot read fleet config +// until it knows where to look), credentials, and what this machine can +// physically do (which fix agent is installed, where its disk is, how many +// sessions it can take). Everything else is policy, and policy the hosts +// disagree about is how a repository ends up excluded on one and reviewed by +// another with nothing to say so. +func fleetSettings() map[string]fleetSetting { + return map[string]fleetSetting{ + "scope": { + Doc: "owners crq scans when no repository list is set", + Env: "CRQ_SCOPE", + Apply: func(cfg *Config, v string) error { + cfg.Scope = splitList(v) + return nil + }, + Show: func(cfg Config) string { return strings.Join(cfg.Scope, ",") }, + }, + "repos": { + Doc: "the repositories crq reviews and watches", + Env: "CRQ_REPOS", + Apply: func(cfg *Config, v string) error { + cfg.AllowRepos = repoSet(v) + return nil + }, + Show: func(cfg Config) string { return strings.Join(sortedRepoKeys(cfg.AllowRepos), ",") }, + }, + "exclude": { + Doc: "repositories crq never reviews, watches or fixes", + Env: "CRQ_EXCLUDE", + Apply: func(cfg *Config, v string) error { + cfg.ExcludeRepos = repoSet(v) + return nil + }, + Show: func(cfg Config) string { return strings.Join(sortedRepoKeys(cfg.ExcludeRepos), ",") }, + }, + "required-bots": { + Doc: "logins that must review a head before a round converges", + Env: "CRQ_REQUIRED_BOTS", + Apply: func(cfg *Config, v string) error { + cfg.RequiredBots = splitList(v) + return nil + }, + Show: func(cfg Config) string { return strings.Join(cfg.RequiredBots, ",") }, + }, + "min-interval": { + Doc: "floor between two metered reviews", + Env: "CRQ_MIN_INTERVAL", + Apply: func(cfg *Config, v string) error { + d, err := parseFleetDuration(v) + cfg.MinInterval = d + return err + }, + Show: func(cfg Config) string { return cfg.MinInterval.String() }, + }, + "rate-limit-fallback": { + Doc: "how long to wait when a rate-limit notice states no window", + Env: "CRQ_RL_FALLBACK", + Apply: func(cfg *Config, v string) error { + d, err := parseFleetDuration(v) + cfg.RateLimitFallback = d + return err + }, + Show: func(cfg Config) string { return cfg.RateLimitFallback.String() }, + }, + "calibrate-ttl": { + Doc: "how long a calibration answer counts as current", + Env: "CRQ_CALIBRATE_TTL", + Apply: func(cfg *Config, v string) error { + d, err := parseFleetDuration(v) + cfg.CalibrationTTL = d + return err + }, + Show: func(cfg Config) string { return cfg.CalibrationTTL.String() }, + }, + "settle": { + Doc: "quiet window before a PR counts as converged", + Env: "CRQ_SETTLE", + Apply: func(cfg *Config, v string) error { + d, err := parseFleetDuration(v) + cfg.SettleWindow = d + return err + }, + Show: func(cfg Config) string { return cfg.SettleWindow.String() }, + }, + "skip-marker": { + Doc: "text in a PR body that opts it out of review (empty disables the opt-out)", + Env: "CRQ_AUTOREVIEW_SKIP_MARKER", + Apply: func(cfg *Config, v string) error { + cfg.SkipMarker = v + return nil + }, + Show: func(cfg Config) string { return cfg.SkipMarker }, + }, + } +} + +// FleetKeys lists every setting the fleet owns, in a stable order. +func FleetKeys() []string { + settings := fleetSettings() + keys := make([]string, 0, len(settings)) + for key := range settings { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// parseFleetDuration rejects what a Go duration cannot express, and refuses a +// negative one: every setting here is a window, and a negative window is a +// setting that would make crq act immediately, for ever. +func parseFleetDuration(v string) (time.Duration, error) { + d, err := time.ParseDuration(strings.TrimSpace(v)) + if err != nil { + return 0, fmt.Errorf("not a duration (try 90s, 15m, 2h): %w", err) + } + if d < 0 { + return 0, fmt.Errorf("duration must not be negative, got %s", d) + } + return d, nil +} + +// ValidateFleetSetting reports whether key is a fleet setting and value is one +// it can hold. Set fails on a bad value rather than writing it: the state ref +// is shared, so a value only one host can parse is a value that breaks the +// fleet from the inside. +func ValidateFleetSetting(key, value string) error { + setting, ok := fleetSettings()[key] + if !ok { + return fmt.Errorf("unknown setting %q (try one of: %s)", key, strings.Join(FleetKeys(), ", ")) + } + probe := Config{} + return setting.Apply(&probe, value) +} + +// applyFleet overlays the fleet's recorded policy onto a host's configuration. +// +// Recorded wins. The environment is what a host uses until the fleet has an +// opinion — which is what makes adopting this safe on a machine at a time, and +// what `crq config seed` writes from. +// +// A value this binary cannot parse is LEFT to the host rather than applied +// half-way: a newer crq may have widened what it accepts, and acting on a +// partially-read policy is worse than acting on the one already in hand. The +// disagreement surfaces in `crq doctor`, not silently here. +func applyFleet(cfg Config, fleet map[string]string, warn func(string)) Config { + settings := fleetSettings() + for _, key := range sortedKeys(fleet) { + setting, ok := settings[key] + if !ok { + if warn != nil { + warn(fmt.Sprintf("fleet setting %q is not one this crq understands; ignoring it", key)) + } + continue + } + candidate := cfg + if err := setting.Apply(&candidate, fleet[key]); err != nil { + if warn != nil { + warn(fmt.Sprintf("fleet setting %q: %v; keeping this host's value", key, err)) + } + continue + } + cfg = candidate + } + return cfg +} + +func sortedKeys(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func sortedRepoKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/crq/fleetconfig_test.go b/internal/crq/fleetconfig_test.go new file mode 100644 index 00000000..3b0bbb03 --- /dev/null +++ b/internal/crq/fleetconfig_test.go @@ -0,0 +1,119 @@ +package crq + +import ( + "context" + "testing" + "time" +) + +// One setting, one place. A host that carries its own answer for something the +// fleet has decided is how a repository ends up excluded on one machine and +// reviewed by another, with nothing to say so. +func TestFleetPolicyOverridesTheHostsOwn(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"owner/local": true} + cfg.MinInterval = time.Minute + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + if err := svc.SetFleetConfig(ctx, "repos", "owner/fleet-a,owner/fleet-b"); err != nil { + t.Fatal(err) + } + if err := svc.SetFleetConfig(ctx, "min-interval", "5m"); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + got := svc.fleetCfg(st) + if !got.AllowRepos["owner/fleet-a"] || !got.AllowRepos["owner/fleet-b"] { + t.Errorf("repos = %v, want the fleet's list", got.AllowRepos) + } + if got.AllowRepos["owner/local"] { + t.Error("the host's own repository list survived the fleet's") + } + if got.MinInterval != 5*time.Minute { + t.Errorf("min-interval = %s, want the fleet's 5m", got.MinInterval) + } + + // A setting the fleet has no opinion on stays this host's answer. + if got.SettleWindow != cfg.SettleWindow { + t.Errorf("settle = %s, want the host's %s when the fleet is silent", got.SettleWindow, cfg.SettleWindow) + } + + // And unsetting hands it back. + if dropped, err := svc.UnsetFleetConfig(ctx, "min-interval"); err != nil || !dropped { + t.Fatalf("unset = %v %v", dropped, err) + } + st, _, _ = store.Load(ctx) + if svc.fleetCfg(st).MinInterval != time.Minute { + t.Error("unsetting a fleet setting did not return the host's own value") + } +} + +// A value only some binaries can read would break the fleet from the inside, +// so it is refused where it is set — and if one arrives anyway, from a newer +// crq, the host keeps what it has rather than acting on half a policy. +func TestFleetRefusesWhatItCannotRead(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.MinInterval = 90 * time.Second + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + if err := svc.SetFleetConfig(ctx, "min-interval", "later"); err == nil { + t.Error("a duration that does not parse was accepted") + } + if err := svc.SetFleetConfig(ctx, "min-interval", "-5m"); err == nil { + t.Error("a negative window was accepted") + } + if err := svc.SetFleetConfig(ctx, "nonsense", "1"); err == nil { + t.Error("an unknown setting was accepted") + } + + // Planted directly, as a newer binary would leave it. + if _, err := store.Update(ctx, func(st *State) error { + st.SetFleetValue("min-interval", "a fortnight") + st.SetFleetValue("from-the-future", "whatever") + return nil + }); err != nil { + t.Fatal(err) + } + st, _, _ := store.Load(ctx) + if got := svc.fleetCfg(st).MinInterval; got != 90*time.Second { + t.Errorf("min-interval = %s, want this host's 90s kept when the fleet's is unreadable", got) + } +} + +// Seeding is how an existing setup adopts this without retyping it, and it must +// not let a second machine overwrite the first one's answer. +func TestSeedingDoesNotOverwriteWhatTheFleetAlreadySays(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"owner/from-this-host": true} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + if err := svc.SetFleetConfig(ctx, "repos", "owner/decided-already"); err != nil { + t.Fatal(err) + } + seeded, err := svc.SeedFleetConfig(ctx) + if err != nil { + t.Fatal(err) + } + for _, key := range seeded { + if key == "repos" { + t.Error("seeding overwrote a setting the fleet had already recorded") + } + } + st, _, _ := store.Load(ctx) + if v, _ := st.FleetValue("repos"); v != "owner/decided-already" { + t.Errorf("repos = %q, want the fleet's existing answer", v) + } + // Everything else it had no answer for is now recorded. + if v, ok := st.FleetValue("settle"); !ok || v == "" { + t.Errorf("settle = %q %v, want this host's value seeded", v, ok) + } +} diff --git a/internal/crq/reviewers.go b/internal/crq/reviewers.go index c851f3c0..a5146356 100644 --- a/internal/crq/reviewers.go +++ b/internal/crq/reviewers.go @@ -321,18 +321,49 @@ func containsBot(logins []string, login string) bool { return false } -// cfgFor is the configuration crq should use for one repository: the fleet -// default with that repository's override applied. +// cfgFor is the configuration crq should use for one repository: this host's +// own settings, then the fleet's recorded policy, then that repository's +// override. Each layer is narrower than the one before it. func (s *Service) cfgFor(st State, repo string) Config { + base := s.fleetCfg(st) ov, ok := st.RepoOverride(repo) if !ok { - return s.cfg + return base } - out := s.cfg.ForRepo(ov) + out := base.ForRepo(ov) out.OverrideAt = ov.UpdatedAt return out } +// fleetCfg is this host's configuration with the fleet's policy applied. It is +// the base every other decision starts from, and the reason a setting only has +// to be changed in one place for every machine to follow it. +func (s *Service) fleetCfg(st State) Config { + if len(st.FleetConfig) == 0 { + return s.cfg + } + return applyFleet(s.cfg, st.FleetConfig, s.warnOnce) +} + +// warnOnce logs a fleet-configuration complaint at most once per process. The +// same unreadable setting is met on every pass, and a daemon repeating it every +// two minutes for a week is how a log stops being read. +func (s *Service) warnOnce(msg string) { + if s.log == nil { + return + } + s.fleetWarned.Lock() + defer s.fleetWarned.Unlock() + if s.fleetWarnedOf == nil { + s.fleetWarnedOf = map[string]bool{} + } + if s.fleetWarnedOf[msg] { + return + } + s.fleetWarnedOf[msg] = true + s.log.Printf("config: %s", msg) +} + // overrideChanged reports whether repo's reviewer override differs from the one // cfg was built from. // diff --git a/internal/crq/service.go b/internal/crq/service.go index 2077417e..4eef3387 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -10,6 +10,7 @@ import ( "net/url" "sort" "strings" + "sync" "time" "github.com/kristofferR/coderabbit-queue/internal/dialect" @@ -58,6 +59,11 @@ type Service struct { // scanOffset rotates the bounded quota-free rescue scan's window so a round // past the first few is not starved forever; in-memory only, same writer. scanOffset int + // fleetWarned guards fleetWarnedOf, which remembers the fleet-configuration + // complaints already logged. Passes read the fleet policy concurrently with + // dispatched sessions, so this one is genuinely shared. + fleetWarned sync.Mutex + fleetWarnedOf map[string]bool // now overrides the wall clock for the scheduling DECISIONS in the // pump/enqueue/sweep/wait paths (see clock). nil in production; the replay // suite injects a controllable fake so an incident can be re-enacted diff --git a/internal/state/fleet.go b/internal/state/fleet.go new file mode 100644 index 00000000..dbe00ef5 --- /dev/null +++ b/internal/state/fleet.go @@ -0,0 +1,56 @@ +package state + +import "sort" + +// Fleet is the policy every host in the fleet shares, keyed by setting name. +// +// It lives HERE for the reason the per-repository overrides do, one level up: +// a daemon on one machine and an agent on another reading different +// configurations while writing one shared state ref is a class of divergence +// worth not having. Per-host environment files diverge the moment somebody +// edits one — a repository excluded on the laptop and reviewed by the server, +// a rate-limit window one host respects and another does not — and nothing +// says so, because each host is behaving correctly according to what it can +// see. +// +// A flat map rather than a struct, on purpose. One JSON member means an older +// binary round-trips the whole thing rather than dropping settings it does not +// know, and adding a setting later needs no schema change. The keys are +// validated where they are set and where they are applied, not here. +type Fleet map[string]string + +// FleetValue returns a fleet setting and whether it is recorded. Absent means +// "the fleet has no opinion", which is what lets a host fall back to its own +// environment and to crq's defaults. +func (s *State) FleetValue(key string) (string, bool) { + value, ok := s.FleetConfig[key] + return value, ok +} + +// SetFleetValue records a fleet setting, replacing any earlier value. +func (s *State) SetFleetValue(key, value string) { + if s.FleetConfig == nil { + s.FleetConfig = Fleet{} + } + s.FleetConfig[key] = value +} + +// UnsetFleetValue drops a setting, returning the fleet to per-host defaults for +// it, and reports whether one was there. +func (s *State) UnsetFleetValue(key string) bool { + if _, ok := s.FleetConfig[key]; !ok { + return false + } + delete(s.FleetConfig, key) + return true +} + +// FleetKeys lists the recorded settings in a stable order. +func (s *State) FleetKeys() []string { + keys := make([]string, 0, len(s.FleetConfig)) + for key := range s.FleetConfig { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/internal/state/state.go b/internal/state/state.go index f6353a64..c96c8abe 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -338,6 +338,12 @@ type State struct { // read this ref, so both cannot disagree about it. Repos map[string]RepoReviewers `json:"repos,omitempty"` + // FleetConfig is the policy every host shares — which repositories are in + // scope, who reviews, and the timings the queue paces itself by. Same + // argument as Repos above, one level up: hosts that each carry their own + // answer diverge silently. See fleet.go. + FleetConfig Fleet `json:"fleet,omitempty"` + // Archive keeps recently finished rounds (superseded, closed, cancelled) // for the dashboard and debugging. Bounded by ArchiveMax. Archive []Round `json:"archive,omitempty"` From 4e5f21e7ffb7d1d5fffa481d561bc91084154a54 Mon Sep 17 00:00:00 2001 From: kristofferR <481270+kristofferR@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:05:46 +0200 Subject: [PATCH 20/37] Apply fleet policy consistently --- internal/crq/auto.go | 2 +- internal/crq/cliquota.go | 15 ++-- internal/crq/cliquota_test.go | 20 +++++ internal/crq/config.go | 16 +++- internal/crq/feedback.go | 2 +- internal/crq/fleetcmd.go | 118 ++++++++++++++++++++++++-- internal/crq/fleetconfig.go | 15 ++++ internal/crq/fleetconfig_test.go | 140 +++++++++++++++++++++++++++++++ internal/crq/next.go | 8 +- internal/crq/repoconfig.go | 10 ++- internal/crq/repoconfig_test.go | 32 +++++++ internal/crq/reviewers.go | 22 ++++- internal/crq/service.go | 20 +++-- internal/crq/service_test.go | 10 ++- internal/crq/state.go | 2 + internal/crq/watch.go | 23 ++--- internal/crq/watch_test.go | 10 ++- internal/state/dashboard.go | 2 + internal/state/dashboard_test.go | 6 ++ internal/state/fleet.go | 17 +++- internal/state/state.go | 6 +- internal/state/statusline.go | 1 + 22 files changed, 447 insertions(+), 50 deletions(-) diff --git a/internal/crq/auto.go b/internal/crq/auto.go index 6102bb59..a3fcb642 100644 --- a/internal/crq/auto.go +++ b/internal/crq/auto.go @@ -269,7 +269,7 @@ func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, t if s.cfg.SkipAuthors[dialect.NormalizeBotName(strings.ToLower(pr.Author))] { return false, nil } - if s.cfg.SkipsReview(pr.Body) { + if cfg.SkipsReview(pr.Body) { return false, nil } scanned++ diff --git a/internal/crq/cliquota.go b/internal/crq/cliquota.go index 55744026..3e992490 100644 --- a/internal/crq/cliquota.go +++ b/internal/crq/cliquota.go @@ -61,10 +61,15 @@ func (s *Service) RecordCLIQuota(ctx context.Context, report PreflightReport, cl if err := s.cfg.RequireState(); err != nil { return CLIQuotaResult{Reason: "no crq state configured, so there is no shared quota to update"}, nil } - if !s.cliOrgMatches(cliOrg) { + st, _, err := s.store.Load(ctx) + if err != nil { + return CLIQuotaResult{}, err + } + cfg := s.fleetCfg(st) + if !cliOrgMatches(cfg, cliOrg) { return CLIQuotaResult{ Reason: "the coderabbit cli is authenticated to " + orDash(cliOrg) + - ", which is not the account crq queues for (" + strings.Join(s.cfg.Scope, ",") + ")", + ", which is not the account crq queues for (" + strings.Join(cfg.Scope, ",") + ")", }, nil } @@ -75,7 +80,7 @@ func (s *Service) RecordCLIQuota(ctx context.Context, report PreflightReport, cl // conservative fallback is right: treating an unreadable window as "not // blocked" is what let the daemon re-fire every couple of minutes against // a limit measured in tens of minutes. - fallback := now.Add(cliQuotaFallback(s.cfg.RateLimitFallback)) + fallback := now.Add(cliQuotaFallback(cfg.RateLimitFallback)) until = &fallback } @@ -100,7 +105,7 @@ func (s *Service) RecordCLIQuota(ctx context.Context, report PreflightReport, cl // cliOrgMatches reports whether the CLI's current organisation is the account // crq queues for. An empty org fails closed: without knowing whose limit this is, // applying it fleet-wide is the more expensive mistake. -func (s *Service) cliOrgMatches(cliOrg string) bool { +func cliOrgMatches(cfg Config, cliOrg string) bool { org := strings.ToLower(strings.TrimSpace(cliOrg)) if org == "" { return false @@ -109,7 +114,7 @@ func (s *Service) cliOrgMatches(cliOrg string) bool { // let a personal CodeRabbit org stall an unrelated scope: with // CRQ_REPO=alice/crq-state and CRQ_SCOPE=acme, Alice's local limit would have // blocked every review for acme. - for _, scope := range s.cfg.Scope { + for _, scope := range cfg.Scope { if strings.EqualFold(strings.TrimSpace(scope), org) { return true } diff --git a/internal/crq/cliquota_test.go b/internal/crq/cliquota_test.go index eb6a28b7..1b159655 100644 --- a/internal/crq/cliquota_test.go +++ b/internal/crq/cliquota_test.go @@ -98,6 +98,26 @@ func TestRecordCLIQuotaRefusesAnotherAccount(t *testing.T) { } } +func TestRecordCLIQuotaUsesFleetScopeAndFallback(t *testing.T) { + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + svc, store := cliQuotaService(t, now) + if _, err := store.Update(context.Background(), func(st *State) error { + st.SetFleetValue("scope", "fleet-org") + st.SetFleetValue("rate-limit-fallback", "47m") + return nil + }); err != nil { + t.Fatal(err) + } + got, err := svc.RecordCLIQuota(context.Background(), blockedReport(t, "soon"), "fleet-org") + if err != nil { + t.Fatal(err) + } + want := now.Add(47 * time.Minute) + if !got.Applied || got.Until == nil || !got.Until.Equal(want) { + t.Fatalf("fleet quota result = %+v, want applied until %s", got, want) + } +} + // A window read from a PR comment is authoritative about the whole account; a // local reading may be a narrower limit. Extending is safe, shortening is not. func TestRecordCLIQuotaNeverShortensAStandingBlock(t *testing.T) { diff --git a/internal/crq/config.go b/internal/crq/config.go index dd42d842..c49608d2 100644 --- a/internal/crq/config.go +++ b/internal/crq/config.go @@ -83,7 +83,15 @@ type Config struct { FeedbackBotsExplicit bool // OverrideAt is when the per-repo reviewer override this configuration was // built from was last written, so a fire can tell whether it still holds. - OverrideAt *time.Time + OverrideAt *time.Time + // FleetRevision identifies the fleet policy snapshot this configuration was + // built from. Like OverrideAt, it is revalidated inside the CAS mutation + // that commits a decision. + FleetRevision string + // ExplicitFleetEnv records fleet-owned variables supplied by either the + // config file or the process environment. LoadConfig does not export normal + // config-file values, so os.Getenv alone cannot detect that divergence. + ExplicitFleetEnv map[string]bool RateLimitCommand string RateLimitMarker string CalibrationMarker string @@ -261,6 +269,12 @@ func LoadConfig() (Config, error) { RateLimitCoDegrade: stringEnv(env, "CRQ_RL_CO_DEGRADE", stringEnv(env, "CRQ_RL_CODEX_DEGRADE", "1")) != "0", } + cfg.ExplicitFleetEnv = map[string]bool{} + for _, setting := range fleetSettings() { + if _, ok := env[setting.Env]; ok { + cfg.ExplicitFleetEnv[setting.Env] = true + } + } if len(cfg.Scope) == 0 && cfg.GateRepo != "" { cfg.Scope = []string{ownerOf(cfg.GateRepo)} } diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index 84f73548..95703ace 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -702,7 +702,7 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport if settledAt.IsZero() { settledAt = s.clock() } - if s.cfg.SettleWindow <= 0 || s.clock().Sub(settledAt) >= s.cfg.SettleWindow { + if report.config.SettleWindow <= 0 || s.clock().Sub(settledAt) >= report.config.SettleWindow { if report.Converged { s.completeWaitRound(ctx, repo, pr, head, report.PrimaryAckPending, &report.config) } diff --git a/internal/crq/fleetcmd.go b/internal/crq/fleetcmd.go index 4d411a0e..54985872 100644 --- a/internal/crq/fleetcmd.go +++ b/internal/crq/fleetcmd.go @@ -3,7 +3,6 @@ package crq import ( "context" "fmt" - "os" ) // FleetSetting is one policy as `crq config` reports it: what the fleet @@ -22,6 +21,8 @@ type FleetSetting struct { // Error is why a recorded value was not applied, when this binary could not // read it. Error string `json:"error,omitempty"` + // Lagging names active queue drivers that cannot enforce fleet policy. + Lagging []string `json:"lagging_hosts,omitempty"` } // FleetConfig reports every fleet setting, in force and as this host has it. @@ -32,12 +33,13 @@ func (s *Service) FleetConfig(ctx context.Context) ([]FleetSetting, error) { } settings := fleetSettings() effective := s.fleetCfg(st) + lagging := st.LaggingWriters(CapsFleetPolicy, s.clock()) out := make([]FleetSetting, 0, len(settings)) for _, key := range FleetKeys() { setting := settings[key] item := FleetSetting{ Key: key, Doc: setting.Doc, Env: setting.Env, - Value: setting.Show(effective), Source: "host", + Value: setting.Show(effective), Source: "host", Lagging: lagging, } if recorded, ok := st.FleetValue(key); ok { item.Source = "fleet" @@ -60,8 +62,23 @@ func (s *Service) SetFleetConfig(ctx context.Context, key, value string) error { if err := ValidateFleetSetting(key, value); err != nil { return err } - _, err := s.store.Update(ctx, func(st *State) error { + if s.cfg.DryRun { + return nil + } + open, err := s.prepareFleetReviewerChange(ctx, key) + if err != nil { + return err + } + _, err = s.store.Update(ctx, func(st *State) error { + if err := s.requireFleetCapableDrivers(st); err != nil { + return err + } + before := s.fleetCfg(*st) + if current, ok := st.FleetValue(key); ok && current == value { + return ErrNoChange + } st.SetFleetValue(key, value) + s.reopenForFleetReviewerChange(st, before, s.fleetCfg(*st), open) return nil }) return err @@ -73,12 +90,29 @@ func (s *Service) UnsetFleetConfig(ctx context.Context, key string) (bool, error if _, ok := fleetSettings()[key]; !ok { return false, fmt.Errorf("unknown setting %q", key) } + if s.cfg.DryRun { + st, _, err := s.store.Load(ctx) + if err != nil { + return false, err + } + _, dropped := st.FleetValue(key) + return dropped, nil + } + open, err := s.prepareFleetReviewerChange(ctx, key) + if err != nil { + return false, err + } dropped := false - _, err := s.store.Update(ctx, func(st *State) error { + _, err = s.store.Update(ctx, func(st *State) error { + if err := s.requireFleetCapableDrivers(st); err != nil { + return err + } + before := s.fleetCfg(*st) dropped = st.UnsetFleetValue(key) if !dropped { return ErrNoChange } + s.reopenForFleetReviewerChange(st, before, s.fleetCfg(*st), open) return nil }) return dropped, err @@ -95,7 +129,30 @@ func (s *Service) UnsetFleetConfig(ctx context.Context, key string) (bool, error func (s *Service) SeedFleetConfig(ctx context.Context) ([]string, error) { settings := fleetSettings() var seeded []string - _, err := s.store.Update(ctx, func(st *State) error { + initial, _, err := s.store.Load(ctx) + if err != nil { + return nil, err + } + if s.cfg.DryRun { + for _, key := range FleetKeys() { + if _, ok := initial.FleetValue(key); !ok { + seeded = append(seeded, key) + } + } + return seeded, nil + } + var open map[string]map[int]bool + if _, ok := initial.FleetValue("required-bots"); !ok { + open, err = s.openFleetPRs(ctx, initial) + if err != nil { + return nil, err + } + } + _, err = s.store.Update(ctx, func(st *State) error { + if err := s.requireFleetCapableDrivers(st); err != nil { + return err + } + before := s.fleetCfg(*st) seeded = seeded[:0] for _, key := range FleetKeys() { if _, ok := st.FleetValue(key); ok { @@ -107,6 +164,7 @@ func (s *Service) SeedFleetConfig(ctx context.Context) ([]string, error) { if len(seeded) == 0 { return ErrNoChange } + s.reopenForFleetReviewerChange(st, before, s.fleetCfg(*st), open) return nil }) return seeded, err @@ -130,10 +188,58 @@ func (s *Service) FleetDivergence(ctx context.Context) ([]string, error) { case item.Error != "": out = append(out, fmt.Sprintf("%s: the fleet's value is one this crq cannot read (%s); using this host's %q", item.Key, item.Error, item.Value)) - case item.HostValue != "" && os.Getenv(item.Env) != "": + case item.HostValue != "" && s.cfg.ExplicitFleetEnv[item.Env]: out = append(out, fmt.Sprintf("%s is %q for the fleet, but %s is set to %q on this host; remove it or run crq config set %s", item.Key, item.Value, item.Env, item.HostValue, item.Key)) } } return out, nil } + +func (s *Service) requireFleetCapableDrivers(st *State) error { + if lagging := st.LaggingWriters(CapsFleetPolicy, s.clock()); len(lagging) > 0 { + return fmt.Errorf("cannot activate fleet policy while queue drivers lack fleet-policy support: %v", lagging) + } + return nil +} + +func (s *Service) prepareFleetReviewerChange(ctx context.Context, key string) (map[string]map[int]bool, error) { + if key != "required-bots" { + return nil, nil + } + st, _, err := s.store.Load(ctx) + if err != nil { + return nil, err + } + return s.openFleetPRs(ctx, st) +} + +func (s *Service) openFleetPRs(ctx context.Context, st State) (map[string]map[int]bool, error) { + repos := map[string]bool{} + for _, round := range st.Rounds { + repos[NormalizeRepo(round.Repo)] = true + } + open := make(map[string]map[int]bool, len(repos)) + for repo := range repos { + pulls, err := s.openPRs(ctx, repo) + if err != nil { + return nil, err + } + open[repo] = pulls + } + return open, nil +} + +func (s *Service) reopenForFleetReviewerChange(st *State, before, after Config, open map[string]map[int]bool) { + if sameLogins(before.RequiredBots, after.RequiredBots) { + return + } + repos := map[string]bool{} + for _, round := range st.Rounds { + repos[NormalizeRepo(round.Repo)] = true + } + for repo := range repos { + ov, _ := st.RepoOverride(repo) + s.reopenForChangedReviewers(st, repo, before.ForRepo(ov), after.ForRepo(ov), open[repo]) + } +} diff --git a/internal/crq/fleetconfig.go b/internal/crq/fleetconfig.go index 86fdc594..69968b59 100644 --- a/internal/crq/fleetconfig.go +++ b/internal/crq/fleetconfig.go @@ -194,6 +194,21 @@ func applyFleet(cfg Config, fleet map[string]string, warn func(string)) Config { } cfg = candidate } + // RequiredBots is the input to the derived reviewer views. Applying it as a + // scalar without rebuilding those views can leave a required co-reviewer + // disabled or carrying its optional trigger mode. + if _, ok := fleet["required-bots"]; ok { + enabled := make([]string, 0, len(cfg.CoBots)) + for _, cb := range cfg.CoBots { + enabled = append(enabled, cb.Login) + } + cfg = cfg.ForRepo(RepoReviewers{ + CoBots: enabled, + SetCoBots: true, + Required: cfg.RequiredBots, + SetRequired: true, + }) + } return cfg } diff --git a/internal/crq/fleetconfig_test.go b/internal/crq/fleetconfig_test.go index 3b0bbb03..7f4103d8 100644 --- a/internal/crq/fleetconfig_test.go +++ b/internal/crq/fleetconfig_test.go @@ -2,8 +2,12 @@ package crq import ( "context" + "strings" "testing" "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) // One setting, one place. A host that carries its own answer for something the @@ -117,3 +121,139 @@ func TestSeedingDoesNotOverwriteWhatTheFleetAlreadySays(t *testing.T) { t.Errorf("settle = %q %v, want this host's value seeded", v, ok) } } + +func TestFleetRequiredBotsRebuildsDerivedReviewers(t *testing.T) { + cfg := isolatedConfig(t, map[string]string{ + "CRQ_COBOTS": "codex", + "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", + }) + st := DefaultState(cfg) + st.SetFleetValue("required-bots", "coderabbitai[bot],"+dialect.CodexBotLogin) + + got := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil).fleetCfg(st) + if !containsBot(got.RequiredBots, dialect.CodexBotLogin) { + t.Fatalf("RequiredBots = %v, want fleet-required codex", got.RequiredBots) + } + if len(got.CoBots) != 1 || !got.CoBots[0].Required || got.CoBots[0].Trigger != "always" { + t.Fatalf("CoBots = %+v, want required codex with its required trigger", got.CoBots) + } +} + +func TestFleetMutationsAreInertInDryRun(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.SetFleetValue("min-interval", "5m") + return nil + }); err != nil { + t.Fatal(err) + } + cfg.DryRun = true + svc := NewService(cfg, newFakeGitHub(), store, nil) + + if err := svc.SetFleetConfig(ctx, "min-interval", "10m"); err != nil { + t.Fatal(err) + } + if dropped, err := svc.UnsetFleetConfig(ctx, "min-interval"); err != nil || !dropped { + t.Fatalf("dry-run unset = %v, %v; want the report without a write", dropped, err) + } + if seeded, err := svc.SeedFleetConfig(ctx); err != nil || len(seeded) == 0 { + t.Fatalf("dry-run seed = %v, %v; want missing keys reported", seeded, err) + } + st, _, _ := store.Load(ctx) + if value, _ := st.FleetValue("min-interval"); value != "5m" || len(st.FleetConfig) != 1 { + t.Fatalf("dry run changed fleet state: %v", st.FleetConfig) + } +} + +func TestFleetDivergenceIncludesConfigFileValues(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.MinInterval = time.Minute + cfg.ExplicitFleetEnv = map[string]bool{"CRQ_MIN_INTERVAL": true} + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.SetFleetValue("min-interval", "5m") + return nil + }); err != nil { + t.Fatal(err) + } + got, err := NewService(cfg, newFakeGitHub(), store, nil).FleetDivergence(ctx) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || !strings.Contains(got[0], "CRQ_MIN_INTERVAL") { + t.Fatalf("divergence = %v, want the explicitly loaded config-file value", got) + } +} + +func TestFleetRevisionInvalidatesAStaleDecision(t *testing.T) { + cfg := firingConfig() + st := DefaultState(cfg) + svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) + snapshot := svc.cfgFor(st, "owner/repo") + st.SetFleetValue("min-interval", "5m") + if !overrideChanged(&st, "owner/repo", snapshot) { + t.Error("a fleet policy change did not invalidate the earlier decision") + } +} + +func TestFleetSettleWindowShapesNext(t *testing.T) { + cfg := firingConfig() + cfg.SettleWindow = 0 + st := DefaultState(cfg) + st.SetFleetValue("settle", "5m") + effective := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil).fleetCfg(st) + evidence := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + got := settleUntil(FeedbackReport{LastEvidenceAt: evidence}, effective.SettleWindow) + want := evidence.Add(5 * time.Minute) + if got == nil || !got.Equal(want) { + t.Fatalf("settle until = %v, want %s", got, want) + } +} + +func TestFleetPolicyRefusesALaggingQueueDriver(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + cfg := firingConfig() + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.Leader = &LeaderLease{Owner: "old-daemon", ExpiresAt: now.Add(time.Minute)} + st.NoteWriter("old-daemon", CapsRepoOverrides, now) + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, newFakeGitHub(), store, nil) + svc.now = func() time.Time { return now } + if err := svc.SetFleetConfig(ctx, "min-interval", "5m"); err == nil || !strings.Contains(err.Error(), "lack fleet-policy support") { + t.Fatalf("set with a lagging driver = %v, want a capability refusal", err) + } +} + +func TestFleetReviewerChangeReopensCompletedRounds(t *testing.T) { + ctx := context.Background() + cfg := isolatedConfig(t, map[string]string{ + "CRQ_COBOTS": "codex", + "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", + }) + repo, pr := "owner/repo", 7 + gh := newFakeGitHub() + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.PutRound(Round{Repo: repo, PR: pr, Head: "abcdef123", Phase: PhaseCompleted}) + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, gh, store, nil) + if err := svc.SetFleetConfig(ctx, "required-bots", "coderabbitai[bot],"+dialect.CodexBotLogin); err != nil { + t.Fatal(err) + } + st, _, _ := store.Load(ctx) + if round := st.Round(repo, pr); round == nil || round.Phase != PhaseQueued { + t.Fatalf("round = %+v, want completed round reopened", round) + } +} diff --git a/internal/crq/next.go b/internal/crq/next.go index b5d78bbc..3b297ef1 100644 --- a/internal/crq/next.go +++ b/internal/crq/next.go @@ -174,11 +174,11 @@ func (s *Service) Next(ctx context.Context, repo string, pr int) (NextReport, er // settleUntil is when a convergence verdict may be trusted: the newest review // plus the configured quiet period. Nil when settling is disabled or nothing has // been observed to settle from. -func (s *Service) settleUntil(feedback FeedbackReport) *time.Time { - if s.cfg.SettleWindow <= 0 || feedback.LastEvidenceAt.IsZero() { +func settleUntil(feedback FeedbackReport, window time.Duration) *time.Time { + if window <= 0 || feedback.LastEvidenceAt.IsZero() { return nil } - at := feedback.LastEvidenceAt.Add(s.cfg.SettleWindow).UTC() + at := feedback.LastEvidenceAt.Add(window).UTC() return &at } @@ -260,7 +260,7 @@ func (s *Service) nextFromState(ctx context.Context, repo string, pr int) (NextR Deferred: feedback.CodeRabbitDeferred, DeferredUntil: feedback.DeferredUntil, MinDelay: s.cfg.PollInterval, - SettleUntil: s.settleUntil(feedback), + SettleUntil: settleUntil(feedback, feedback.config.SettleWindow), } // Only a round that still tracks THIS head may shape the verdict. A stale // fired/reviewing round carries its own phase and deadline, and an elapsed diff --git a/internal/crq/repoconfig.go b/internal/crq/repoconfig.go index b79886cf..9ae02f5e 100644 --- a/internal/crq/repoconfig.go +++ b/internal/crq/repoconfig.go @@ -163,6 +163,7 @@ func (s *Service) SetReviewers(ctx context.Context, repo string, coBots, require } now := s.clock().UTC() state, err := s.store.Update(ctx, func(st *State) error { + base := s.fleetCfg(*st) ov, _ := st.RepoOverride(repo) beforeOverride := ov if coBots != nil { @@ -178,9 +179,9 @@ func (s *Service) SetReviewers(ctx context.Context, repo string, coBots, require return ErrNoChange } ov.UpdatedAt, ov.By = &now, s.cfg.Host - before := s.cfg.ForRepo(mustOverride(st, repo)) + before := base.ForRepo(mustOverride(st, repo)) st.SetRepoOverride(repo, ov) - s.reopenForChangedReviewers(st, repo, before, s.cfg.ForRepo(ov), open) + s.reopenForChangedReviewers(st, repo, before, base.ForRepo(ov), open) return nil }) if err != nil { @@ -204,11 +205,12 @@ func (s *Service) ClearReviewers(ctx context.Context, repo string) (ReviewerView return ReviewerView{}, err } state, err := s.store.Update(ctx, func(st *State) error { - before := s.cfg.ForRepo(mustOverride(st, repo)) + base := s.fleetCfg(*st) + before := base.ForRepo(mustOverride(st, repo)) if !st.ClearRepoOverride(repo) { return ErrNoChange } - s.reopenForChangedReviewers(st, repo, before, s.cfg, open) + s.reopenForChangedReviewers(st, repo, before, base, open) return nil }) if err != nil && !errors.Is(err, ErrNoChange) { diff --git a/internal/crq/repoconfig_test.go b/internal/crq/repoconfig_test.go index e53cd8e2..d4e55653 100644 --- a/internal/crq/repoconfig_test.go +++ b/internal/crq/repoconfig_test.go @@ -67,6 +67,38 @@ func TestRepoOverrideReachesTheDecision(t *testing.T) { } } +func TestClearReviewersComparesAgainstFleetPolicy(t *testing.T) { + ctx := context.Background() + cfg := isolatedConfig(t, map[string]string{ + "CRQ_COBOTS": "codex", + "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", + }) + repo, pr := "owner/repo", 19 + now := time.Now().UTC() + gh := newFakeGitHub() + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.SetFleetValue("required-bots", "coderabbitai[bot],"+dialect.CodexBotLogin) + st.SetRepoOverride(repo, RepoReviewers{ + Required: []string{"coderabbitai[bot]"}, + SetRequired: true, + UpdatedAt: &now, + }) + st.PutRound(Round{Repo: repo, PR: pr, Head: "abcdef123", Phase: PhaseCompleted}) + return nil + }); err != nil { + t.Fatal(err) + } + if _, err := NewService(cfg, gh, store, nil).ClearReviewers(ctx, repo); err != nil { + t.Fatal(err) + } + st, _, _ := store.Load(ctx) + if round := st.Round(repo, pr); round == nil || round.Phase != PhaseQueued { + t.Fatalf("round = %+v, want clearing to the fleet reviewer set to reopen it", round) + } +} + // The override must be able to ADD a reviewer, not only subtract one. Resolving // choices against the fleet's enabled list made "which bots for which project" // a one-way filter: with CRQ_COBOTS=codex, asking for bugbot was rejected as diff --git a/internal/crq/reviewers.go b/internal/crq/reviewers.go index a5146356..1a998acd 100644 --- a/internal/crq/reviewers.go +++ b/internal/crq/reviewers.go @@ -339,10 +339,23 @@ func (s *Service) cfgFor(st State, repo string) Config { // the base every other decision starts from, and the reason a setting only has // to be changed in one place for every machine to follow it. func (s *Service) fleetCfg(st State) Config { - if len(st.FleetConfig) == 0 { - return s.cfg + cfg := s.cfg + if len(st.FleetConfig) != 0 { + cfg = applyFleet(cfg, st.FleetConfig, s.warnOnce) } - return applyFleet(s.cfg, st.FleetConfig, s.warnOnce) + cfg.FleetRevision = fleetRevision(st) + return cfg +} + +func fleetRevision(st State) string { + var b strings.Builder + for _, key := range st.FleetKeys() { + b.WriteString(key) + b.WriteByte(0) + b.WriteString(st.FleetConfig[key]) + b.WriteByte(0) + } + return b.String() } // warnOnce logs a fleet-configuration complaint at most once per process. The @@ -374,6 +387,9 @@ func (s *Service) warnOnce(msg string) { // reads is the state the write lands on; a separate read beforehand would leave // the same window one step earlier. func overrideChanged(st *State, repo string, cfg Config) bool { + if fleetRevision(*st) != cfg.FleetRevision { + return true + } ov, _ := st.RepoOverride(repo) switch { case ov.UpdatedAt == nil && cfg.OverrideAt == nil: diff --git a/internal/crq/service.go b/internal/crq/service.go index d04a066e..b2822266 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -1868,7 +1868,7 @@ func (s *Service) Status(ctx context.Context) (State, string, error) { if err != nil { return State{}, "", err } - return state, renderDashboard(state, s.cfg), nil + return state, renderDashboard(state, s.fleetCfg(state)), nil } func (s *Service) RefreshQuota(ctx context.Context) (State, error) { @@ -1876,22 +1876,24 @@ func (s *Service) RefreshQuota(ctx context.Context) (State, error) { if err != nil { return State{}, err } - if s.cfg.CalibrationPR <= 0 { + cfg := s.fleetCfg(state) + if cfg.CalibrationPR <= 0 { return state, nil } now := s.clock() // Honor the freshness shortcut only when the last reading was conclusive. If a // probe is still pending (CalibAskedAt set, no reply yet), keep re-checking so a // late "account blocked" reply isn't ignored for the full TTL. - if state.Account.CalibAskedAt == nil && state.Account.CheckedAt != nil && now.Sub(*state.Account.CheckedAt) < s.cfg.CalibrationTTL { + if state.Account.CalibAskedAt == nil && state.Account.CheckedAt != nil && now.Sub(*state.Account.CheckedAt) < cfg.CalibrationTTL { return state, nil } - quota, err := s.readQuota(ctx, s.calibrationIssue(state), now, state.Account.CalibAskedAt) + quota, err := s.readQuota(ctx, s.calibrationIssue(state), now, state.Account.CalibAskedAt, cfg) if err != nil { return state, err } updated, err := s.store.Update(ctx, func(st *State) error { - if st.Account.CalibAskedAt == nil && st.Account.CheckedAt != nil && now.Sub(*st.Account.CheckedAt) < s.cfg.CalibrationTTL { + currentTTL := s.fleetCfg(*st).CalibrationTTL + if st.Account.CalibAskedAt == nil && st.Account.CheckedAt != nil && now.Sub(*st.Account.CheckedAt) < currentTTL { return ErrNoChange } // A fresh reading replaces the whole quota; carry the account-quota comment @@ -1987,10 +1989,10 @@ func (s *Service) rotateCalibration(ctx context.Context, oldIssue int) (int, err return issue.Number, nil } -func (s *Service) readQuota(ctx context.Context, issue int, now time.Time, pendingAsked *time.Time) (AccountQuota, error) { - quota := AccountQuota{Scope: strings.Join(s.cfg.Scope, ","), Source: "calibrate", CheckedAt: &now} - cutoff := now.Add(-s.cfg.CalibrationTTL) - keepAfter := now.Add(-2 * s.cfg.CalibrationTTL) +func (s *Service) readQuota(ctx context.Context, issue int, now time.Time, pendingAsked *time.Time, cfg Config) (AccountQuota, error) { + quota := AccountQuota{Scope: strings.Join(cfg.Scope, ","), Source: "calibrate", CheckedAt: &now} + cutoff := now.Add(-cfg.CalibrationTTL) + keepAfter := now.Add(-2 * cfg.CalibrationTTL) if reply, ok, err := s.latestCalibrationReply(ctx, issue, cutoff); err != nil { return quota, err } else if ok { diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index f8ebad98..b6409d62 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -632,11 +632,11 @@ func TestAutoReviewScanSkipsMarkedPRs(t *testing.T) { ReviewCommand: "@coderabbitai review", LeaderTTL: time.Minute, AutoReviewMaxScan: 10, - SkipMarker: "", + SkipMarker: "", } gh := newFakeGitHub() gh.searchPRs = []ghapi.SearchPR{ - {Repo: "o/app", Number: 1, Author: "alice", Body: "Tiny maintenance change.\n\n"}, + {Repo: "o/app", Number: 1, Author: "alice", Body: "Tiny maintenance change.\n\n"}, {Repo: "o/app", Number: 2, Author: "alice", Body: "Review this change."}, } for pr := 1; pr <= 2; pr++ { @@ -646,6 +646,12 @@ func TestAutoReviewScanSkipsMarkedPRs(t *testing.T) { gh.pulls[fakeKey("o/app", pr)] = pull } store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.SetFleetValue("skip-marker", "") + return nil + }); err != nil { + t.Fatal(err) + } svc := NewService(cfg, gh, store, nil) if err := svc.AutoReview(ctx, AutoOptions{Once: true, Incremental: true}); err != nil { diff --git a/internal/crq/state.go b/internal/crq/state.go index 9c37dc1e..5d470507 100644 --- a/internal/crq/state.go +++ b/internal/crq/state.go @@ -50,6 +50,8 @@ const ( DrainUnhealthyAfter = crqstate.DrainUnhealthyAfter // CapsRepoOverrides is the binary capability per-repo reviewer overrides need. CapsRepoOverrides = crqstate.CapsRepoOverrides + // CapsFleetPolicy is the binary capability state-backed fleet policy needs. + CapsFleetPolicy = crqstate.CapsFleetPolicy ) var ( diff --git a/internal/crq/watch.go b/internal/crq/watch.go index b4f4bcbc..4645a0c5 100644 --- a/internal/crq/watch.go +++ b/internal/crq/watch.go @@ -179,6 +179,11 @@ func (s *Service) Watch(ctx context.Context, opts WatchOptions, emit func(WatchE func (s *Service) watchPass(ctx context.Context, opts WatchOptions, pool *dispatchPool, emit func(WatchEvent) error) error { var failures []string + state, _, err := s.store.Load(ctx) + if err != nil { + return err + } + cfg := s.fleetCfg(state) type pendingEvent struct { event WatchEvent result <-chan dispatchResult @@ -190,10 +195,10 @@ func (s *Service) watchPass(ctx context.Context, opts WatchOptions, pool *dispat // repository names straight over the fix command, and every dispatch in the // fleet died with "fork/exec kristofferr/coderabbit-queue: no such file or // directory". A caller's slice is the caller's. - repos := make([]string, 0, len(opts.Repos)+len(s.cfg.AllowRepos)) + repos := make([]string, 0, len(opts.Repos)+len(cfg.AllowRepos)) repos = append(repos, opts.Repos...) if len(repos) == 0 { - for repo := range s.cfg.AllowRepos { + for repo := range cfg.AllowRepos { repos = append(repos, repo) } sort.Strings(repos) // stable order: a pass must not depend on map iteration @@ -213,16 +218,14 @@ func (s *Service) watchPass(ctx context.Context, opts WatchOptions, pool *dispat pull ghapi.Pull } var candidates []candidate - gate := NormalizeRepo(s.cfg.GateRepo) + gate := NormalizeRepo(cfg.GateRepo) // Which repositories may be FIXED is per repository, read once for the pass. // Watching is unaffected: a repository with draining off is still observed // and still reviewed, so its feedback arrives for a person to act on. drainOff := map[string]bool{} - if st, _, err := s.store.Load(ctx); err == nil { - for _, repo := range repos { - if !st.DrainEnabled(repo) { - drainOff[NormalizeRepo(repo)] = true - } + for _, repo := range repos { + if !state.DrainEnabled(repo) { + drainOff[NormalizeRepo(repo)] = true } } for _, repo := range repos { @@ -238,7 +241,7 @@ func (s *Service) watchPass(ctx context.Context, opts WatchOptions, pool *dispat // it; this one did not, so the single setting that reads like a fleet-wide // opt-out silently covered half of what crq does — reviews stopped and the // watcher carried on, which is not a setting anyone can reason about. - if s.cfg.ExcludeRepos[NormalizeRepo(repo)] { + if cfg.ExcludeRepos[NormalizeRepo(repo)] { continue } pulls, err := s.gh.ListPulls(ctx, repo, openPullQuery()) @@ -278,7 +281,7 @@ func (s *Service) watchPass(ctx context.Context, opts WatchOptions, pool *dispat // the shared quota. Next is a MUTATING oracle — it enqueues and can // fire — so the marker has to be honoured before calling it, not // after. - if s.cfg.SkipsReview(pull.Body) { + if cfg.SkipsReview(pull.Body) { event := WatchEvent{ Repo: repo, PR: pull.Number, Action: "skipped", Reason: "fleet auto-review skip marker present", diff --git a/internal/crq/watch_test.go b/internal/crq/watch_test.go index 52aed5fd..c8fbe8cc 100644 --- a/internal/crq/watch_test.go +++ b/internal/crq/watch_test.go @@ -1066,14 +1066,20 @@ func dispatchOn() *bool { func TestWatchHonoursTheExcludedRepositories(t *testing.T) { cfg := firingConfig() cfg.AllowRepos = map[string]bool{"owner/kept": true, "owner/gone": true} - cfg.ExcludeRepos = map[string]bool{"owner/gone": true} gh := newFakeGitHub() for _, repo := range []string{"owner/kept", "owner/gone"} { var pull ghapi.Pull pull.State, pull.Number, pull.Head.SHA = "open", 1, "aaaaaaaa1" gh.pulls[fakeKey(repo, 1)] = pull } - svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) + store := NewMemoryStore(cfg) + if _, err := store.Update(context.Background(), func(st *State) error { + st.SetFleetValue("exclude", "owner/gone") + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, gh, store, nil) var seen []string if err := svc.watchPass(context.Background(), WatchOptions{}, newDispatchPool(0), diff --git a/internal/state/dashboard.go b/internal/state/dashboard.go index ea6ff1c7..e2bbf980 100644 --- a/internal/state/dashboard.go +++ b/internal/state/dashboard.go @@ -257,6 +257,7 @@ func hostName(writer string) string { // RenderDashboard renders the human-facing dashboard for the current state: // rounds by phase instead of v2's queue/fired/awaiting maps. func RenderDashboard(st State, cfg StoreConfig) string { + cfg = cfg.withFleet(st) loc := dashboardLoc(cfg) now := time.Now().UTC() queue := st.Queue(now, cfg.MinInterval) @@ -411,6 +412,7 @@ func RenderDashboard(st State, cfg StoreConfig) string { // count is the WHOLE queue, cooling-down rounds included: a state whose only // work is not yet fire-eligible is queued, never idle. func RenderTitle(st State, cfg StoreConfig) string { + cfg = cfg.withFleet(st) now := time.Now().UTC() queue := len(st.Queue(now, cfg.MinInterval)) held := len(heldRounds(st)) diff --git a/internal/state/dashboard_test.go b/internal/state/dashboard_test.go index 107b8761..bb319e75 100644 --- a/internal/state/dashboard_test.go +++ b/internal/state/dashboard_test.go @@ -933,6 +933,12 @@ func TestStatusLine(t *testing.T) { if got := StatusLine(ready, StoreConfig{}); !strings.Contains(got, "next #7") { t.Errorf("a ready queue should name what is next, got %q", got) } + fleetPaced := stateWith(queuedRound("kristofferr/a", 7, 1, now)) + fleetPaced.LastFired = &now + fleetPaced.SetFleetValue("min-interval", "1h") + if got := StatusLine(fleetPaced, StoreConfig{MinInterval: 0}); strings.Contains(got, "next #7") { + t.Errorf("fleet pacing must keep status from claiming the round is ready: %q", got) + } // Blocked: the countdown is the useful part. blockedState := stateWith(queuedRound("kristofferr/a", 7, 1, now)) diff --git a/internal/state/fleet.go b/internal/state/fleet.go index dbe00ef5..7206a579 100644 --- a/internal/state/fleet.go +++ b/internal/state/fleet.go @@ -1,6 +1,9 @@ package state -import "sort" +import ( + "sort" + "time" +) // Fleet is the policy every host in the fleet shares, keyed by setting name. // @@ -54,3 +57,15 @@ func (s *State) FleetKeys() []string { sort.Strings(keys) return keys } + +// withFleet applies the state-backed settings the state package itself renders. +// The full policy is interpreted by crq; MinInterval also shapes queue +// readiness in dashboards/status, including GitStateStore's background sync. +func (c StoreConfig) withFleet(s State) StoreConfig { + if value, ok := s.FleetValue("min-interval"); ok { + if interval, err := time.ParseDuration(value); err == nil && interval >= 0 { + c.MinInterval = interval + } + } + return c +} diff --git a/internal/state/state.go b/internal/state/state.go index 599681b7..c3903d7d 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -485,12 +485,16 @@ const SchemaVersion = 4 // WriterCaps is what THIS binary understands. Bump it when a state field starts // changing decisions, so a fleet running two versions can tell. -const WriterCaps = 1 +const WriterCaps = 2 // CapsRepoOverrides is the capability that makes per-repository reviewer // overrides safe to act on. const CapsRepoOverrides = 1 +// CapsFleetPolicy is the capability that makes state-backed fleet policy safe +// to activate while a process is driving the queue. +const CapsFleetPolicy = 2 + // writerTTL is how long a host counts as still active for capability purposes. const writerTTL = 30 * time.Minute diff --git a/internal/state/statusline.go b/internal/state/statusline.go index bacae777..c22f7d29 100644 --- a/internal/state/statusline.go +++ b/internal/state/statusline.go @@ -16,6 +16,7 @@ import ( // Everything here is already computed for the dashboard; this is a second // rendering of the same reduced state, not new logic. func StatusLine(st State, cfg StoreConfig) string { + cfg = cfg.withFleet(st) now := time.Now().UTC() queue := st.Queue(now, cfg.MinInterval) inFlight := inFlightRounds(st) From 1ad6b797b006601e029970819fad03b214bf7198 Mon Sep 17 00:00:00 2001 From: kristofferR <481270+kristofferR@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:30:39 +0200 Subject: [PATCH 21/37] Fence fleet policy updates and account state --- AGENTS.md | 8 +-- README.md | 2 +- internal/crq/cliquota.go | 9 ++- internal/crq/cliquota_test.go | 28 +++++++++ internal/crq/drain.go | 93 ++++++++++++++++------------ internal/crq/drain_test.go | 21 +++++++ internal/crq/fleetcmd.go | 71 +++++++++++++-------- internal/crq/fleetconfig.go | 23 +++++-- internal/crq/fleetconfig_test.go | 87 ++++++++++++++++++++++++++ internal/crq/service.go | 26 +++++++- internal/state/state.go | 10 +-- internal/state/store.go | 12 ++-- internal/state/store_version_test.go | 12 ++-- internal/state/tolerant.go | 4 +- 14 files changed, 310 insertions(+), 96 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3f8507e6..82045259 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ Dependency rule (Go-enforced, no cycles): `dialect ← engine ← crq`, `state credential-safe Git execution, stale-worktree pruning, and mirror migration. Owns persistent filesystem and process I/O for checkouts; `crq` supplies only configured roots and a current-token resolver. -- `internal/state/` — persisted schema v4: one `Round` per PR, one global +- `internal/state/` — persisted schema v5: one `Round` per PR, one global `FireSlot`, the CodeRabbit `AccountQuota`, an `Archive` ring. Round transition methods, durable tombstones for tidied trigger comments, the CAS store, and dashboard rendering. `Round.CoBots` holds per- @@ -39,9 +39,9 @@ Dependency rule (Go-enforced, no cycles): `dialect ← engine ← crq`, `state binary versions (`Normalize` folds them back on load). `Round` and `State` also **round-trip unknown JSON members** (`tolerant.go`), so a field a newer binary added survives being read and rewritten by an older one — which is what makes - ordinary additions safe without another dual-write or schema bump. Schema v4 - is the deliberate exception: older v3 clients refuse it, fencing pumping - clients that cannot enforce administrative holds. + ordinary additions safe without another dual-write or schema bump. Schema v5 + is the deliberate exception: older v4 clients refuse it, fencing pumping + clients that cannot enforce state-backed fleet policy. - `internal/engine/` — PURE decision logic, `now` passed in, no ctx/gh: `DecideFire` (the single fire owner), `Progress` (fired/reviewing round transitions), `Completion` (the one "is the round done?"), `BlockingFindings` diff --git a/README.md b/README.md index a26e83a9..d91baaf4 100644 --- a/README.md +++ b/README.md @@ -518,7 +518,7 @@ and what the machine can physically do (`CRQ_DISPATCH_CMD`, `CRQ_WORKSPACE`, | `CRQ_ISSUE` | from `init` | dashboard issue number | | `CRQ_CAL_PR` | from `init` | calibration PR number | | `CRQ_SCOPE` | owner of `CRQ_REPO` | which owners/orgs share this quota (comma-separated) | -| `CRQ_STATE_REF` | `crq-state-v3` | git ref that stores the typed CAS state. The name is fixed; the schema inside it is v4, and a binary that predates a schema **refuses** the payload rather than erasing it — so upgrade every host together | +| `CRQ_STATE_REF` | `crq-state-v3` | git ref that stores the typed CAS state. The name is fixed; the schema inside it is v5, and a binary that predates a schema **refuses** the payload rather than erasing it — so upgrade every host together | | `CRQ_REPOS` | _(all in scope)_ | `autoreview` allowlist — only these `owner/name` repos (comma-separated) | | `CRQ_EXCLUDE` | _(none)_ | denylist — crq never reviews, watches or fixes these `owner/name` repos (comma-separated) | | `CRQ_AUTOREVIEW_SKIP_AUTHORS` | `dependabot[bot]` | PR authors `autoreview` never enqueues (comma-separated; case and `[bot]` suffix don't matter) — set to empty to auto-review bot PRs too; manual `crq review` is unaffected | diff --git a/internal/crq/cliquota.go b/internal/crq/cliquota.go index 3e992490..755de9da 100644 --- a/internal/crq/cliquota.go +++ b/internal/crq/cliquota.go @@ -2,6 +2,7 @@ package crq import ( "context" + "errors" "strings" "time" @@ -90,7 +91,11 @@ func (s *Service) RecordCLIQuota(ctx context.Context, report PreflightReport, cl return result, nil } - applied, standing, err := s.applyAccountBlock(ctx, *until, "coderabbit-cli") + applied, standing, err := s.applyAccountBlock(ctx, *until, "coderabbit-cli", cfg, cliOrg) + if errors.Is(err, errFleetQuotaChanged) { + result.Reason = "fleet policy changed while recording the block; run preflight again" + return result, nil + } if err != nil { return result, err } @@ -102,6 +107,8 @@ func (s *Service) RecordCLIQuota(ctx context.Context, report PreflightReport, cl return result, nil } +var errFleetQuotaChanged = errors.New("fleet policy changed while recording cli quota") + // cliOrgMatches reports whether the CLI's current organisation is the account // crq queues for. An empty org fails closed: without knowing whose limit this is, // applying it fleet-wide is the more expensive mistake. diff --git a/internal/crq/cliquota_test.go b/internal/crq/cliquota_test.go index 1b159655..e8b69ab4 100644 --- a/internal/crq/cliquota_test.go +++ b/internal/crq/cliquota_test.go @@ -3,6 +3,7 @@ package crq import ( "context" "encoding/json" + "errors" "os" "path/filepath" "testing" @@ -118,6 +119,33 @@ func TestRecordCLIQuotaUsesFleetScopeAndFallback(t *testing.T) { } } +func TestRecordCLIQuotaRefusesAChangedFleetAccountAtCommit(t *testing.T) { + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + svc, store := cliQuotaService(t, now) + st, _, err := store.Load(context.Background()) + if err != nil { + t.Fatal(err) + } + snapshot := svc.fleetCfg(st) + if _, err := store.Update(context.Background(), func(st *State) error { + st.SetFleetValue("scope", "other-org") + return nil + }); err != nil { + t.Fatal(err) + } + + _, _, err = svc.applyAccountBlock( + context.Background(), now.Add(time.Hour), "coderabbit-cli", snapshot, "kristofferR", + ) + if !errors.Is(err, errFleetQuotaChanged) { + t.Fatalf("stale quota write = %v, want fleet-account refusal", err) + } + st, _, _ = store.Load(context.Background()) + if st.Account.BlockedUntil != nil { + t.Fatalf("stale CLI account block was recorded: %+v", st.Account) + } +} + // A window read from a PR comment is authoritative about the whole account; a // local reading may be a narrower limit. Extending is safe, shortening is not. func TestRecordCLIQuotaNeverShortensAStandingBlock(t *testing.T) { diff --git a/internal/crq/drain.go b/internal/crq/drain.go index 350366aa..4a41a129 100644 --- a/internal/crq/drain.go +++ b/internal/crq/drain.go @@ -52,11 +52,16 @@ type DrainInstall struct { // failure this whole feature is about. func (s *Service) InstallDrain(ctx context.Context, agent string, agentArgs []string, repos []string, dryRun bool) (DrainInstall, error) { effectiveDryRun := dryRun || s.cfg.DryRun - plan, err := DrainPlan(s.cfg, agent, agentArgs, repos, effectiveDryRun) + st, _, err := s.store.Load(ctx) + if err != nil { + return DrainInstall{}, err + } + effective := s.fleetCfg(st) + plan, err := DrainPlan(effective, agent, agentArgs, repos, effectiveDryRun) if err != nil || effectiveDryRun { return plan, err } - return s.applyDrain(ctx, plan) + return s.applyDrain(ctx, plan, effective) } // DrainPlan computes what an install WOULD write and run, from configuration @@ -149,7 +154,7 @@ func DrainPlan(cfg Config, agent string, agentArgs []string, repos []string, dry } // applyDrain writes the plan to disk and starts the service. -func (s *Service) applyDrain(ctx context.Context, plan DrainInstall) (DrainInstall, error) { +func (s *Service) applyDrain(ctx context.Context, plan DrainInstall, cfg Config) (DrainInstall, error) { invocation, logDir := plan.Invocation, plan.LogDir self, err := os.Executable() if err != nil { @@ -178,7 +183,7 @@ exec %s watch -- %s }{ {plan.Prompt, fixPrompt, 0o644}, {plan.Wrapper, wrapper, 0o755}, - {plan.Unit, s.drainUnit(plan), 0o644}, + {plan.Unit, drainUnitFor(cfg, plan), 0o644}, } { if err := os.MkdirAll(filepath.Dir(f.path), 0o755); err != nil { return plan, err @@ -339,48 +344,52 @@ func drainPath(plan DrainInstall) string { // wrong still reports Started, and the watcher then loads a different queue, or // none. func (s *Service) drainEnv(plan DrainInstall) map[string]string { + return drainEnvFor(s.cfg, plan) +} + +func drainEnvFor(cfg Config, plan DrainInstall) map[string]string { env := map[string]string{ "CRQ_REPOS": strings.Join(plan.Repos, ","), // The denylist travels with the allowlist. Carrying one and not the other // installs a service that watches a repository the operator excluded. - "CRQ_EXCLUDE": strings.Join(sortedRepoList(s.cfg.ExcludeRepos), ","), - "CRQ_SCOPE": strings.Join(s.cfg.Scope, ","), - "CRQ_WATCH_INTERVAL": s.cfg.WatchInterval.String(), - "CRQ_DISPATCH_MAX_ATTEMPTS": fmt.Sprint(s.cfg.DispatchMaxAttempts), - "CRQ_DISPATCH_CONCURRENCY": fmt.Sprint(s.cfg.DispatchConcurrency), - "CRQ_DISPATCH_FORKS": strconv.FormatBool(s.cfg.DispatchForks), - "CRQ_AUTOREVIEW_SKIP_MARKER": s.cfg.SkipMarker, - "CRQ_BOT": s.cfg.Bot, - "CRQ_REQUIRED_BOTS": strings.Join(s.cfg.RequiredBots, ","), - "CRQ_FEEDBACK_BOTS": strings.Join(s.cfg.FeedbackBots, ","), - "CRQ_REVIEW_CMD": s.cfg.ReviewCommand, - "CRQ_RATELIMIT_CMD": s.cfg.RateLimitCommand, - "CRQ_RL_MARKER": s.cfg.RateLimitMarker, - "CRQ_CAL_REPLY_MARKER": s.cfg.CalibrationMarker, - "CRQ_REVIEW_DONE_MARKER": s.cfg.ReviewDoneMarker, - "CRQ_COMPLETION_MARKER": s.cfg.CompletionMarker, - "CRQ_MIN_INTERVAL": s.cfg.MinInterval.String(), - "CRQ_INFLIGHT_TIMEOUT": s.cfg.InflightTimeout.String(), - "CRQ_POLL": s.cfg.PollInterval.String(), - "CRQ_FEEDBACK_WAIT_TIMEOUT": s.cfg.FeedbackWaitTimeout.String(), - "CRQ_SETTLE": s.cfg.SettleWindow.String(), + "CRQ_EXCLUDE": strings.Join(sortedRepoList(cfg.ExcludeRepos), ","), + "CRQ_SCOPE": strings.Join(cfg.Scope, ","), + "CRQ_WATCH_INTERVAL": cfg.WatchInterval.String(), + "CRQ_DISPATCH_MAX_ATTEMPTS": fmt.Sprint(cfg.DispatchMaxAttempts), + "CRQ_DISPATCH_CONCURRENCY": fmt.Sprint(cfg.DispatchConcurrency), + "CRQ_DISPATCH_FORKS": strconv.FormatBool(cfg.DispatchForks), + "CRQ_AUTOREVIEW_SKIP_MARKER": cfg.SkipMarker, + "CRQ_BOT": cfg.Bot, + "CRQ_REQUIRED_BOTS": strings.Join(cfg.RequiredBots, ","), + "CRQ_FEEDBACK_BOTS": strings.Join(cfg.FeedbackBots, ","), + "CRQ_REVIEW_CMD": cfg.ReviewCommand, + "CRQ_RATELIMIT_CMD": cfg.RateLimitCommand, + "CRQ_RL_MARKER": cfg.RateLimitMarker, + "CRQ_CAL_REPLY_MARKER": cfg.CalibrationMarker, + "CRQ_REVIEW_DONE_MARKER": cfg.ReviewDoneMarker, + "CRQ_COMPLETION_MARKER": cfg.CompletionMarker, + "CRQ_MIN_INTERVAL": cfg.MinInterval.String(), + "CRQ_INFLIGHT_TIMEOUT": cfg.InflightTimeout.String(), + "CRQ_POLL": cfg.PollInterval.String(), + "CRQ_FEEDBACK_WAIT_TIMEOUT": cfg.FeedbackWaitTimeout.String(), + "CRQ_SETTLE": cfg.SettleWindow.String(), // The quota timings belong here for the same reason as every other // setting: the service does not inherit the shell that installed it. A // deliberately longer fallback set only in that shell was folded into // this config, written into no unit, and then silently replaced by the // default — so the watcher retried a review command earlier than // configured for every account block it could not parse a window from. - "CRQ_CALIBRATE_TTL": s.cfg.CalibrationTTL.String(), - "CRQ_RL_FALLBACK": s.cfg.RateLimitFallback.String(), + "CRQ_CALIBRATE_TTL": cfg.CalibrationTTL.String(), + "CRQ_RL_FALLBACK": cfg.RateLimitFallback.String(), "PATH": drainPath(plan), } - if s.cfg.RateLimitCoDegrade { + if cfg.RateLimitCoDegrade { env["CRQ_RL_CO_DEGRADE"] = "1" } else { env["CRQ_RL_CO_DEGRADE"] = "0" } - coNames := make([]string, 0, len(s.cfg.CoBots)) - for _, co := range s.cfg.CoBots { + coNames := make([]string, 0, len(cfg.CoBots)) + for _, co := range cfg.CoBots { coNames = append(coNames, co.Name) prefix := "CRQ_COBOT_" + strings.ToUpper(co.Name) env[prefix+"_CMD"] = co.Command @@ -396,7 +405,7 @@ func (s *Service) drainEnv(plan DrainInstall) map[string]string { // or dispatch silently falls back to another filesystem. workspace := plan.Workspace if workspace == "" { - workspace = s.cfg.WorkspaceRoot + workspace = cfg.WorkspaceRoot } if workspace != "" { env["CRQ_WORKSPACE"] = workspace @@ -408,24 +417,28 @@ func (s *Service) drainEnv(plan DrainInstall) map[string]string { // the dashboard, and the PR the account quota is probed on. Without the first // two, the watcher cannot even load state — or loads a queue nobody else is // using, which looks exactly like an idle fleet. - if s.cfg.GateRepo != "" { - env["CRQ_REPO"] = s.cfg.GateRepo + if cfg.GateRepo != "" { + env["CRQ_REPO"] = cfg.GateRepo } - if s.cfg.StateRef != "" { - env["CRQ_STATE_REF"] = s.cfg.StateRef + if cfg.StateRef != "" { + env["CRQ_STATE_REF"] = cfg.StateRef } - if s.cfg.DashboardIssue > 0 { - env["CRQ_ISSUE"] = fmt.Sprint(s.cfg.DashboardIssue) + if cfg.DashboardIssue > 0 { + env["CRQ_ISSUE"] = fmt.Sprint(cfg.DashboardIssue) } - if s.cfg.CalibrationPR > 0 { - env["CRQ_CAL_PR"] = fmt.Sprint(s.cfg.CalibrationPR) + if cfg.CalibrationPR > 0 { + env["CRQ_CAL_PR"] = fmt.Sprint(cfg.CalibrationPR) } return env } // drainUnit renders the platform's service definition. func (s *Service) drainUnit(plan DrainInstall) string { - env := s.drainEnv(plan) + return drainUnitFor(s.cfg, plan) +} + +func drainUnitFor(cfg Config, plan DrainInstall) string { + env := drainEnvFor(cfg, plan) // Sorted: the unit is a file on disk that a re-install rewrites, and map order // would make every rewrite a different file for the same configuration. keys := make([]string, 0, len(env)) diff --git a/internal/crq/drain_test.go b/internal/crq/drain_test.go index 8e6af956..3c0da085 100644 --- a/internal/crq/drain_test.go +++ b/internal/crq/drain_test.go @@ -69,6 +69,27 @@ func TestInstallDrainHonoursConfiguredDryRun(t *testing.T) { } } +func TestInstallDrainUsesFleetRepositories(t *testing.T) { + cfg := firingConfig() + cfg.AllowRepos = nil + store := NewMemoryStore(cfg) + if _, err := store.Update(context.Background(), func(st *State) error { + st.SetFleetValue("repos", "owner/fleet-repo") + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, newFakeGitHub(), store, nil) + + plan, err := svc.InstallDrain(context.Background(), fakeAgent(t, "claude"), nil, nil, true) + if err != nil { + t.Fatal(err) + } + if len(plan.Repos) != 1 || plan.Repos[0] != "owner/fleet-repo" { + t.Fatalf("drain repos = %v, want the fleet repository", plan.Repos) + } +} + // A missing agent must fail loudly at install time. Discovering it at the first // dispatch means a drain that looks installed and fixes nothing. func TestInstallDrainRefusesWithoutAnAgent(t *testing.T) { diff --git a/internal/crq/fleetcmd.go b/internal/crq/fleetcmd.go index 54985872..edd7d82f 100644 --- a/internal/crq/fleetcmd.go +++ b/internal/crq/fleetcmd.go @@ -2,7 +2,9 @@ package crq import ( "context" + "errors" "fmt" + "strings" ) // FleetSetting is one policy as `crq config` reports it: what the fleet @@ -62,14 +64,11 @@ func (s *Service) SetFleetConfig(ctx context.Context, key, value string) error { if err := ValidateFleetSetting(key, value); err != nil { return err } - if s.cfg.DryRun { - return nil - } open, err := s.prepareFleetReviewerChange(ctx, key) if err != nil { return err } - _, err = s.store.Update(ctx, func(st *State) error { + _, err = s.updateFleet(ctx, func(st *State) error { if err := s.requireFleetCapableDrivers(st); err != nil { return err } @@ -78,7 +77,7 @@ func (s *Service) SetFleetConfig(ctx context.Context, key, value string) error { return ErrNoChange } st.SetFleetValue(key, value) - s.reopenForFleetReviewerChange(st, before, s.fleetCfg(*st), open) + s.reconcileFleetChange(st, before, s.fleetCfg(*st), open) return nil }) return err @@ -90,20 +89,12 @@ func (s *Service) UnsetFleetConfig(ctx context.Context, key string) (bool, error if _, ok := fleetSettings()[key]; !ok { return false, fmt.Errorf("unknown setting %q", key) } - if s.cfg.DryRun { - st, _, err := s.store.Load(ctx) - if err != nil { - return false, err - } - _, dropped := st.FleetValue(key) - return dropped, nil - } open, err := s.prepareFleetReviewerChange(ctx, key) if err != nil { return false, err } dropped := false - _, err = s.store.Update(ctx, func(st *State) error { + _, err = s.updateFleet(ctx, func(st *State) error { if err := s.requireFleetCapableDrivers(st); err != nil { return err } @@ -112,7 +103,7 @@ func (s *Service) UnsetFleetConfig(ctx context.Context, key string) (bool, error if !dropped { return ErrNoChange } - s.reopenForFleetReviewerChange(st, before, s.fleetCfg(*st), open) + s.reconcileFleetChange(st, before, s.fleetCfg(*st), open) return nil }) return dropped, err @@ -133,14 +124,6 @@ func (s *Service) SeedFleetConfig(ctx context.Context) ([]string, error) { if err != nil { return nil, err } - if s.cfg.DryRun { - for _, key := range FleetKeys() { - if _, ok := initial.FleetValue(key); !ok { - seeded = append(seeded, key) - } - } - return seeded, nil - } var open map[string]map[int]bool if _, ok := initial.FleetValue("required-bots"); !ok { open, err = s.openFleetPRs(ctx, initial) @@ -148,7 +131,7 @@ func (s *Service) SeedFleetConfig(ctx context.Context) ([]string, error) { return nil, err } } - _, err = s.store.Update(ctx, func(st *State) error { + _, err = s.updateFleet(ctx, func(st *State) error { if err := s.requireFleetCapableDrivers(st); err != nil { return err } @@ -164,12 +147,30 @@ func (s *Service) SeedFleetConfig(ctx context.Context) ([]string, error) { if len(seeded) == 0 { return ErrNoChange } - s.reopenForFleetReviewerChange(st, before, s.fleetCfg(*st), open) + s.reconcileFleetChange(st, before, s.fleetCfg(*st), open) return nil }) return seeded, err } +// updateFleet runs the exact same mutation for a preview and a real update. +// Dry-run differs only at the persistence boundary, so validation, capability +// fences and reconciliation cannot drift from the command it previews. +func (s *Service) updateFleet(ctx context.Context, mutate func(*State) error) (State, error) { + if !s.cfg.DryRun { + return s.store.Update(ctx, mutate) + } + st, _, err := s.store.Load(ctx) + if err != nil { + return State{}, err + } + err = mutate(&st) + if errors.Is(err, ErrNoChange) { + err = nil + } + return st, err +} + // FleetDivergence lists the settings whose value on this host differs from what // the fleet records, for `crq doctor`. // @@ -230,6 +231,26 @@ func (s *Service) openFleetPRs(ctx context.Context, st State) (map[string]map[in return open, nil } +func (s *Service) reconcileFleetChange(st *State, before, after Config, open map[string]map[int]bool) { + if strings.Join(before.Scope, ",") != strings.Join(after.Scope, ",") { + st.Account = AccountQuota{ + Scope: strings.Join(after.Scope, ","), + Source: "fleet scope changed", + } + } + for _, round := range st.Rounds { + if round.Phase != PhaseQueued && round.Phase != PhaseAwaitingRetry { + continue + } + if !after.ExcludeRepos[NormalizeRepo(round.Repo)] { + continue + } + st.EndRound(round.Repo, round.PR, "repository excluded by fleet policy") + releaseSlot(st, QueueKey(round.Repo, round.PR)) + } + s.reopenForFleetReviewerChange(st, before, after, open) +} + func (s *Service) reopenForFleetReviewerChange(st *State, before, after Config, open map[string]map[int]bool) { if sameLogins(before.RequiredBots, after.RequiredBots) { return diff --git a/internal/crq/fleetconfig.go b/internal/crq/fleetconfig.go index 69968b59..7cd2a508 100644 --- a/internal/crq/fleetconfig.go +++ b/internal/crq/fleetconfig.go @@ -52,8 +52,9 @@ func fleetSettings() map[string]fleetSetting { Doc: "the repositories crq reviews and watches", Env: "CRQ_REPOS", Apply: func(cfg *Config, v string) error { - cfg.AllowRepos = repoSet(v) - return nil + repos, err := fleetRepoSet(v) + cfg.AllowRepos = repos + return err }, Show: func(cfg Config) string { return strings.Join(sortedRepoKeys(cfg.AllowRepos), ",") }, }, @@ -61,8 +62,9 @@ func fleetSettings() map[string]fleetSetting { Doc: "repositories crq never reviews, watches or fixes", Env: "CRQ_EXCLUDE", Apply: func(cfg *Config, v string) error { - cfg.ExcludeRepos = repoSet(v) - return nil + repos, err := fleetRepoSet(v) + cfg.ExcludeRepos = repos + return err }, Show: func(cfg Config) string { return strings.Join(sortedRepoKeys(cfg.ExcludeRepos), ",") }, }, @@ -71,6 +73,9 @@ func fleetSettings() map[string]fleetSetting { Env: "CRQ_REQUIRED_BOTS", Apply: func(cfg *Config, v string) error { cfg.RequiredBots = splitList(v) + if len(cfg.RequiredBots) == 0 { + return fmt.Errorf("at least one required bot is needed") + } return nil }, Show: func(cfg Config) string { return strings.Join(cfg.RequiredBots, ",") }, @@ -165,6 +170,16 @@ func ValidateFleetSetting(key, value string) error { return setting.Apply(&probe, value) } +func fleetRepoSet(value string) (map[string]bool, error) { + repos := repoSet(value) + for repo := range repos { + if !validRepoSlug(repo) { + return nil, fmt.Errorf("repo must be owner/name, got %q", repo) + } + } + return repos, nil +} + // applyFleet overlays the fleet's recorded policy onto a host's configuration. // // Recorded wins. The environment is what a host uses until the fleet has an diff --git a/internal/crq/fleetconfig_test.go b/internal/crq/fleetconfig_test.go index 7f4103d8..f645ff3e 100644 --- a/internal/crq/fleetconfig_test.go +++ b/internal/crq/fleetconfig_test.go @@ -76,6 +76,14 @@ func TestFleetRefusesWhatItCannotRead(t *testing.T) { if err := svc.SetFleetConfig(ctx, "nonsense", "1"); err == nil { t.Error("an unknown setting was accepted") } + for _, key := range []string{"repos", "exclude"} { + if err := svc.SetFleetConfig(ctx, key, "owner-repo"); err == nil { + t.Errorf("%s accepted a malformed repository slug", key) + } + } + if err := svc.SetFleetConfig(ctx, "required-bots", ""); err == nil { + t.Error("an empty required reviewer set was accepted") + } // Planted directly, as a newer binary would leave it. if _, err := store.Update(ctx, func(st *State) error { @@ -167,6 +175,85 @@ func TestFleetMutationsAreInertInDryRun(t *testing.T) { } } +func TestFleetDryRunStillChecksDriverCompatibility(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + cfg := firingConfig() + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.Leader = &LeaderLease{Owner: "old-daemon", ExpiresAt: now.Add(time.Minute)} + st.NoteWriter("old-daemon", CapsRepoOverrides, now) + return nil + }); err != nil { + t.Fatal(err) + } + cfg.DryRun = true + svc := NewService(cfg, newFakeGitHub(), store, nil) + svc.now = func() time.Time { return now } + + err := svc.SetFleetConfig(ctx, "min-interval", "5m") + if err == nil || !strings.Contains(err.Error(), "lack fleet-policy support") { + t.Fatalf("dry-run set with a lagging driver = %v, want a capability refusal", err) + } +} + +func TestFleetScopeChangeInvalidatesAccountQuota(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + blocked := time.Date(2026, 7, 27, 13, 0, 0, 0, time.UTC) + remaining := 3 + if _, err := store.Update(ctx, func(st *State) error { + st.Account.BlockedUntil = &blocked + st.Account.Remaining = &remaining + st.Account.CheckedAt = &blocked + st.Account.CalibAskedAt = &blocked + st.Account.RLCommentID = 42 + st.Account.RLCommentUpdated = &blocked + return nil + }); err != nil { + t.Fatal(err) + } + + if err := NewService(cfg, newFakeGitHub(), store, nil).SetFleetConfig(ctx, "scope", "other-org"); err != nil { + t.Fatal(err) + } + st, _, _ := store.Load(ctx) + if st.Account.Scope != "other-org" { + t.Fatalf("account scope = %q, want other-org", st.Account.Scope) + } + if st.Account.BlockedUntil != nil || st.Account.Remaining != nil || st.Account.CheckedAt != nil || + st.Account.CalibAskedAt != nil || st.Account.RLCommentID != 0 || st.Account.RLCommentUpdated != nil { + t.Fatalf("old account quota survived the scope change: %+v", st.Account) + } +} + +func TestFleetExcludeRetiresQueuedRounds(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.PutRound(Round{ + Repo: "owner/repo", PR: 7, Head: "abcdef123", + Phase: PhaseQueued, EnqueuedAt: time.Now().UTC(), + }) + return nil + }); err != nil { + t.Fatal(err) + } + + if err := NewService(cfg, newFakeGitHub(), store, nil).SetFleetConfig(ctx, "exclude", "owner/repo"); err != nil { + t.Fatal(err) + } + st, _, _ := store.Load(ctx) + if round := st.Round("owner/repo", 7); round != nil { + t.Fatalf("excluded round remains fire-eligible: %+v", round) + } + if len(st.Archive) != 1 || st.Archive[0].Phase != PhaseAbandoned { + t.Fatalf("excluded round was not archived as abandoned: %+v", st.Archive) + } +} + func TestFleetDivergenceIncludesConfigFileValues(t *testing.T) { ctx := context.Background() cfg := firingConfig() diff --git a/internal/crq/service.go b/internal/crq/service.go index b2822266..36003441 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -238,6 +238,11 @@ func (s *Service) Enqueue(ctx context.Context, repo string, pr int) (EnqueueResu result.Head = head state, err := s.store.Update(ctx, func(st *State) error { now := s.clock() + if s.fleetCfg(*st).ExcludeRepos[repo] { + result.Held = true + result.Reason = "repository excluded by fleet policy" + return ErrNoChange + } if h, held := st.HeldPR(repo, pr); held { // Enqueueing a held PR would queue a round nothing may fire, which // reads on the dashboard as work waiting its turn. @@ -303,9 +308,13 @@ func (s *Service) enqueueBatch(ctx context.Context, items []queueCandidate) erro } state, err := s.store.Update(ctx, func(st *State) error { now := s.clock() + cfg := s.fleetCfg(*st) added := 0 for _, it := range items { repo := NormalizeRepo(it.Repo) + if cfg.ExcludeRepos[repo] { + continue + } if _, held := st.HeldPR(repo, it.PR); held { continue } @@ -724,13 +733,23 @@ func (s *Service) recordDismissal(ctx context.Context, repo string, pr int, head // the rest of the CAS writes rather than beside each evidence source. // // It returns whether anything was written, and the block that stands either way. -func (s *Service) applyAccountBlock(ctx context.Context, until time.Time, source string) (bool, *time.Time, error) { +func (s *Service) applyAccountBlock( + ctx context.Context, + until time.Time, + source string, + expected Config, + cliOrg string, +) (bool, *time.Time, error) { now := s.clock() // Update swallows ErrNoChange, so whether anything was written has to be // recorded by the mutation itself. It is assigned on every attempt because a // CAS conflict runs the closure again. applied := false state, err := s.store.Update(ctx, func(st *State) error { + current := s.fleetCfg(*st) + if current.FleetRevision != expected.FleetRevision || !cliOrgMatches(current, cliOrg) { + return errFleetQuotaChanged + } if !engine.AcceptAccountBlock(st.Account.BlockedUntil, until) { applied = false return ErrNoChange @@ -740,7 +759,7 @@ func (s *Service) applyAccountBlock(ctx context.Context, until time.Time, source st.Account.BlockedUntil = &at st.Account.CheckedAt = &now st.Account.Source = source - st.Account.Scope = strings.Join(s.cfg.Scope, ",") + st.Account.Scope = strings.Join(current.Scope, ",") // A reading from outside the PR says nothing about how many reviews are // left, and a stale count beside a fresh block would read as authoritative. st.Account.Remaining = nil @@ -1012,6 +1031,9 @@ func firedOrEnqueuedAt(r Round) time.Time { // is meant to close: SetReviewers commits in between, and the mutation goes on // to apply the decision the new configuration would not have made. func (s *Service) applyFire(ctx context.Context, cfg Config, round Round, obs engine.Observation, d engine.FireDecision, now time.Time) (PumpResult, error) { + if cfg.ExcludeRepos[NormalizeRepo(round.Repo)] { + return s.abandonRound(ctx, round, "repository excluded by fleet policy", "skipped") + } switch d.Verdict { case engine.FireDrop: return s.abandonRound(ctx, round, "pr closed", "skipped") diff --git a/internal/state/state.go b/internal/state/state.go index c3903d7d..faff2bb0 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -1,4 +1,4 @@ -// Package state defines crq's persisted schema v4: one Round per tracked PR, +// Package state defines crq's persisted schema v5: one Round per tracked PR, // a single global fire slot, and the CodeRabbit account quota. A Round is // never deleted, only transitioned (or archived when superseded by a new // head) — the invariant that makes "forgot we already requested a review at @@ -367,10 +367,10 @@ func (l LeaderCapabilityLease) HasCapability(want string) bool { return false } -// State is schema v4. It persists as state.json in the existing git state ref; -// v3 is migrated in place so its live rounds survive the compatibility fence. +// State is schema v5. It persists as state.json in the existing git state ref; +// v4 is migrated in place so its live rounds survive the compatibility fence. type State struct { - Version int `json:"v"` // 4 + Version int `json:"v"` // 5 Rev int64 `json:"rev"` NextSeq int64 `json:"next_seq"` @@ -481,7 +481,7 @@ func (s State) LeaderHasCapability(want string) bool { s.LeaderCapabilities.HasCapability(want) } -const SchemaVersion = 4 +const SchemaVersion = 5 // WriterCaps is what THIS binary understands. Bump it when a state field starts // changing decisions, so a fleet running two versions can tell. diff --git a/internal/state/store.go b/internal/state/store.go index 2237303b..b44387c8 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -84,10 +84,10 @@ type StateStore interface { SyncDashboard(context.Context, State) error } -// GitStateStore persists v4 state as state.json in a git ref, with the same -// compare-and-swap mechanism as v3 (12 retries on UpdateRef 409/422). +// GitStateStore persists v5 state as state.json in a git ref, with the same +// compare-and-swap mechanism as v4 (12 retries on UpdateRef 409/422). // -// V3 is migrated in place; still older payloads are discarded because crq is +// V4 is migrated in place; still older payloads are discarded because crq is // pre-release and they describe a world this binary cannot act on. A NEWER one // is refused. The fleet runs mixed binary versions during a rolling deploy, so // reinitializing there would mean the first old binary to wake up erases every @@ -149,9 +149,9 @@ func (s *GitStateStore) Load(ctx context.Context) (State, Revision, error) { if err != nil { return State{}, Revision{}, err } - // Peek at the schema version before a full decode. V3 is the one supported - // migration: v4 intentionally fences v3 pumping clients from administrative - // holds, while preserving every live v3 round during the rollout. + // Peek at the schema version before a full decode. V4 is the one supported + // migration: v5 intentionally fences v4 pumping clients from fleet policy, + // while preserving every live v4 round during the rollout. var probe struct { Version int `json:"v"` } diff --git a/internal/state/store_version_test.go b/internal/state/store_version_test.go index edabb503..a6274cbe 100644 --- a/internal/state/store_version_test.go +++ b/internal/state/store_version_test.go @@ -76,9 +76,9 @@ func TestLoadRefusesUndecodableCurrentState(t *testing.T) { } } -func TestLoadMigratesV3WithoutLosingLiveRounds(t *testing.T) { +func TestLoadMigratesV4WithoutLosingLiveRounds(t *testing.T) { payload := `{ - "v":3, + "v":4, "rev":7, "next_seq":2, "rounds":{ @@ -102,21 +102,21 @@ func TestLoadMigratesV3WithoutLosingLiveRounds(t *testing.T) { t.Errorf("version = %d, want migrated v%d", st.Version, SchemaVersion) } if round := st.Round("owner/repo", 7); round == nil || round.Head != "abcdef123" { - t.Fatalf("live v3 round was lost during migration: %+v", round) + t.Fatalf("live v4 round was lost during migration: %+v", round) } encoded, err := json.Marshal(st) if err != nil { t.Fatal(err) } if !strings.Contains(string(encoded), "future_top_level") { - t.Fatalf("unknown v3 state was lost during migration: %s", encoded) + t.Fatalf("unknown v4 state was lost during migration: %s", encoded) } } // An OLDER payload is genuinely obsolete: crq is pre-release, there is no -// migration, and a v2 state describes a world this binary cannot act on. +// migration, and a v3 state describes a world this binary cannot act on. func TestLoadReinitializesOlderState(t *testing.T) { - st, _, err := versionStore(t, `{"v":2,"queue":[{"repo":"owner/repo","pr":7}]}`).Load(context.Background()) + st, _, err := versionStore(t, `{"v":3,"queue":[{"repo":"owner/repo","pr":7}]}`).Load(context.Background()) if err != nil { t.Fatalf("an older schema must reinitialize, got %v", err) } diff --git a/internal/state/tolerant.go b/internal/state/tolerant.go index 8e4309d5..eb04aa04 100644 --- a/internal/state/tolerant.go +++ b/internal/state/tolerant.go @@ -18,8 +18,8 @@ import ( // Unknown members are therefore carried by default. A load keeps whatever it // did not recognise, a save puts it back, and a field this binary has never // heard of survives a foreign write untouched. A schema bump is reserved for a -// compatibility fence: newer state is refused by older binaries, as v4 requires -// so v3 pumping clients cannot ignore administrative holds. +// compatibility fence: newer state is refused by older binaries, as v5 requires +// so v4 pumping clients cannot ignore state-backed fleet policy. // unknownFields holds JSON members this binary has no field for, verbatim. type unknownFields map[string]json.RawMessage From 0907c5e4b0286f595c91736808a680f6ad068645 Mon Sep 17 00:00:00 2001 From: kristofferR <481270+kristofferR@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:05:46 +0200 Subject: [PATCH 22/37] Apply fleet policy consistently across background work --- internal/crq/auto.go | 2 +- internal/crq/drainswitch.go | 5 +- internal/crq/drainswitch_test.go | 22 +++++ internal/crq/feedback_test.go | 39 ++++++++ internal/crq/fleetcmd.go | 34 ++++++- internal/crq/fleetconfig_test.go | 134 ++++++++++++++++++++++++++ internal/crq/init.go | 2 +- internal/crq/replay_test.go | 3 +- internal/crq/repoconfig_test.go | 2 +- internal/crq/service.go | 24 ++++- internal/crq/service_test.go | 124 ++++++++++++++++++++++-- internal/crq/watch_test.go | 2 +- internal/state/dashboard_sync_test.go | 29 +++++- internal/state/store.go | 10 +- 14 files changed, 400 insertions(+), 32 deletions(-) diff --git a/internal/crq/auto.go b/internal/crq/auto.go index a3fcb642..ab5e8aef 100644 --- a/internal/crq/auto.go +++ b/internal/crq/auto.go @@ -309,7 +309,7 @@ func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, t } } // One batched write for the whole pass instead of N (#2). - return s.enqueueBatch(ctx, candidates) + return s.enqueueBatch(ctx, candidates, cfg.FleetRevision) } // needsReview reports whether an open PR should be enqueued for review, and its diff --git a/internal/crq/drainswitch.go b/internal/crq/drainswitch.go index 68f3b448..a45aa772 100644 --- a/internal/crq/drainswitch.go +++ b/internal/crq/drainswitch.go @@ -81,8 +81,9 @@ func (s *Service) DrainSettings(ctx context.Context) ([]DrainSetting, error) { } out = append(out, setting) } - watched := make([]string, 0, len(s.cfg.AllowRepos)) - for repo := range s.cfg.AllowRepos { + effective := s.fleetCfg(st) + watched := make([]string, 0, len(effective.AllowRepos)) + for repo := range effective.AllowRepos { watched = append(watched, repo) } sort.Strings(watched) diff --git a/internal/crq/drainswitch_test.go b/internal/crq/drainswitch_test.go index 1e9ec22e..aa52ba26 100644 --- a/internal/crq/drainswitch_test.go +++ b/internal/crq/drainswitch_test.go @@ -55,6 +55,28 @@ func TestDrainIsOnUnlessARepositorySaysOtherwise(t *testing.T) { } } +func TestDrainSettingsListsTheFleetRepositoryAllowlist(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"owner/host-only": true} + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.SetFleetValue("repos", "owner/fleet-only") + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, newFakeGitHub(), store, nil) + + settings, err := svc.DrainSettings(ctx) + if err != nil { + t.Fatal(err) + } + if len(settings) != 1 || settings[0].Repo != "owner/fleet-only" { + t.Fatalf("settings = %+v, want the effective fleet repository", settings) + } +} + func TestDrainSwitchRejectsMalformedRepositoryNames(t *testing.T) { ctx := context.Background() cfg := firingConfig() diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index 0b4548f3..385d0a4c 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -100,6 +100,45 @@ func TestFeedbackReturnsObservedAccountBlockPersistenceFailure(t *testing.T) { } } +func TestFeedbackUsesTheFleetFallbackForAWindowlessAccountBlock(t *testing.T) { + cfg := firingConfig() + cfg.RateLimitFallback = 5 * time.Minute + now := time.Now().UTC() + gh := newFakeGitHub() + var pull ghapi.Pull + pull.State = "open" + pull.Head.SHA = "abcdef1234567890" + gh.pulls[fakeKey("o/repo", 3)] = pull + notice := ghapi.IssueComment{ + ID: 17, Body: "You are rate limited by coderabbit.ai. Please wait before requesting another review.", + CreatedAt: now, UpdatedAt: now, + } + notice.User.Login = cfg.Bot + gh.comments[fakeKey("o/repo", 3)] = []ghapi.IssueComment{notice} + + store := NewMemoryStore(cfg) + if _, err := store.Update(context.Background(), func(st *State) error { + st.SetFleetValue("rate-limit-fallback", "45m") + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, gh, store, nil) + svc.now = func() time.Time { return now } + + if _, err := svc.Feedback(context.Background(), "o/repo", 3); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(context.Background()) + if err != nil { + t.Fatal(err) + } + want := now.Add(45 * time.Minute) + if st.Account.BlockedUntil == nil || !st.Account.BlockedUntil.Equal(want) { + t.Fatalf("blocked until = %v, want fleet fallback %s", st.Account.BlockedUntil, want) + } +} + func TestFeedbackCountsCompletionReplyForFiredHead(t *testing.T) { // A re-review with nothing new to say produces no review object: CodeRabbit // only replies "Review finished" to the command. That reply must satisfy diff --git a/internal/crq/fleetcmd.go b/internal/crq/fleetcmd.go index edd7d82f..219a081d 100644 --- a/internal/crq/fleetcmd.go +++ b/internal/crq/fleetcmd.go @@ -4,7 +4,10 @@ import ( "context" "errors" "fmt" + "net/http" "strings" + + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) // FleetSetting is one policy as `crq config` reports it: what the fleet @@ -19,7 +22,7 @@ type FleetSetting struct { // HostValue is what this machine's own environment says, reported only when // it differs from what is in force. It is the divergence an operator needs // to see: a setting they changed on this box that the fleet is overriding. - HostValue string `json:"host_value,omitempty"` + HostValue *string `json:"host_value,omitempty"` // Error is why a recorded value was not applied, when this binary could not // read it. Error string `json:"error,omitempty"` @@ -50,7 +53,7 @@ func (s *Service) FleetConfig(ctx context.Context) ([]FleetSetting, error) { } } if host := setting.Show(s.cfg); host != item.Value { - item.HostValue = host + item.HostValue = &host } out = append(out, item) } @@ -189,9 +192,9 @@ func (s *Service) FleetDivergence(ctx context.Context) ([]string, error) { case item.Error != "": out = append(out, fmt.Sprintf("%s: the fleet's value is one this crq cannot read (%s); using this host's %q", item.Key, item.Error, item.Value)) - case item.HostValue != "" && s.cfg.ExplicitFleetEnv[item.Env]: + case item.HostValue != nil && s.cfg.ExplicitFleetEnv[item.Env]: out = append(out, fmt.Sprintf("%s is %q for the fleet, but %s is set to %q on this host; remove it or run crq config set %s", - item.Key, item.Value, item.Env, item.HostValue, item.Key)) + item.Key, item.Value, item.Env, *item.HostValue, item.Key)) } } return out, nil @@ -224,13 +227,34 @@ func (s *Service) openFleetPRs(ctx context.Context, st State) (map[string]map[in for repo := range repos { pulls, err := s.openPRs(ctx, repo) if err != nil { - return nil, err + if !inaccessibleRepoLookup(err) { + return nil, err + } + // Completed rounds are permanent dedup markers, so repositories that + // were deleted or became inaccessible remain in state indefinitely. + // Treat their PRs as closed for this reconciliation: the rounds are + // marked and will reopen if a later enqueue proves the PR is live. + if s.log != nil { + s.log.Printf("warning: reviewer change could not inspect historical repository %s: %v", repo, err) + } + continue } open[repo] = pulls } return open, nil } +func inaccessibleRepoLookup(err error) bool { + if ghapi.IsThrottled(err) { + return false + } + if errors.Is(err, ghapi.ErrNotFound) { + return true + } + var apiErr *ghapi.APIError + return errors.As(err, &apiErr) && apiErr.Status == http.StatusForbidden +} + func (s *Service) reconcileFleetChange(st *State, before, after Config, open map[string]map[int]bool) { if strings.Join(before.Scope, ",") != strings.Join(after.Scope, ",") { st.Account = AccountQuota{ diff --git a/internal/crq/fleetconfig_test.go b/internal/crq/fleetconfig_test.go index f645ff3e..8930fc2b 100644 --- a/internal/crq/fleetconfig_test.go +++ b/internal/crq/fleetconfig_test.go @@ -2,6 +2,7 @@ package crq import ( "context" + "net/http" "strings" "testing" "time" @@ -275,6 +276,27 @@ func TestFleetDivergenceIncludesConfigFileValues(t *testing.T) { } } +func TestFleetDivergenceIncludesAnExplicitlyEmptyHostValue(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.ExcludeRepos = map[string]bool{} + cfg.ExplicitFleetEnv = map[string]bool{"CRQ_EXCLUDE": true} + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.SetFleetValue("exclude", "owner/repo") + return nil + }); err != nil { + t.Fatal(err) + } + got, err := NewService(cfg, newFakeGitHub(), store, nil).FleetDivergence(ctx) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || !strings.Contains(got[0], `CRQ_EXCLUDE is set to ""`) { + t.Fatalf("divergence = %v, want the explicitly empty host value", got) + } +} + func TestFleetRevisionInvalidatesAStaleDecision(t *testing.T) { cfg := firingConfig() st := DefaultState(cfg) @@ -286,6 +308,42 @@ func TestFleetRevisionInvalidatesAStaleDecision(t *testing.T) { } } +type dashboardConfigStore struct { + StateStore + render StoreConfig +} + +func (s *dashboardConfigStore) SyncDashboard(_ context.Context, _ State, cfg StoreConfig) error { + s.render = cfg + return nil +} + +func TestDashboardSyncReceivesTheEffectiveFleetReviewers(t *testing.T) { + ctx := context.Background() + cfg := isolatedConfig(t, map[string]string{ + "CRQ_COBOTS": "codex", + "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", + }) + cfg.DashboardIssue = 1 + inner := NewMemoryStore(cfg) + store := &dashboardConfigStore{StateStore: inner} + if _, err := inner.Update(ctx, func(st *State) error { + st.SetFleetValue("required-bots", "coderabbitai[bot],"+dialect.CodexBotLogin) + return nil + }); err != nil { + t.Fatal(err) + } + st, _, err := inner.Load(ctx) + if err != nil { + t.Fatal(err) + } + svc := NewService(cfg, newFakeGitHub(), store, &recordingLogger{}) + svc.sync(ctx, st) + if !strings.Contains(store.render.CoReviewers, "codex (required") { + t.Fatalf("dashboard co-reviewers = %q, want fleet-required codex", store.render.CoReviewers) + } +} + func TestFleetSettleWindowShapesNext(t *testing.T) { cfg := firingConfig() cfg.SettleWindow = 0 @@ -344,3 +402,79 @@ func TestFleetReviewerChangeReopensCompletedRounds(t *testing.T) { t.Fatalf("round = %+v, want completed round reopened", round) } } + +func TestFleetReviewerChangeDoesNotFailOnAnInaccessibleHistoricalRepository(t *testing.T) { + ctx := context.Background() + cfg := isolatedConfig(t, map[string]string{ + "CRQ_COBOTS": "codex", + "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", + }) + gh := newFakeGitHub() + gh.searchPRs = []ghapi.SearchPR{{Repo: "owner/live", Number: 7}} + gh.listPullErrs["owner/gone"] = &ghapi.APIError{Status: http.StatusForbidden, Body: "resource not accessible"} + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.PutRound(Round{Repo: "owner/gone", PR: 6, Head: "aaaaaaa11", Phase: PhaseCompleted}) + st.PutRound(Round{Repo: "owner/live", PR: 7, Head: "bbbbbbb22", Phase: PhaseCompleted}) + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, gh, store, nil) + if err := svc.SetFleetConfig(ctx, "required-bots", "coderabbitai[bot],"+dialect.CodexBotLogin); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round("owner/live", 7); round == nil || round.Phase != PhaseQueued { + t.Fatalf("live round = %+v, want it reopened", round) + } + if round := st.Round("owner/gone", 6); round == nil || !round.ReviewersChanged || round.Phase != PhaseCompleted { + t.Fatalf("inaccessible historical round = %+v, want a marked completed round", round) + } +} + +func TestFleetReviewerChangePropagatesGitHubThrottling(t *testing.T) { + ctx := context.Background() + cfg := isolatedConfig(t, map[string]string{ + "CRQ_COBOTS": "codex", + "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", + }) + gh := newFakeGitHub() + gh.listPullErrs["owner/repo"] = &ghapi.RateLimitError{Kind: "secondary"} + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.PutRound(Round{Repo: "owner/repo", PR: 7, Head: "bbbbbbb22", Phase: PhaseCompleted}) + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, gh, store, nil) + err := svc.SetFleetConfig(ctx, "required-bots", "coderabbitai[bot],"+dialect.CodexBotLogin) + if !ghapi.IsThrottled(err) { + t.Fatalf("reviewer change error = %v, want GitHub throttling propagated", err) + } +} + +func TestInaccessibleRepoLookupClassification(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {name: "not found", err: ghapi.ErrNotFound, want: true}, + {name: "forbidden", err: &ghapi.APIError{Status: http.StatusForbidden}, want: true}, + {name: "unprocessable", err: &ghapi.APIError{Status: http.StatusUnprocessableEntity}, want: false}, + {name: "primary throttle", err: &ghapi.RateLimitError{Kind: "primary"}, want: false}, + {name: "secondary throttle", err: &ghapi.RateLimitError{Kind: "secondary"}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := inaccessibleRepoLookup(tt.err); got != tt.want { + t.Fatalf("inaccessibleRepoLookup(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} diff --git a/internal/crq/init.go b/internal/crq/init.go index ecc7a565..e62006be 100644 --- a/internal/crq/init.go +++ b/internal/crq/init.go @@ -48,7 +48,7 @@ func Init(ctx context.Context, cfg Config, gh *ghapi.GitHub, store StateStore) ( return InitResult{}, err } cfg.DashboardIssue = issue.Number - } else if err := store.SyncDashboard(ctx, state); err != nil { + } else if err := store.SyncDashboard(ctx, state, cfg.storeConfig()); err != nil { return InitResult{}, err } return InitResult{ diff --git a/internal/crq/replay_test.go b/internal/crq/replay_test.go index 2a7e9b0c..98a6331c 100644 --- a/internal/crq/replay_test.go +++ b/internal/crq/replay_test.go @@ -241,6 +241,7 @@ func (f *replayFixture) autoReviewEnqueue(repo string, pr int) bool { if err != nil { f.t.Fatalf("load: %v", err) } + fleetRevision := f.svc.fleetCfg(st).FleetRevision need, head, err := f.svc.needsReview(f.ctx, st, repo, pr, true) if err != nil { f.t.Fatalf("needsReview %s#%d: %v", repo, pr, err) @@ -248,7 +249,7 @@ func (f *replayFixture) autoReviewEnqueue(repo string, pr int) bool { if !need { return false } - if err := f.svc.enqueueBatch(f.ctx, []queueCandidate{{Repo: repo, PR: pr, Head: head}}); err != nil { + if err := f.svc.enqueueBatch(f.ctx, []queueCandidate{{Repo: repo, PR: pr, Head: head}}, fleetRevision); err != nil { f.t.Fatalf("enqueueBatch %s#%d: %v", repo, pr, err) } return true diff --git a/internal/crq/repoconfig_test.go b/internal/crq/repoconfig_test.go index d4e55653..5d99338f 100644 --- a/internal/crq/repoconfig_test.go +++ b/internal/crq/repoconfig_test.go @@ -694,7 +694,7 @@ func TestAReopenedPRPicksUpRequirementsChangedWhileItWasClosed(t *testing.T) { if !need || gotHead != head { t.Fatalf("needsReview = %v %q, want the reopened PR enqueued at %q", need, gotHead, head) } - if err := svc.enqueueBatch(ctx, []queueCandidate{{Repo: repo, PR: auto, Head: gotHead}}); err != nil { + if err := svc.enqueueBatch(ctx, []queueCandidate{{Repo: repo, PR: auto, Head: gotHead}}, svc.fleetCfg(st).FleetRevision); err != nil { t.Fatal(err) } diff --git a/internal/crq/service.go b/internal/crq/service.go index 36003441..2d57483e 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -302,13 +302,16 @@ type queueCandidate struct { // dashboard sync, so a large autoreview pass doesn't produce N separate state // writes / issue edits. A PR already tracked at the same head is skipped; a // stale head is superseded. The DecideFire dedup still backstops at pump time. -func (s *Service) enqueueBatch(ctx context.Context, items []queueCandidate) error { - if len(items) == 0 { +func (s *Service) enqueueBatch(ctx context.Context, items []queueCandidate, expectedFleetRevision string) error { + if len(items) == 0 || s.cfg.DryRun { return nil } state, err := s.store.Update(ctx, func(st *State) error { now := s.clock() cfg := s.fleetCfg(*st) + if cfg.FleetRevision != expectedFleetRevision { + return ErrNoChange + } added := 0 for _, it := range items { repo := NormalizeRepo(it.Repo) @@ -588,11 +591,15 @@ func (s *Service) advanceQuotaFree(ctx context.Context, repo string, pr int) (Pu // counts here. AcceptAccountBlock still decides whether it replaces the standing // window, and never shortens it. func (s *Service) recordObservedBlock(ctx context.Context, obs observation, st State, now time.Time) (*State, error) { - blk := engine.ObservedAccountBlock(obs.eng, s.cfg.policy(), st.Account, now) + cfg := s.fleetCfg(st) + blk := engine.ObservedAccountBlock(obs.eng, cfg.policy(), st.Account, now) if blk == nil || s.cfg.DryRun || !observedAccountBlockChanges(st.Account, blk) { return nil, nil } updated, err := s.store.Update(ctx, func(w *State) error { + if fleetRevision(*w) != cfg.FleetRevision { + return ErrNoChange + } if !observedAccountBlockChanges(w.Account, blk) { return ErrNoChange } @@ -1898,6 +1905,9 @@ func (s *Service) RefreshQuota(ctx context.Context) (State, error) { if err != nil { return State{}, err } + if s.cfg.DryRun { + return state, nil + } cfg := s.fleetCfg(state) if cfg.CalibrationPR <= 0 { return state, nil @@ -1914,7 +1924,11 @@ func (s *Service) RefreshQuota(ctx context.Context) (State, error) { return state, err } updated, err := s.store.Update(ctx, func(st *State) error { - currentTTL := s.fleetCfg(*st).CalibrationTTL + current := s.fleetCfg(*st) + if current.FleetRevision != cfg.FleetRevision { + return ErrNoChange + } + currentTTL := current.CalibrationTTL if st.Account.CalibAskedAt == nil && st.Account.CheckedAt != nil && now.Sub(*st.Account.CheckedAt) < currentTTL { return ErrNoChange } @@ -2215,7 +2229,7 @@ func (s *Service) sync(ctx context.Context, state State) { if s.log == nil || s.cfg.DashboardIssue <= 0 { return } - if err := s.store.SyncDashboard(ctx, state); err != nil { + if err := s.store.SyncDashboard(ctx, state, s.fleetCfg(state).storeConfig()); err != nil { s.log.Printf("warning: dashboard sync failed: %v", err) } } diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index b6409d62..cc861cf0 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -73,6 +73,7 @@ func newFakeGitHub() *fakeGitHub { issueReactions: map[string][]ghapi.Reaction{}, reactions: map[int64][]ghapi.Reaction{}, reactionErrs: map[int64]error{}, + listPullErrs: map[string]error{}, deleteErrs: map[int64]error{}, deleteAfterErrs: map[int64]error{}, } @@ -264,8 +265,12 @@ func (f *fakeGitHub) SearchOpenPRs(context.Context, string, bool, int) ([]ghapi. return nil, nil } -func (f *fakeGitHub) EachOpenPR(_ context.Context, _ string, _ bool, fn func(ghapi.SearchPR) (bool, error)) error { +func (f *fakeGitHub) EachOpenPR(_ context.Context, target string, _ bool, fn func(ghapi.SearchPR) (bool, error)) error { f.mu.Lock() + if err := f.listPullErrs[strings.ToLower(target)]; err != nil { + f.mu.Unlock() + return err + } prs := append([]ghapi.SearchPR(nil), f.searchPRs...) f.mu.Unlock() for _, pr := range prs { @@ -417,7 +422,7 @@ func (s retryNoChangeStore) Update(_ context.Context, mutate func(*State) error) return second, nil } -func (retryNoChangeStore) SyncDashboard(context.Context, State) error { return nil } +func (retryNoChangeStore) SyncDashboard(context.Context, State, StoreConfig) error { return nil } // adoptionRaceStore loads a queued round with an adoptable command, but every // Update simulates another worker already holding the fire slot. @@ -453,7 +458,7 @@ func (s *adoptionRaceStore) Update(_ context.Context, mutate func(*State) error) return state, nil } -func (s *adoptionRaceStore) SyncDashboard(context.Context, State) error { return nil } +func (s *adoptionRaceStore) SyncDashboard(context.Context, State, StoreConfig) error { return nil } // --- test helpers --- @@ -678,7 +683,7 @@ func TestEnqueueBatchAppendsOncePerPR(t *testing.T) { {Repo: "o/b", PR: 2, Head: "bbbbbbbb2"}, {Repo: "o/a", PR: 1, Head: "aaaaaaaa1"}, } - if err := svc.enqueueBatch(ctx, items); err != nil { + if err := svc.enqueueBatch(ctx, items, svc.fleetCfg(DefaultState(cfg)).FleetRevision); err != nil { t.Fatal(err) } st, _, _ := svc.store.Load(ctx) @@ -689,7 +694,7 @@ func TestEnqueueBatchAppendsOncePerPR(t *testing.T) { if queued[0].Seq == queued[1].Seq || queued[0].Seq == 0 { t.Fatalf("expected distinct non-zero seqs, got %d and %d", queued[0].Seq, queued[1].Seq) } - if err := svc.enqueueBatch(ctx, items); err != nil { + if err := svc.enqueueBatch(ctx, items, svc.fleetCfg(DefaultState(cfg)).FleetRevision); err != nil { t.Fatal(err) } st2, _, _ := svc.store.Load(ctx) @@ -715,7 +720,7 @@ func TestEnqueueBatchSkipsHeldPRsUnderCAS(t *testing.T) { {Repo: "o/held", PR: 1, Head: "aaaaaaaa1"}, {Repo: "o/ready", PR: 2, Head: "bbbbbbbb2"}, } - if err := svc.enqueueBatch(ctx, items); err != nil { + if err := svc.enqueueBatch(ctx, items, svc.fleetCfg(DefaultState(cfg)).FleetRevision); err != nil { t.Fatal(err) } st, _, err := store.Load(ctx) @@ -730,6 +735,60 @@ func TestEnqueueBatchSkipsHeldPRsUnderCAS(t *testing.T) { } } +func TestEnqueueBatchRejectsCandidatesSelectedUnderStaleFleetPolicy(t *testing.T) { + cfg := Config{GateRepo: "o/gate", Scope: []string{"o"}, Host: "h"} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + ctx := context.Background() + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + selectedRevision := svc.fleetCfg(st).FleetRevision + if _, err := store.Update(ctx, func(st *State) error { + st.SetFleetValue("repos", "o/elsewhere") + return nil + }); err != nil { + t.Fatal(err) + } + + if err := svc.enqueueBatch(ctx, []queueCandidate{ + {Repo: "o/obsolete", PR: 1, Head: "aaaaaaaa1"}, + }, selectedRevision); err != nil { + t.Fatal(err) + } + st, _, err = store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round("o/obsolete", 1); round != nil { + t.Fatalf("stale fleet discovery enqueued a round: %+v", round) + } +} + +func TestEnqueueBatchDoesNotWriteInDryRun(t *testing.T) { + cfg := Config{GateRepo: "o/gate", Scope: []string{"o"}, Host: "h", DryRun: true} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + ctx := context.Background() + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if err := svc.enqueueBatch(ctx, []queueCandidate{ + {Repo: "o/repo", PR: 1, Head: "aaaaaaaa1"}, + }, svc.fleetCfg(st).FleetRevision); err != nil { + t.Fatal(err) + } + st, _, err = store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round("o/repo", 1); round != nil { + t.Fatalf("dry-run batch wrote a round: %+v", round) + } +} + func TestLatestCalibrationReplyToleratesBotSuffix(t *testing.T) { cfg := Config{Bot: "coderabbitai", GateRepo: "o/gate", CalibrationPR: 1, CalibrationMarker: "auto-generated reply by CodeRabbit"} gh := newFakeGitHub() @@ -2361,6 +2420,59 @@ func TestRefreshQuotaPreservesBlockOnInconclusiveProbe(t *testing.T) { } } +func TestRefreshQuotaRejectsAReadingFromAnObsoleteFleetScope(t *testing.T) { + ctx := context.Background() + cfg := Config{ + GateRepo: "owner/gate", StateRef: "crq-state-v3", CalibrationPR: 1, + CalibrationMarker: "auto-generated reply by CodeRabbit", + RateLimitCommand: "@coderabbitai rate limit", + CalibrationTTL: 2 * time.Minute, Scope: []string{"old-owner"}, + } + gh := newFakeGitHub() + inner := NewMemoryStore(cfg) + store := &hookedStore{StateStore: inner} + svc := NewService(cfg, gh, store, nil) + store.hook = func() { + if _, err := inner.Update(ctx, func(st *State) error { + st.SetFleetValue("scope", "new-owner") + st.Account = AccountQuota{Scope: "new-owner", Source: "fleet scope changed"} + return nil + }); err != nil { + t.Fatal(err) + } + } + + updated, err := svc.RefreshQuota(ctx) + if err != nil { + t.Fatal(err) + } + if updated.Account.Scope != "new-owner" || updated.Account.CalibAskedAt != nil || updated.Account.CheckedAt != nil { + t.Fatalf("obsolete calibration reading overwrote the new scope reset: %+v", updated.Account) + } +} + +func TestRefreshQuotaDoesNotProbeOrWriteInDryRun(t *testing.T) { + ctx := context.Background() + cfg := Config{ + GateRepo: "owner/gate", CalibrationPR: 1, DryRun: true, + CalibrationTTL: 2 * time.Minute, Scope: []string{"owner"}, + } + gh := newFakeGitHub() + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + + updated, err := svc.RefreshQuota(ctx) + if err != nil { + t.Fatal(err) + } + if len(gh.posted) != 0 { + t.Fatalf("dry-run calibration posted comments: %v", gh.posted) + } + if updated.Account.CalibAskedAt != nil || updated.Account.CheckedAt != nil { + t.Fatalf("dry-run calibration changed quota state: %+v", updated.Account) + } +} + // TestLoopDegradesToCodexOnlyOnRateLimit: a rate-limited round with Codex // activity must return Codex findings promptly — marked deferred — instead of // waiting out the CodeRabbit window. diff --git a/internal/crq/watch_test.go b/internal/crq/watch_test.go index c8fbe8cc..1b4baeb7 100644 --- a/internal/crq/watch_test.go +++ b/internal/crq/watch_test.go @@ -689,7 +689,7 @@ type dashboardCountingStore struct { syncs []State } -func (s *dashboardCountingStore) SyncDashboard(_ context.Context, state State) error { +func (s *dashboardCountingStore) SyncDashboard(_ context.Context, state State, _ StoreConfig) error { s.syncs = append(s.syncs, cloneState(state)) return nil } diff --git a/internal/state/dashboard_sync_test.go b/internal/state/dashboard_sync_test.go index 831e5963..d5f10dba 100644 --- a/internal/state/dashboard_sync_test.go +++ b/internal/state/dashboard_sync_test.go @@ -98,7 +98,7 @@ func TestSyncDashboardWritesOnlyOnChange(t *testing.T) { st := syncTestState(t) for i := 0; i < 4; i++ { - if err := store.SyncDashboard(ctx, st); err != nil { + if err := store.SyncDashboard(ctx, st, cfg); err != nil { t.Fatalf("sync %d: %v", i, err) } } @@ -120,7 +120,7 @@ func TestSyncDashboardWritesOnlyOnChange(t *testing.T) { t.Fatal(err) } st.Normalize(t0) - if err := store.SyncDashboard(ctx, st); err != nil { + if err := store.SyncDashboard(ctx, st, cfg); err != nil { t.Fatal(err) } srv.mu.Lock() @@ -142,7 +142,7 @@ func TestSyncDashboardSkipsWriteWhenTheIssueAlreadyMatches(t *testing.T) { st := syncTestState(t) // One process (think: the daemon) publishes the dashboard. - if err := NewGitStateStore(cfg, client, nil).SyncDashboard(ctx, st); err != nil { + if err := NewGitStateStore(cfg, client, nil).SyncDashboard(ctx, st, cfg); err != nil { t.Fatal(err) } srv.mu.Lock() @@ -155,7 +155,7 @@ func TestSyncDashboardSkipsWriteWhenTheIssueAlreadyMatches(t *testing.T) { // A genuinely cold second process against the same issue: its own client, so // it shares no ETag cache with the first. cold := NewGitStateStore(cfg, gh.NewTestClient(httpSrv.URL, httpSrv.Client()), nil) - if err := cold.SyncDashboard(ctx, st); err != nil { + if err := cold.SyncDashboard(ctx, st, cfg); err != nil { t.Fatal(err) } srv.mu.Lock() @@ -167,3 +167,24 @@ func TestSyncDashboardSkipsWriteWhenTheIssueAlreadyMatches(t *testing.T) { t.Fatal("the cross-process check must actually read the issue") } } + +func TestSyncDashboardUsesTheSuppliedEffectiveRenderingConfig(t *testing.T) { + srv := &issueServer{} + _, client := srv.start(t) + startup := StoreConfig{ + GateRepo: "owner/state", StateRef: "crq-state-v3", DashboardIssue: 1, + Scope: []string{"owner"}, CoReviewers: "codex (selfheal)", + } + effective := startup + effective.CoReviewers = "codex (required, always)" + st := syncTestState(t) + + if err := NewGitStateStore(startup, client, nil).SyncDashboard(context.Background(), st, effective); err != nil { + t.Fatal(err) + } + srv.mu.Lock() + defer srv.mu.Unlock() + if !strings.Contains(srv.body, effective.CoReviewers) || strings.Contains(srv.body, startup.CoReviewers) { + t.Fatalf("dashboard body did not use the effective reviewer summary:\n%s", srv.body) + } +} diff --git a/internal/state/store.go b/internal/state/store.go index b44387c8..3c2d3b3f 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -81,7 +81,7 @@ type Revision struct { type StateStore interface { Load(context.Context) (State, Revision, error) Update(context.Context, func(*State) error) (State, error) - SyncDashboard(context.Context, State) error + SyncDashboard(context.Context, State, StoreConfig) error } // GitStateStore persists v5 state as state.json in a git ref, with the same @@ -290,15 +290,15 @@ func (s *GitStateStore) compareAndSwap(ctx context.Context, st *State, rev Revis // // A read failure is never fatal: fall through and PATCH, which is the old // behavior. -func (s *GitStateStore) SyncDashboard(ctx context.Context, st State) error { +func (s *GitStateStore) SyncDashboard(ctx context.Context, st State, renderCfg StoreConfig) error { if err := s.cfg.requireDashboard(); err != nil { return err } - body, err := IssueBody(st, s.cfg) + body, err := IssueBody(st, renderCfg) if err != nil { return err } - title := RenderTitle(st, s.cfg) + title := RenderTitle(st, renderCfg) // Held across read-then-write so two concurrent syncs cannot both observe a // stale issue and both write it. @@ -356,7 +356,7 @@ func (m *MemoryStore) Update(_ context.Context, mutate func(*State) error) (Stat return st, nil } -func (m *MemoryStore) SyncDashboard(context.Context, State) error { return nil } +func (m *MemoryStore) SyncDashboard(context.Context, State, StoreConfig) error { return nil } // Clone deep-copies a State via its JSON representation, so a mutate closure // can never scribble on the store's retained copy. From b883db84eb022d79662c8c55af8ab3c43bb1ab7a Mon Sep 17 00:00:00 2001 From: kristofferR <481270+kristofferR@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:28:00 +0200 Subject: [PATCH 23/37] Fix fleet drain configuration previews --- cmd/crq/main.go | 27 ++++++++++--------- internal/crq/drain.go | 37 +++++++++++++++++++------- internal/crq/drain_test.go | 45 ++++++++++++++++++++++++++++++++ internal/crq/fleetcmd.go | 16 ++++++++++-- internal/crq/fleetconfig_test.go | 29 ++++++++++++++++++++ 5 files changed, 131 insertions(+), 23 deletions(-) diff --git a/cmd/crq/main.go b/cmd/crq/main.go index 0971f9a9..b65d1d62 100644 --- a/cmd/crq/main.go +++ b/cmd/crq/main.go @@ -73,25 +73,28 @@ func run(ctx context.Context, args []string) int { case "preflight": return preflight(ctx, args[1:]) case "drain": - // A dry run is documented as a PREVIEW: it writes nothing and reads no - // GitHub state. Deciding it here, before the authenticated client is - // built, is what lets somebody inspect the setup before finishing it — - // otherwise the one command for looking at the plan was itself another - // thing to set up first. A real install still goes through the - // authenticated path below. + // An authenticated dry run follows the normal path below so its plan is + // built from the same fleet state as the real install. Keep a host-only + // fallback for somebody who has not configured or authenticated GitHub + // yet, but label it so it cannot be mistaken for the fleet-backed plan. if opts, perr := parseDrainArgs(args[1:]); perr == nil && opts.dryRun { cfg, cerr := crq.LoadConfig() if cerr != nil { fatal(cerr) return 1 } - plan, ierr := crq.DrainPlan(cfg, opts.agent, crq.SplitArgv(opts.agentArgs), opts.repos, true) - if ierr != nil { - fatal(ierr) - return 1 + _, authErr := ghapi.NewGitHub(ctx) + if cfg.RequireState() != nil || authErr != nil { + plan, ierr := crq.DrainPlan(cfg, opts.agent, crq.SplitArgv(opts.agentArgs), opts.repos, true) + if ierr != nil { + fatal(ierr) + return 1 + } + plan.PolicySource = "host" + plan.Warning = "GitHub fleet state is unavailable; this host-only preview may differ from an authenticated install" + printJSON(plan) + return 0 } - printJSON(plan) - return 0 } } diff --git a/internal/crq/drain.go b/internal/crq/drain.go index 4a41a129..8875f020 100644 --- a/internal/crq/drain.go +++ b/internal/crq/drain.go @@ -25,13 +25,15 @@ var fixPrompt string // DrainInstall describes what an install would do, so --dry-run can print it and // the result can be reported. type DrainInstall struct { - Platform string `json:"platform"` - Prompt string `json:"prompt"` - Wrapper string `json:"wrapper"` - Unit string `json:"unit"` - LogDir string `json:"log_dir"` - Workspace string `json:"workspace,omitempty"` - Agent string `json:"agent"` + Platform string `json:"platform"` + Prompt string `json:"prompt"` + Wrapper string `json:"wrapper"` + Unit string `json:"unit"` + LogDir string `json:"log_dir"` + Workspace string `json:"workspace,omitempty"` + Agent string `json:"agent"` + PolicySource string `json:"policy_source"` + Warning string `json:"warning,omitempty"` // Invocation is the exact command the wrapper runs, so --dry-run shows what // the fix session will actually be — including the model, which is the // agent's business and not crq's to hardcode. @@ -58,10 +60,27 @@ func (s *Service) InstallDrain(ctx context.Context, agent string, agentArgs []st } effective := s.fleetCfg(st) plan, err := DrainPlan(effective, agent, agentArgs, repos, effectiveDryRun) + plan.PolicySource = "fleet" if err != nil || effectiveDryRun { return plan, err } - return s.applyDrain(ctx, plan, effective) + return s.applyDrain(ctx, plan, s.drainFallbackConfig(repos)) +} + +// drainFallbackConfig is what the unit should carry for settings the fleet may +// later unset. Fleet policy is overlaid from state at runtime; baking today's +// effective values into the service would make them survive after that policy +// was removed. Explicit install repositories remain this host's chosen fallback. +func (s *Service) drainFallbackConfig(repos []string) Config { + cfg := s.cfg + if len(repos) == 0 { + return cfg + } + cfg.AllowRepos = make(map[string]bool, len(repos)) + for _, repo := range repos { + cfg.AllowRepos[repo] = true + } + return cfg } // DrainPlan computes what an install WOULD write and run, from configuration @@ -349,7 +368,7 @@ func (s *Service) drainEnv(plan DrainInstall) map[string]string { func drainEnvFor(cfg Config, plan DrainInstall) map[string]string { env := map[string]string{ - "CRQ_REPOS": strings.Join(plan.Repos, ","), + "CRQ_REPOS": strings.Join(sortedRepoList(cfg.AllowRepos), ","), // The denylist travels with the allowlist. Carrying one and not the other // installs a service that watches a repository the operator excluded. "CRQ_EXCLUDE": strings.Join(sortedRepoList(cfg.ExcludeRepos), ","), diff --git a/internal/crq/drain_test.go b/internal/crq/drain_test.go index 3c0da085..9e296260 100644 --- a/internal/crq/drain_test.go +++ b/internal/crq/drain_test.go @@ -88,6 +88,51 @@ func TestInstallDrainUsesFleetRepositories(t *testing.T) { if len(plan.Repos) != 1 || plan.Repos[0] != "owner/fleet-repo" { t.Fatalf("drain repos = %v, want the fleet repository", plan.Repos) } + if plan.PolicySource != "fleet" { + t.Fatalf("policy source = %q, want fleet", plan.PolicySource) + } +} + +func TestDrainUnitKeepsHostFallbacksUnderFleetPolicy(t *testing.T) { + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"owner/host-repo": true} + cfg.ExcludeRepos = map[string]bool{"owner/host-excluded": true} + store := NewMemoryStore(cfg) + if _, err := store.Update(context.Background(), func(st *State) error { + st.SetFleetValue("repos", "owner/fleet-repo") + st.SetFleetValue("exclude", "owner/fleet-excluded") + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, newFakeGitHub(), store, nil) + + plan, err := svc.InstallDrain(context.Background(), fakeAgent(t, "claude"), nil, nil, true) + if err != nil { + t.Fatal(err) + } + if len(plan.Repos) != 1 || plan.Repos[0] != "owner/fleet-repo" { + t.Fatalf("preview repos = %v, want current fleet policy", plan.Repos) + } + env := drainEnvFor(svc.drainFallbackConfig(nil), plan) + if got := env["CRQ_REPOS"]; got != "owner/host-repo" { + t.Fatalf("unit CRQ_REPOS = %q, want host fallback", got) + } + if got := env["CRQ_EXCLUDE"]; got != "owner/host-excluded" { + t.Fatalf("unit CRQ_EXCLUDE = %q, want host fallback", got) + } +} + +func TestDrainUnitKeepsExplicitRepositoriesAsHostFallback(t *testing.T) { + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"owner/configured": true} + svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) + plan := DrainInstall{Repos: []string{"owner/explicit"}} + + env := drainEnvFor(svc.drainFallbackConfig(plan.Repos), plan) + if got := env["CRQ_REPOS"]; got != "owner/explicit" { + t.Fatalf("unit CRQ_REPOS = %q, want explicit install repository", got) + } } // A missing agent must fail loudly at install time. Discovering it at the first diff --git a/internal/crq/fleetcmd.go b/internal/crq/fleetcmd.go index 219a081d..141ba744 100644 --- a/internal/crq/fleetcmd.go +++ b/internal/crq/fleetcmd.go @@ -160,14 +160,26 @@ func (s *Service) SeedFleetConfig(ctx context.Context) ([]string, error) { // Dry-run differs only at the persistence boundary, so validation, capability // fences and reconciliation cannot drift from the command it previews. func (s *Service) updateFleet(ctx context.Context, mutate func(*State) error) (State, error) { + changed := false + apply := func(st *State) error { + err := mutate(st) + if err == nil { + changed = true + } + return err + } if !s.cfg.DryRun { - return s.store.Update(ctx, mutate) + st, err := s.store.Update(ctx, apply) + if err == nil && changed { + s.sync(ctx, st) + } + return st, err } st, _, err := s.store.Load(ctx) if err != nil { return State{}, err } - err = mutate(&st) + err = apply(&st) if errors.Is(err, ErrNoChange) { err = nil } diff --git a/internal/crq/fleetconfig_test.go b/internal/crq/fleetconfig_test.go index 8930fc2b..bfaab151 100644 --- a/internal/crq/fleetconfig_test.go +++ b/internal/crq/fleetconfig_test.go @@ -311,13 +311,42 @@ func TestFleetRevisionInvalidatesAStaleDecision(t *testing.T) { type dashboardConfigStore struct { StateStore render StoreConfig + syncs int } func (s *dashboardConfigStore) SyncDashboard(_ context.Context, _ State, cfg StoreConfig) error { s.render = cfg + s.syncs++ return nil } +func TestFleetMutationsSyncTheDashboard(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.DashboardIssue = 1 + store := &dashboardConfigStore{StateStore: NewMemoryStore(cfg)} + svc := NewService(cfg, newFakeGitHub(), store, &recordingLogger{}) + + if err := svc.SetFleetConfig(ctx, "min-interval", "5m"); err != nil { + t.Fatal(err) + } + if store.syncs != 1 { + t.Fatalf("dashboard syncs after set = %d, want 1", store.syncs) + } + if dropped, err := svc.UnsetFleetConfig(ctx, "min-interval"); err != nil || !dropped { + t.Fatalf("unset = %v, %v", dropped, err) + } + if store.syncs != 2 { + t.Fatalf("dashboard syncs after unset = %d, want 2", store.syncs) + } + if seeded, err := svc.SeedFleetConfig(ctx); err != nil || len(seeded) == 0 { + t.Fatalf("seed = %v, %v", seeded, err) + } + if store.syncs != 3 { + t.Fatalf("dashboard syncs after seed = %d, want 3", store.syncs) + } +} + func TestDashboardSyncReceivesTheEffectiveFleetReviewers(t *testing.T) { ctx := context.Background() cfg := isolatedConfig(t, map[string]string{ From 7c2db6bce8faba019df1e96929e35359157ca64c Mon Sep 17 00:00:00 2001 From: kristofferR <481270+kristofferR@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:51:51 +0200 Subject: [PATCH 24/37] Fix fleet policy review edge cases --- README.md | 4 +-- cmd/crq/main.go | 15 +++++--- internal/crq/drain.go | 28 ++++++++++----- internal/crq/drain_test.go | 10 ++++++ internal/crq/fleetcmd.go | 31 ++++++++++++++-- internal/crq/fleetconfig.go | 10 ++++++ internal/crq/fleetconfig_test.go | 61 ++++++++++++++++++++++++++++++-- internal/crq/init.go | 1 + internal/crq/init_test.go | 43 ++++++++++++++++++++++ internal/crq/repoconfig_test.go | 32 +++++++++++++++++ internal/crq/service.go | 2 +- internal/crq/watch.go | 4 +++ internal/crq/watch_test.go | 29 +++++++++++++++ internal/state/fleet.go | 4 +-- internal/state/state.go | 4 +-- internal/state/writers_test.go | 6 ++++ 16 files changed, 261 insertions(+), 23 deletions(-) create mode 100644 internal/crq/init_test.go diff --git a/README.md b/README.md index d91baaf4..7020711c 100644 --- a/README.md +++ b/README.md @@ -491,8 +491,8 @@ Set these in `~/.config/crq/env` (sourced automatically) or as environment varia ### One configuration for the fleet, not one per machine These settings belong to the whole fleet and live in the state ref, not in the table below: -`scope`, `repos`, `exclude`, `required-bots`, `min-interval`, `rate-limit-fallback`, -`calibrate-ttl`, `settle`, `skip-marker`. +`scope`, `repos`, `exclude`, `required-bots`, `min-interval`, `inflight-timeout`, +`rate-limit-fallback`, `calibrate-ttl`, `settle`, `skip-marker`. ```bash crq config # what is in force, and where it came from diff --git a/cmd/crq/main.go b/cmd/crq/main.go index b65d1d62..a3e81602 100644 --- a/cmd/crq/main.go +++ b/cmd/crq/main.go @@ -83,8 +83,15 @@ func run(ctx context.Context, args []string) int { fatal(cerr) return 1 } - _, authErr := ghapi.NewGitHub(ctx) - if cfg.RequireState() != nil || authErr != nil { + gh, fleetErr := ghapi.NewGitHub(ctx) + if fleetErr == nil { + fleetErr = cfg.RequireState() + } + if fleetErr == nil { + store := crq.NewGitStateStore(cfg, gh, stderrLogger{}) + _, _, fleetErr = store.Load(ctx) + } + if fleetErr != nil { plan, ierr := crq.DrainPlan(cfg, opts.agent, crq.SplitArgv(opts.agentArgs), opts.repos, true) if ierr != nil { fatal(ierr) @@ -1116,8 +1123,8 @@ window one host respects and another does not — and nothing says so, because each host is behaving correctly according to what it can see. These settings have one answer for the fleet: - scope, repos, exclude, required-bots, min-interval, rate-limit-fallback, - calibrate-ttl, settle, skip-marker + scope, repos, exclude, required-bots, min-interval, inflight-timeout, + rate-limit-fallback, calibrate-ttl, settle, skip-marker Three kinds of setting deliberately stay local: where the state lives (CRQ_REPO, CRQ_ISSUE, CRQ_STATE_REF — a host cannot read fleet policy until it diff --git a/internal/crq/drain.go b/internal/crq/drain.go index 8875f020..f95a73e0 100644 --- a/internal/crq/drain.go +++ b/internal/crq/drain.go @@ -64,7 +64,7 @@ func (s *Service) InstallDrain(ctx context.Context, agent string, agentArgs []st if err != nil || effectiveDryRun { return plan, err } - return s.applyDrain(ctx, plan, s.drainFallbackConfig(repos)) + return s.applyDrain(ctx, plan, s.drainFallbackConfig(repos), repos) } // drainFallbackConfig is what the unit should carry for settings the fleet may @@ -173,7 +173,7 @@ func DrainPlan(cfg Config, agent string, agentArgs []string, repos []string, dry } // applyDrain writes the plan to disk and starts the service. -func (s *Service) applyDrain(ctx context.Context, plan DrainInstall, cfg Config) (DrainInstall, error) { +func (s *Service) applyDrain(ctx context.Context, plan DrainInstall, cfg Config, repos []string) (DrainInstall, error) { invocation, logDir := plan.Invocation, plan.LogDir self, err := os.Executable() if err != nil { @@ -188,12 +188,7 @@ func (s *Service) applyDrain(ctx context.Context, plan DrainInstall, cfg Config) // Known agents receive their non-interactive flags. Any other executable is // treated as a self-contained prompt-taking wrapper. - wrapper := fmt.Sprintf(`#!/usr/bin/env bash -# Installed by "crq drain install". Runs the review drain: crq decides, and a -# fix session is started for each PR that needs one. -set -uo pipefail -exec %s watch -- %s -`, shellQuote(self), invocation) + wrapper := drainWrapper(self, invocation, repos) for _, f := range []struct { path string @@ -251,6 +246,23 @@ exec %s watch -- %s return plan, nil } +func drainWrapper(self, invocation string, repos []string) string { + watchArgs := make([]string, 0, len(repos)) + for _, repo := range repos { + watchArgs = append(watchArgs, shellQuote(repo)) + } + watch := strings.Join(watchArgs, " ") + if watch != "" { + watch += " " + } + return fmt.Sprintf(`#!/usr/bin/env bash +# Installed by "crq drain install". Runs the review drain: crq decides, and a +# fix session is started for each PR that needs one. +set -uo pipefail +exec %s watch %s-- %s +`, shellQuote(self), watch, invocation) +} + func writeDrainFile(path, body string, mode os.FileMode) error { if err := os.WriteFile(path, []byte(body), mode); err != nil { return err diff --git a/internal/crq/drain_test.go b/internal/crq/drain_test.go index 9e296260..62f586e1 100644 --- a/internal/crq/drain_test.go +++ b/internal/crq/drain_test.go @@ -135,6 +135,16 @@ func TestDrainUnitKeepsExplicitRepositoriesAsHostFallback(t *testing.T) { } } +func TestDrainWrapperKeepsExplicitRepositoriesAuthoritative(t *testing.T) { + got := drainWrapper("/usr/bin/crq", "'agent' 'prompt'", []string{"owner/explicit", "owner/with space"}) + if !strings.Contains(got, "watch 'owner/explicit' 'owner/with space' --") { + t.Fatalf("wrapper lost explicit repositories:\n%s", got) + } + if fallback := drainWrapper("/usr/bin/crq", "'agent' 'prompt'", nil); !strings.Contains(fallback, "watch --") { + t.Fatalf("wrapper without explicit repositories does not use runtime fleet policy:\n%s", fallback) + } +} + // A missing agent must fail loudly at install time. Discovering it at the first // dispatch means a drain that looks installed and fixes nothing. func TestInstallDrainRefusesWithoutAnAgent(t *testing.T) { diff --git a/internal/crq/fleetcmd.go b/internal/crq/fleetcmd.go index 141ba744..fa9cabbe 100644 --- a/internal/crq/fleetcmd.go +++ b/internal/crq/fleetcmd.go @@ -122,6 +122,10 @@ func (s *Service) UnsetFleetConfig(ctx context.Context, key string) (bool, error // whole mechanism exists to end. func (s *Service) SeedFleetConfig(ctx context.Context) ([]string, error) { settings := fleetSettings() + rendered := make(map[string]string, len(settings)) + for _, key := range FleetKeys() { + rendered[key] = settings[key].Show(s.cfg) + } var seeded []string initial, _, err := s.store.Load(ctx) if err != nil { @@ -144,9 +148,14 @@ func (s *Service) SeedFleetConfig(ctx context.Context) ([]string, error) { if _, ok := st.FleetValue(key); ok { continue } - st.SetFleetValue(key, settings[key].Show(s.cfg)) + if err := ValidateFleetSetting(key, rendered[key]); err != nil { + return fmt.Errorf("cannot seed %s from this host: %w", key, err) + } seeded = append(seeded, key) } + for _, key := range seeded { + st.SetFleetValue(key, rendered[key]) + } if len(seeded) == 0 { return ErrNoChange } @@ -268,7 +277,7 @@ func inaccessibleRepoLookup(err error) bool { } func (s *Service) reconcileFleetChange(st *State, before, after Config, open map[string]map[int]bool) { - if strings.Join(before.Scope, ",") != strings.Join(after.Scope, ",") { + if !sameFoldedSet(before.Scope, after.Scope) { st.Account = AccountQuota{ Scope: strings.Join(after.Scope, ","), Source: "fleet scope changed", @@ -287,6 +296,24 @@ func (s *Service) reconcileFleetChange(st *State, before, after Config, open map s.reopenForFleetReviewerChange(st, before, after, open) } +func sameFoldedSet(a, b []string) bool { + if len(a) != len(b) { + return false + } + seen := make(map[string]int, len(a)) + for _, value := range a { + seen[strings.ToLower(strings.TrimSpace(value))]++ + } + for _, value := range b { + key := strings.ToLower(strings.TrimSpace(value)) + seen[key]-- + if seen[key] < 0 { + return false + } + } + return true +} + func (s *Service) reopenForFleetReviewerChange(st *State, before, after Config, open map[string]map[int]bool) { if sameLogins(before.RequiredBots, after.RequiredBots) { return diff --git a/internal/crq/fleetconfig.go b/internal/crq/fleetconfig.go index 7cd2a508..8cb0a303 100644 --- a/internal/crq/fleetconfig.go +++ b/internal/crq/fleetconfig.go @@ -90,6 +90,16 @@ func fleetSettings() map[string]fleetSetting { }, Show: func(cfg Config) string { return cfg.MinInterval.String() }, }, + "inflight-timeout": { + Doc: "how long a metered review command may remain unanswered", + Env: "CRQ_INFLIGHT_TIMEOUT", + Apply: func(cfg *Config, v string) error { + d, err := parseFleetDuration(v) + cfg.InflightTimeout = d + return err + }, + Show: func(cfg Config) string { return cfg.InflightTimeout.String() }, + }, "rate-limit-fallback": { Doc: "how long to wait when a rate-limit notice states no window", Env: "CRQ_RL_FALLBACK", diff --git a/internal/crq/fleetconfig_test.go b/internal/crq/fleetconfig_test.go index bfaab151..ec1439e5 100644 --- a/internal/crq/fleetconfig_test.go +++ b/internal/crq/fleetconfig_test.go @@ -131,6 +131,25 @@ func TestSeedingDoesNotOverwriteWhatTheFleetAlreadySays(t *testing.T) { } } +func TestSeedingValidatesEveryMissingValueBeforeWriting(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"not-a-repository": true} + store := NewMemoryStore(cfg) + + seeded, err := NewService(cfg, newFakeGitHub(), store, nil).SeedFleetConfig(ctx) + if err == nil || !strings.Contains(err.Error(), "cannot seed repos") { + t.Fatalf("seed = %v, %v; want the malformed host repository rejected", seeded, err) + } + st, _, loadErr := store.Load(ctx) + if loadErr != nil { + t.Fatal(loadErr) + } + if len(st.FleetConfig) != 0 { + t.Fatalf("failed seed partially wrote fleet policy: %v", st.FleetConfig) + } +} + func TestFleetRequiredBotsRebuildsDerivedReviewers(t *testing.T) { cfg := isolatedConfig(t, map[string]string{ "CRQ_COBOTS": "codex", @@ -183,7 +202,7 @@ func TestFleetDryRunStillChecksDriverCompatibility(t *testing.T) { store := NewMemoryStore(cfg) if _, err := store.Update(ctx, func(st *State) error { st.Leader = &LeaderLease{Owner: "old-daemon", ExpiresAt: now.Add(time.Minute)} - st.NoteWriter("old-daemon", CapsRepoOverrides, now) + st.NoteWriter("old-daemon", CapsFleetPolicy-1, now) return nil }); err != nil { t.Fatal(err) @@ -229,6 +248,44 @@ func TestFleetScopeChangeInvalidatesAccountQuota(t *testing.T) { } } +func TestEquivalentFleetScopeKeepsAccountQuota(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.Scope = []string{"Acme", "Foo"} + store := NewMemoryStore(cfg) + blocked := time.Date(2026, 7, 27, 13, 0, 0, 0, time.UTC) + if _, err := store.Update(ctx, func(st *State) error { + st.Account.BlockedUntil = &blocked + st.Account.Scope = "Acme,Foo" + return nil + }); err != nil { + t.Fatal(err) + } + + if err := NewService(cfg, newFakeGitHub(), store, nil).SetFleetConfig(ctx, "scope", "foo,acme"); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if st.Account.BlockedUntil == nil || !st.Account.BlockedUntil.Equal(blocked) { + t.Fatalf("equivalent scope cleared the account block: %+v", st.Account) + } +} + +func TestFleetInflightTimeoutOverridesTheHost(t *testing.T) { + cfg := firingConfig() + cfg.InflightTimeout = 15 * time.Minute + st := DefaultState(cfg) + st.SetFleetValue("inflight-timeout", "45m") + + got := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil).fleetCfg(st) + if got.InflightTimeout != 45*time.Minute { + t.Fatalf("inflight timeout = %s, want fleet 45m", got.InflightTimeout) + } +} + func TestFleetExcludeRetiresQueuedRounds(t *testing.T) { ctx := context.Background() cfg := firingConfig() @@ -394,7 +451,7 @@ func TestFleetPolicyRefusesALaggingQueueDriver(t *testing.T) { store := NewMemoryStore(cfg) if _, err := store.Update(ctx, func(st *State) error { st.Leader = &LeaderLease{Owner: "old-daemon", ExpiresAt: now.Add(time.Minute)} - st.NoteWriter("old-daemon", CapsRepoOverrides, now) + st.NoteWriter("old-daemon", CapsFleetPolicy-1, now) return nil }); err != nil { t.Fatal(err) diff --git a/internal/crq/init.go b/internal/crq/init.go index e62006be..5c58ecb7 100644 --- a/internal/crq/init.go +++ b/internal/crq/init.go @@ -38,6 +38,7 @@ func Init(ctx context.Context, cfg Config, gh *ghapi.GitHub, store StateStore) ( if err != nil { return InitResult{}, err } + cfg = applyFleet(cfg, state.FleetConfig, nil) if cfg.DashboardIssue <= 0 { body, err := issueBody(state, cfg) if err != nil { diff --git a/internal/crq/init_test.go b/internal/crq/init_test.go new file mode 100644 index 00000000..ac280b29 --- /dev/null +++ b/internal/crq/init_test.go @@ -0,0 +1,43 @@ +package crq + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" +) + +func TestInitSyncsDashboardWithFleetReviewers(t *testing.T) { + ctx := context.Background() + cfg := isolatedConfig(t, map[string]string{ + "CRQ_REPO": "owner/gate", + "CRQ_ISSUE": "7", + "CRQ_CAL_PR": "8", + "CRQ_COBOTS": "codex", + "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", + }) + inner := NewMemoryStore(cfg) + if _, err := inner.Update(ctx, func(st *State) error { + st.SetFleetValue("required-bots", "coderabbitai[bot],"+dialect.CodexBotLogin) + return nil + }); err != nil { + t.Fatal(err) + } + store := &dashboardConfigStore{StateStore: inner} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + if _, err := Init(ctx, cfg, ghapi.NewTestClient(server.URL, server.Client()), store); err != nil { + t.Fatal(err) + } + if !strings.Contains(store.render.CoReviewers, "codex (required") { + t.Fatalf("dashboard co-reviewers = %q, want fleet-required codex", store.render.CoReviewers) + } +} diff --git a/internal/crq/repoconfig_test.go b/internal/crq/repoconfig_test.go index 5d99338f..b9b8485b 100644 --- a/internal/crq/repoconfig_test.go +++ b/internal/crq/repoconfig_test.go @@ -945,3 +945,35 @@ func TestSelfHealTriggerIsRevalidatedInsideItsClaim(t *testing.T) { t.Errorf("codex bookkeeping = %+v, want no claim for a removed reviewer", c) } } + +func TestSelfHealDoesNotTriggerForFleetExcludedRepository(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.RequiredBots = []string{cfg.Bot, dialect.CodexBotLogin} + cfg.CoBots = codexCoBots(cfg.RequiredBots) + cfg.ExcludeRepos = map[string]bool{"o/r": true} + store := NewMemoryStore(cfg) + gh := newFakeGitHub() + svc := NewService(cfg, gh, store, nil) + now := time.Now().UTC() + seedRound(t, store, cfg, "o/r", 13, "aaaaaaaa1", PhaseFired, now, 22) + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + + svc.selfHealCoReviewers(ctx, cfg, *st.Round("o/r", 13), engine.Observation{ + Head: "aaaaaaaa1", Open: true, + }, now) + + if len(gh.posted) != 0 { + t.Fatalf("excluded repository received a co-review trigger: %v", gh.posted) + } + st, _, err = store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if c := st.Round("o/r", 13).Co(dialect.CodexBotLogin); c.ClaimedAt != nil || c.CommandID != 0 { + t.Fatalf("excluded repository retained a co-review claim: %+v", c) + } +} diff --git a/internal/crq/service.go b/internal/crq/service.go index 2d57483e..80cb9c29 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -1825,7 +1825,7 @@ func (s *Service) fireCoDeferred(ctx context.Context, cfg Config, round Round, d // command, or an account that reviews on its own all suppress it (see // DecideCoPost) — not a retry counter. func (s *Service) selfHealCoReviewers(ctx context.Context, cfg Config, round Round, obs engine.Observation, now time.Time) { - if s.cfg.DryRun || round.FiredAt == nil || obs.Head != round.Head { + if s.cfg.DryRun || cfg.ExcludeRepos[NormalizeRepo(round.Repo)] || round.FiredAt == nil || obs.Head != round.Head { return } firedAt := round.FiredAt.UTC() diff --git a/internal/crq/watch.go b/internal/crq/watch.go index 4645a0c5..c65a271c 100644 --- a/internal/crq/watch.go +++ b/internal/crq/watch.go @@ -764,6 +764,10 @@ func (s *Service) claimDispatch(ctx context.Context, report NextReport, token st reason, byDesign = "fix sessions are disabled for this repository", true return ErrNoChange } + if s.fleetCfg(*st).ExcludeRepos[NormalizeRepo(report.Repo)] { + reason, byDesign = "repository is excluded by fleet policy", true + return ErrNoChange + } round := st.Round(report.Repo, report.PR) // What this attempt actually read, recorded before anything acts on it. // Three sessions once ran on one PR at one head while the round showed no diff --git a/internal/crq/watch_test.go b/internal/crq/watch_test.go index 1b4baeb7..c9f56354 100644 --- a/internal/crq/watch_test.go +++ b/internal/crq/watch_test.go @@ -808,6 +808,35 @@ func TestClaimDispatchRechecksRepositoryDrainSwitch(t *testing.T) { } } +func TestClaimDispatchRechecksFleetExclusion(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + report := NextReport{ + Repo: "owner/thing", PR: 12, Head: "aaaaaaaa1", Action: "fix", + Findings: []dialect.Finding{{ID: "f1", Commit: "aaaaaaaa1"}}, + } + if _, err := store.Update(ctx, func(st *State) error { + st.SetFleetValue("exclude", report.Repo) + return nil + }); err != nil { + t.Fatal(err) + } + + ok, why, byDesign := svc.claimDispatch(ctx, report, "tok", 3) + if ok || !byDesign || !strings.Contains(why, "excluded") { + t.Fatalf("claim = %v, %q, %v; want a fleet-exclusion refusal", ok, why, byDesign) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round(report.Repo, report.PR); round != nil && round.Dispatch != nil { + t.Errorf("refused claim mutated the round: %+v", round) + } +} + // A head that moved on while its previous round still stood: `Next` reports fix // without enqueueing, so nothing else supersedes the stale round. Refusing the // claim over the mismatch left the new head's findings undispatchable on every diff --git a/internal/state/fleet.go b/internal/state/fleet.go index 7206a579..2629af32 100644 --- a/internal/state/fleet.go +++ b/internal/state/fleet.go @@ -18,8 +18,8 @@ import ( // // A flat map rather than a struct, on purpose. One JSON member means an older // binary round-trips the whole thing rather than dropping settings it does not -// know, and adding a setting later needs no schema change. The keys are -// validated where they are set and where they are applied, not here. +// know. Newly interpreted decision-changing keys still require a writer +// capability bump so an active older driver cannot ignore them. type Fleet map[string]string // FleetValue returns a fleet setting and whether it is recorded. Absent means diff --git a/internal/state/state.go b/internal/state/state.go index faff2bb0..cc5cc81d 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -485,7 +485,7 @@ const SchemaVersion = 5 // WriterCaps is what THIS binary understands. Bump it when a state field starts // changing decisions, so a fleet running two versions can tell. -const WriterCaps = 2 +const WriterCaps = 3 // CapsRepoOverrides is the capability that makes per-repository reviewer // overrides safe to act on. @@ -493,7 +493,7 @@ const CapsRepoOverrides = 1 // CapsFleetPolicy is the capability that makes state-backed fleet policy safe // to activate while a process is driving the queue. -const CapsFleetPolicy = 2 +const CapsFleetPolicy = 3 // writerTTL is how long a host counts as still active for capability purposes. const writerTTL = 30 * time.Minute diff --git a/internal/state/writers_test.go b/internal/state/writers_test.go index cc816eea..e5340d4c 100644 --- a/internal/state/writers_test.go +++ b/internal/state/writers_test.go @@ -5,6 +5,12 @@ import ( "time" ) +func TestFleetPolicyCapabilityAdvancesWithNewDecisionKeys(t *testing.T) { + if WriterCaps != 3 || CapsFleetPolicy != WriterCaps { + t.Fatalf("writer caps = %d, fleet caps = %d; want fleet policy fenced at writer capability 3", WriterCaps, CapsFleetPolicy) + } +} + // Sharing a state ref stops an older binary ERASING a new field. It does not // make that binary act on it: it loads the field as unknown JSON, writes it back // untouched, and keeps deciding from its own fleet-wide configuration. So the From bda5407958f12572f789e4ed372c4aa8356a6652 Mon Sep 17 00:00:00 2001 From: kristofferR <481270+kristofferR@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:19:47 +0200 Subject: [PATCH 25/37] Close fleet policy gaps in retry, hold and reporting Four places where a host still answered for itself: - The slot hold `completeWaitRound` stamps used this host's InflightTimeout instead of the fleet's, so a host with a shorter local value dropped the hold while Progress was still waiting on the metered command, letting another review fire. - applyTransition revalidated the fleet revision only for OutComplete. A retry carries a RetryAt and account block computed from the fleet's rate-limit fallback, so widening it between decide and write persisted the old, earlier deadline. Both policy-dependent outcomes are now dropped when the policy moved under them. - SkipAuthors stayed local while autoReviewPass read it directly, so a Dependabot PR skipped on one host was enqueued after failover to another. It joins the fleet registry as skip-authors. - FleetConfig visited only keys this binary knows, so a setting written by a newer crq never appeared in `crq config` or `crq doctor` while this process silently ignored it. It is reported now, and `crq config unset` accepts a recorded key it does not understand so the remedy doctor names actually works. --- README.md | 2 +- cmd/crq/main.go | 2 +- internal/crq/auto.go | 2 +- internal/crq/feedback.go | 16 ++++++++-- internal/crq/feedback_test.go | 39 ++++++++++++++++++++++ internal/crq/fleetcmd.go | 38 ++++++++++++++++++++-- internal/crq/fleetconfig.go | 15 +++++++-- internal/crq/fleetconfig_test.go | 48 ++++++++++++++++++++++++++++ internal/crq/repoconfig_test.go | 51 +++++++++++++++++++++++++++++ internal/crq/service.go | 23 +++++++++---- internal/crq/service_test.go | 55 ++++++++++++++++++++++++++++++++ 11 files changed, 273 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 7020711c..5b33e18b 100644 --- a/README.md +++ b/README.md @@ -492,7 +492,7 @@ Set these in `~/.config/crq/env` (sourced automatically) or as environment varia These settings belong to the whole fleet and live in the state ref, not in the table below: `scope`, `repos`, `exclude`, `required-bots`, `min-interval`, `inflight-timeout`, -`rate-limit-fallback`, `calibrate-ttl`, `settle`, `skip-marker`. +`rate-limit-fallback`, `calibrate-ttl`, `settle`, `skip-marker`, `skip-authors`. ```bash crq config # what is in force, and where it came from diff --git a/cmd/crq/main.go b/cmd/crq/main.go index a3e81602..3912b863 100644 --- a/cmd/crq/main.go +++ b/cmd/crq/main.go @@ -1124,7 +1124,7 @@ each host is behaving correctly according to what it can see. These settings have one answer for the fleet: scope, repos, exclude, required-bots, min-interval, inflight-timeout, - rate-limit-fallback, calibrate-ttl, settle, skip-marker + rate-limit-fallback, calibrate-ttl, settle, skip-marker, skip-authors Three kinds of setting deliberately stay local: where the state lives (CRQ_REPO, CRQ_ISSUE, CRQ_STATE_REF — a host cannot read fleet policy until it diff --git a/internal/crq/auto.go b/internal/crq/auto.go index ab5e8aef..228303cb 100644 --- a/internal/crq/auto.go +++ b/internal/crq/auto.go @@ -266,7 +266,7 @@ func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, t if len(cfg.AllowRepos) > 0 && !cfg.AllowRepos[repo] { return false, nil } - if s.cfg.SkipAuthors[dialect.NormalizeBotName(strings.ToLower(pr.Author))] { + if cfg.SkipAuthors[dialect.NormalizeBotName(strings.ToLower(pr.Author))] { return false, nil } if cfg.SkipsReview(pr.Body) { diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index 95703ace..c1b64fed 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -864,6 +864,15 @@ func (s *Service) pushWaitDeadline(ctx context.Context, repo string, pr int, hea } } +// inflightTimeout is the in-flight window in force: the fleet's when the caller +// decided from a fleet configuration, this host's when it had none. +func (s *Service) inflightTimeout(cfg *Config) time.Duration { + if cfg != nil { + return cfg.InflightTimeout + } + return s.cfg.InflightTimeout +} + // completeWaitRound ends the wait by completing the fired/reviewing round. The // completed round remains as the "this head was reviewed" dedup marker, so a // subsequent enqueue/needsReview at the same head is deduped rather than re-fired. @@ -898,9 +907,12 @@ func (s *Service) completeWaitRound(ctx context.Context, repo string, pr int, he // advance that follows archives this round, and the slot would be // released with the command it was taken for still unanswered. So // record the hold on the slot itself, where it survives the supersede. - // Bounded by the in-flight window, the deadline Progress gives up at. + // Bounded by the in-flight window, the deadline Progress gives up at — + // the FLEET's, revalidated just above, not this host's. A host whose + // local value is shorter would otherwise drop the hold while Progress + // is still waiting on the command, letting another metered review fire. if r.FiredAt != nil { - until := r.FiredAt.UTC().Add(s.cfg.InflightTimeout) + until := r.FiredAt.UTC().Add(s.inflightTimeout(cfg)) if until.After(s.clock()) && (st.FireSlot.HoldUntil == nil || st.FireSlot.HoldUntil.Before(until)) { st.HoldSlotUntil(until) changed = true diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index 385d0a4c..31853426 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -2401,6 +2401,45 @@ func TestCompleteWaitRoundHoldsAnUnacknowledgedFireSlot(t *testing.T) { } } +// The hold is bounded by the in-flight window Progress gives up at, and that +// window is fleet policy. A host whose own value is shorter would otherwise +// stamp a hold that lapses while Progress is still waiting on the command, +// letting the next pull request spend the allowance alongside it. +func TestHeldFireSlotUsesTheFleetsInflightTimeout(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.InflightTimeout = time.Minute // this host's own, shorter, answer + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + now := time.Now().UTC() + repo, pr, head := "o/r", 6, "aaaaaaaa1" + seedRound(t, store, cfg, repo, pr, head, PhaseFired, now, 12) + if _, err := store.Update(ctx, func(st *State) error { + st.SetFleetValue("inflight-timeout", "2h") + return nil + }); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + fleet := svc.cfgFor(st, repo) + + svc.completeWaitRound(ctx, repo, pr, head, true, &fleet) + st, _, err = store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if st.FireSlot == nil || st.FireSlot.HoldUntil == nil { + t.Fatalf("the slot must be held for the unanswered command, got %+v", st.FireSlot) + } + want := now.Add(2 * time.Hour) + if !st.FireSlot.HoldUntil.Equal(want) { + t.Fatalf("hold until = %s, want the fleet's window %s", st.FireSlot.HoldUntil, want) + } +} + // Converging is the loop's signal to push, and the push supersedes the round the // held slot points at. Leaving the round fired is therefore not enough on its // own: the replacement round archives its predecessor, Normalize drops a slot no diff --git a/internal/crq/fleetcmd.go b/internal/crq/fleetcmd.go index fa9cabbe..fa4689a4 100644 --- a/internal/crq/fleetcmd.go +++ b/internal/crq/fleetcmd.go @@ -26,11 +26,19 @@ type FleetSetting struct { // Error is why a recorded value was not applied, when this binary could not // read it. Error string `json:"error,omitempty"` + // Unknown marks a setting the fleet records that this binary has no notion + // of — written by a newer crq. Nothing is in force for it here, so Value and + // Env are empty and only the key is worth reporting. + Unknown bool `json:"unknown,omitempty"` // Lagging names active queue drivers that cannot enforce fleet policy. Lagging []string `json:"lagging_hosts,omitempty"` } -// FleetConfig reports every fleet setting, in force and as this host has it. +// FleetConfig reports every fleet setting, in force and as this host has it — +// including the ones only a newer crq knows. Reporting just the keys this binary +// understands would hide exactly the case that matters: a recorded setting this +// process silently ignores, while `crq config` and `crq doctor` call the host +// fully in step with the fleet. func (s *Service) FleetConfig(ctx context.Context) ([]FleetSetting, error) { st, _, err := s.store.Load(ctx) if err != nil { @@ -57,6 +65,18 @@ func (s *Service) FleetConfig(ctx context.Context) ([]FleetSetting, error) { } out = append(out, item) } + for _, key := range st.FleetKeys() { + if _, known := settings[key]; known { + continue + } + out = append(out, FleetSetting{ + Key: key, + Unknown: true, + Source: "host", + Error: "not a setting this crq understands; it is being ignored on this host", + Lagging: lagging, + }) + } return out, nil } @@ -88,9 +108,15 @@ func (s *Service) SetFleetConfig(ctx context.Context, key, value string) error { // UnsetFleetConfig returns one setting to whatever each host says, reporting // whether the fleet had an opinion to drop. +// +// A key this binary does not know is refused only when the fleet has not +// recorded it either. Dropping one it HAS recorded is the remedy `crq doctor` +// names for a setting a newer crq wrote and this host ignores, and refusing it +// would leave that key unremovable from every host but the newest. func (s *Service) UnsetFleetConfig(ctx context.Context, key string) (bool, error) { - if _, ok := fleetSettings()[key]; !ok { - return false, fmt.Errorf("unknown setting %q", key) + known := false + if _, ok := fleetSettings()[key]; ok { + known = true } open, err := s.prepareFleetReviewerChange(ctx, key) if err != nil { @@ -98,6 +124,9 @@ func (s *Service) UnsetFleetConfig(ctx context.Context, key string) (bool, error } dropped := false _, err = s.updateFleet(ctx, func(st *State) error { + if _, recorded := st.FleetValue(key); !known && !recorded { + return fmt.Errorf("unknown setting %q", key) + } if err := s.requireFleetCapableDrivers(st); err != nil { return err } @@ -210,6 +239,9 @@ func (s *Service) FleetDivergence(ctx context.Context) ([]string, error) { var out []string for _, item := range items { switch { + case item.Unknown: + out = append(out, fmt.Sprintf("%s is recorded for the fleet but is not a setting this crq understands; upgrade crq on this host or run crq config unset %s", + item.Key, item.Key)) case item.Error != "": out = append(out, fmt.Sprintf("%s: the fleet's value is one this crq cannot read (%s); using this host's %q", item.Key, item.Error, item.Value)) diff --git a/internal/crq/fleetconfig.go b/internal/crq/fleetconfig.go index 8cb0a303..56084754 100644 --- a/internal/crq/fleetconfig.go +++ b/internal/crq/fleetconfig.go @@ -56,7 +56,7 @@ func fleetSettings() map[string]fleetSetting { cfg.AllowRepos = repos return err }, - Show: func(cfg Config) string { return strings.Join(sortedRepoKeys(cfg.AllowRepos), ",") }, + Show: func(cfg Config) string { return strings.Join(sortedSetKeys(cfg.AllowRepos), ",") }, }, "exclude": { Doc: "repositories crq never reviews, watches or fixes", @@ -66,7 +66,7 @@ func fleetSettings() map[string]fleetSetting { cfg.ExcludeRepos = repos return err }, - Show: func(cfg Config) string { return strings.Join(sortedRepoKeys(cfg.ExcludeRepos), ",") }, + Show: func(cfg Config) string { return strings.Join(sortedSetKeys(cfg.ExcludeRepos), ",") }, }, "required-bots": { Doc: "logins that must review a head before a round converges", @@ -139,6 +139,15 @@ func fleetSettings() map[string]fleetSetting { }, Show: func(cfg Config) string { return cfg.SkipMarker }, }, + "skip-authors": { + Doc: "PR authors autoreview never enqueues (empty reviews every author)", + Env: "CRQ_AUTOREVIEW_SKIP_AUTHORS", + Apply: func(cfg *Config, v string) error { + cfg.SkipAuthors = authorSet(v) + return nil + }, + Show: func(cfg Config) string { return strings.Join(sortedSetKeys(cfg.SkipAuthors), ",") }, + }, } } @@ -246,7 +255,7 @@ func sortedKeys(m map[string]string) []string { return out } -func sortedRepoKeys(m map[string]bool) []string { +func sortedSetKeys(m map[string]bool) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) diff --git a/internal/crq/fleetconfig_test.go b/internal/crq/fleetconfig_test.go index ec1439e5..5be5aa82 100644 --- a/internal/crq/fleetconfig_test.go +++ b/internal/crq/fleetconfig_test.go @@ -564,3 +564,51 @@ func TestInaccessibleRepoLookupClassification(t *testing.T) { }) } } + +// A key only a newer crq knows is the one divergence this host cannot act on: +// applyFleet ignores it, and reporting only the settings this binary understands +// would have `crq config` and `crq doctor` both call the host fully in step +// while it silently drops part of the shared policy. +func TestFleetConfigReportsSettingsOnlyANewerCrqKnows(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.ExplicitFleetEnv = map[string]bool{} + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.SetFleetValue("some-future-knob", "7") + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, newFakeGitHub(), store, nil) + + items, err := svc.FleetConfig(ctx) + if err != nil { + t.Fatal(err) + } + var reported *FleetSetting + for i, item := range items { + if item.Key == "some-future-knob" { + reported = &items[i] + } + } + if reported == nil || !reported.Unknown || reported.Error == "" { + t.Fatalf("crq config must report the recorded key it ignores, got %+v", items) + } + diverged, err := svc.FleetDivergence(ctx) + if err != nil { + t.Fatal(err) + } + if len(diverged) != 1 || !strings.Contains(diverged[0], "some-future-knob") { + t.Fatalf("doctor divergence = %v, want the ignored setting named", diverged) + } + + // And the remedy doctor names has to work: refusing to unset a key this + // binary does not know would leave it unremovable from every older host. + if dropped, err := svc.UnsetFleetConfig(ctx, "some-future-knob"); err != nil || !dropped { + t.Fatalf("unset = %v %v, want the recorded key dropped", dropped, err) + } + if _, err := svc.UnsetFleetConfig(ctx, "never-recorded"); err == nil { + t.Fatal("a key that is neither known nor recorded is still a typo") + } +} diff --git a/internal/crq/repoconfig_test.go b/internal/crq/repoconfig_test.go index b9b8485b..b253de57 100644 --- a/internal/crq/repoconfig_test.go +++ b/internal/crq/repoconfig_test.go @@ -865,6 +865,57 @@ func TestCompletionIsRevalidatedInsideItsWrite(t *testing.T) { } } +// A retry is policy-dependent too: its RetryAt and account block are computed +// from the fleet's rate-limit fallback, so a widened window landing in the same +// decision/write gap would be persisted at the old, earlier deadline — and the +// account unblocked in time for another metered review the fleet meant to defer. +func TestRetryIsRevalidatedInsideItsWrite(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + hooked := &hookedStore{StateStore: store} + svc := NewService(cfg, newFakeGitHub(), hooked, nil) + now := time.Now().UTC() + repo, pr, head := "o/r", 14, "aaaaaaaa1" + seedRound(t, store, cfg, repo, pr, head, PhaseFired, now, 22) + + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + stale := svc.cfgFor(st, repo) + hooked.hook = func() { + if _, err := store.Update(ctx, func(st *State) error { + st.SetFleetValue("rate-limit-fallback", "2h") + return nil + }); err != nil { + t.Error(err) + } + } + until := now.Add(15 * time.Minute) + retry := engine.Transition{ + Outcome: engine.OutRetry, + Reason: dialect.ReasonRateLimited, + RetryAt: until, + Blocked: &engine.AccountBlock{Until: until, CommentID: 99, CommentUpdated: now}, + } + if _, err := hooked.Update(ctx, func(st *State) error { + return svc.applyTransition(st, st.Round(repo, pr), retry, now, stale) + }); err != nil { + t.Fatal(err) + } + if got := roundPhase(t, store, repo, pr); got != PhaseFired { + t.Fatalf("phase = %s, want the stale retry dropped so it is recomputed under the fleet's window", got) + } + st, _, err = store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if st.Account.BlockedUntil != nil { + t.Fatalf("the block computed from the old window must not be recorded, got %v", st.Account.BlockedUntil) + } +} + // Feedback and its completion write have the same decision/write window as // Progress. A reviewer override landing in that window must leave the in-flight // round open so the next observation can ask the newly required reviewer. diff --git a/internal/crq/service.go b/internal/crq/service.go index 80cb9c29..59c07671 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -856,17 +856,26 @@ func (s *Service) progressSlotRound(ctx context.Context, slot Round) (PumpResult // where the state it reads is the state the write lands on. func (s *Service) applyTransition(st *State, r *Round, tr engine.Transition, now time.Time, cfg Config) error { key := QueueKey(r.Repo, r.PR) + // Completion and retry are the policy-dependent outcomes, so both are dropped + // when the policy moved under them. The completed round is the "this head was + // reviewed" dedup marker, and reopenForChangedReviewers deliberately leaves an + // in-flight round alone — it is already going to answer — so a reviewer change + // committing between the decision and this write would be answered by neither: + // the marker dedupes the head under the set that no longer gates it. A retry + // carries a RetryAt and an account block computed from the fleet's fallback + // and backoff windows, so a widened window would be persisted at the old, + // earlier deadline and let another metered review fire inside it. Dropping is + // safe either way: the round keeps the slot and the next pump decides again + // under the current policy. The other outcomes record facts — an ack, a closed + // PR, a reservation that never posted — that no policy change revises. switch tr.Outcome { - case engine.OutComplete: - // The completed round is the "this head was reviewed" dedup marker, and - // reopenForChangedReviewers deliberately leaves an in-flight round alone — - // it is already going to answer. A reviewer change that commits between - // the decision and this write would therefore be answered by neither: the - // marker dedupes the head under the set that no longer gates it. Drop the - // stale transition; the next pump decides again under the new set. + case engine.OutComplete, engine.OutRetry: if overrideChanged(st, r.Repo, cfg) { return ErrNoChange } + } + switch tr.Outcome { + case engine.OutComplete: if err := r.Complete(); err != nil { return err } diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index cc861cf0..76f2be05 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -627,6 +627,61 @@ func TestAutoReviewScanSkipsConfiguredAuthors(t *testing.T) { } } +// Who autoreview skips is fleet policy for the same reason the body marker is: +// leadership moves between hosts, and a Dependabot PR skipped on the machine +// that held the lease yesterday must not spend the shared allowance on the one +// that holds it today. +func TestAutoReviewScanSkipsTheFleetsAuthors(t *testing.T) { + ctx := context.Background() + cfg := Config{ + GateRepo: "o/gate", + Scope: []string{"o"}, + Host: "h", + Bot: "coderabbitai[bot]", + ReviewCommand: "@coderabbitai review", + LeaderTTL: time.Minute, + AutoReviewMaxScan: 10, + SkipAuthors: authorSet("renovate[bot]"), + } + gh := newFakeGitHub() + gh.searchPRs = []ghapi.SearchPR{ + {Repo: "o/app", Number: 1, Author: "Dependabot"}, + {Repo: "o/app", Number: 2, Author: "renovate[bot]"}, + {Repo: "o/app", Number: 3, Author: "alice"}, + } + for pr := 1; pr <= 3; pr++ { + var pull ghapi.Pull + pull.State = "open" + pull.Head.SHA = "abcdef1234567890" + gh.pulls[fakeKey("o/app", pr)] = pull + } + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.SetFleetValue("skip-authors", "dependabot[bot]") + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, gh, store, nil) + + if err := svc.AutoReview(ctx, AutoOptions{Once: true, Incremental: true}); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if st.Round("o/app", 1) != nil { + t.Fatalf("the fleet's skipped author must never be queued, got %#v", st.Round("o/app", 1)) + } + if st.Round("o/app", 2) == nil { + t.Fatal("the fleet's list replaces this host's, so its author is reviewed again") + } + if st.Round("o/app", 3) == nil { + t.Fatalf("a human-authored PR must still be enqueued, got rounds=%#v", st.Rounds) + } +} + func TestAutoReviewScanSkipsMarkedPRs(t *testing.T) { ctx := context.Background() cfg := Config{ From fc440569fe7e80746b5db7a40f6b4d32596b5263 Mon Sep 17 00:00:00 2001 From: kristofferR <481270+kristofferR@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:52:04 +0200 Subject: [PATCH 26/37] Put the co-reviewer policy in the fleet registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Which co-reviewers run, how crq drives them, and whether an account-blocked round degrades to them were still per-host answers, so two queue drivers with different CRQ_COBOTS or CRQ_RL_CO_DEGRADE could trigger different bots, read different evidence, and disagree about waiting out a block while sharing one state ref. Record them: cobots, rate-limit-co-degrade, and one family per registry co-reviewer (trigger, cmd, grace). Required-ness stays with required-bots, which already owns that list. A change to any of them rebuilds the derived reviewer views and reconciles existing rounds the same way a required-bots change does, so a newly enabled co-reviewer requeues the heads it never saw. Also render dashboard.md with the effective configuration. It is rewritten on every state commit, and rendering it from the writing host's startup settings put that machine's co-reviewer set in the shared file while the gate issue showed the fleet's — flapping between differently configured hosts. And trim the fleet min-interval where state parses it: `crq config set` accepts a padded duration, so queue decisions honoured " 5m " while the dashboard and status line silently fell back to the host's interval. --- README.md | 6 +- cmd/crq/main.go | 6 +- internal/crq/fleetcmd.go | 26 ++- internal/crq/fleetconfig.go | 250 +++++++++++++++++++++++++- internal/crq/fleetconfig_test.go | 117 ++++++++++++ internal/crq/state.go | 12 +- internal/state/dashboard_sync_test.go | 58 ++++++ internal/state/dashboard_test.go | 7 + internal/state/fleet.go | 7 +- internal/state/store.go | 26 ++- 10 files changed, 498 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 5b33e18b..676c19ee 100644 --- a/README.md +++ b/README.md @@ -491,8 +491,10 @@ Set these in `~/.config/crq/env` (sourced automatically) or as environment varia ### One configuration for the fleet, not one per machine These settings belong to the whole fleet and live in the state ref, not in the table below: -`scope`, `repos`, `exclude`, `required-bots`, `min-interval`, `inflight-timeout`, -`rate-limit-fallback`, `calibrate-ttl`, `settle`, `skip-marker`, `skip-authors`. +`scope`, `repos`, `exclude`, `required-bots`, `cobots`, `rate-limit-co-degrade`, +`min-interval`, `inflight-timeout`, `rate-limit-fallback`, `calibrate-ttl`, `settle`, +`skip-marker`, `skip-authors`, and one family per co-reviewer — +`cobot--trigger`, `cobot--cmd`, `cobot--grace`. ```bash crq config # what is in force, and where it came from diff --git a/cmd/crq/main.go b/cmd/crq/main.go index 3912b863..16e451c5 100644 --- a/cmd/crq/main.go +++ b/cmd/crq/main.go @@ -1123,8 +1123,10 @@ window one host respects and another does not — and nothing says so, because each host is behaving correctly according to what it can see. These settings have one answer for the fleet: - scope, repos, exclude, required-bots, min-interval, inflight-timeout, - rate-limit-fallback, calibrate-ttl, settle, skip-marker, skip-authors + scope, repos, exclude, required-bots, cobots, rate-limit-co-degrade, + min-interval, inflight-timeout, rate-limit-fallback, calibrate-ttl, settle, + skip-marker, skip-authors, and per co-reviewer: cobot--trigger, + cobot--cmd, cobot--grace Three kinds of setting deliberately stay local: where the state lives (CRQ_REPO, CRQ_ISSUE, CRQ_STATE_REF — a host cannot read fleet policy until it diff --git a/internal/crq/fleetcmd.go b/internal/crq/fleetcmd.go index fa4689a4..b615ccb3 100644 --- a/internal/crq/fleetcmd.go +++ b/internal/crq/fleetcmd.go @@ -161,7 +161,7 @@ func (s *Service) SeedFleetConfig(ctx context.Context) ([]string, error) { return nil, err } var open map[string]map[int]bool - if _, ok := initial.FleetValue("required-bots"); !ok { + if seedsReviewers(initial) { open, err = s.openFleetPRs(ctx, initial) if err != nil { return nil, err @@ -261,7 +261,7 @@ func (s *Service) requireFleetCapableDrivers(st *State) error { } func (s *Service) prepareFleetReviewerChange(ctx context.Context, key string) (map[string]map[int]bool, error) { - if key != "required-bots" { + if !isReviewerFleetKey(key) { return nil, nil } st, _, err := s.store.Load(ctx) @@ -271,6 +271,21 @@ func (s *Service) prepareFleetReviewerChange(ctx context.Context, key string) (m return s.openFleetPRs(ctx, st) } +// seedsReviewers reports whether seeding would record a reviewer setting the +// fleet has no answer for yet, which is the case where the effective reviewers +// can move and existing rounds have to be reconciled against them. +func seedsReviewers(st State) bool { + for _, key := range FleetKeys() { + if !isReviewerFleetKey(key) { + continue + } + if _, ok := st.FleetValue(key); !ok { + return true + } + } + return false +} + func (s *Service) openFleetPRs(ctx context.Context, st State) (map[string]map[int]bool, error) { repos := map[string]bool{} for _, round := range st.Rounds { @@ -346,10 +361,11 @@ func sameFoldedSet(a, b []string) bool { return true } +// reopenForFleetReviewerChange requeues what a fleet reviewer change invalidates. +// Which rounds those are is decided per repository, against that repository's +// override — the fleet's required set is only half of the effective one, and a +// co-reviewer the fleet enables or disables moves it just as much. func (s *Service) reopenForFleetReviewerChange(st *State, before, after Config, open map[string]map[int]bool) { - if sameLogins(before.RequiredBots, after.RequiredBots) { - return - } repos := map[string]bool{} for _, round := range st.Rounds { repos[NormalizeRepo(round.Repo)] = true diff --git a/internal/crq/fleetconfig.go b/internal/crq/fleetconfig.go index 56084754..0c68e4de 100644 --- a/internal/crq/fleetconfig.go +++ b/internal/crq/fleetconfig.go @@ -3,8 +3,12 @@ package crq import ( "fmt" "sort" + "strconv" "strings" "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/engine" ) // fleetSetting is one policy the whole fleet shares: how to read it from the @@ -38,7 +42,7 @@ type fleetSetting struct { // disagree about is how a repository ends up excluded on one and reviewed by // another with nothing to say so. func fleetSettings() map[string]fleetSetting { - return map[string]fleetSetting{ + settings := map[string]fleetSetting{ "scope": { Doc: "owners crq scans when no repository list is set", Env: "CRQ_SCOPE", @@ -148,9 +152,207 @@ func fleetSettings() map[string]fleetSetting { }, Show: func(cfg Config) string { return strings.Join(sortedSetKeys(cfg.SkipAuthors), ",") }, }, + "cobots": { + Doc: "co-reviewers crq surfaces and triggers (empty disables all)", + Env: "CRQ_COBOTS", + Apply: func(cfg *Config, v string) error { + names, err := fleetCoBotNames(v) + if err != nil { + return err + } + *cfg = cfg.ForRepo(RepoReviewers{CoBots: names, SetCoBots: true}) + return nil + }, + Show: func(cfg Config) string { + names := make([]string, 0, len(cfg.CoBots)) + for _, cb := range cfg.CoBots { + names = append(names, cb.Name) + } + return strings.Join(names, ",") + }, + }, + "rate-limit-co-degrade": { + Doc: "run co-reviewer-only rounds while the CodeRabbit account is blocked", + Env: "CRQ_RL_CO_DEGRADE", + Apply: func(cfg *Config, v string) error { + on, err := parseFleetBool(v) + if err != nil { + return err + } + cfg.RateLimitCoDegrade = on + return nil + }, + Show: func(cfg Config) string { return fleetBool(cfg.RateLimitCoDegrade) }, + }, + } + // Every co-reviewer's own policy, one family per bot. Nothing here names a + // bot: the registry is what says which exist, so a new co-reviewer arrives + // with its fleet settings already in place. + for _, co := range dialect.KnownCoReviewers() { + for key, setting := range coBotFleetSettings(co) { + settings[key] = setting + } + } + return settings +} + +// coBotFleetSettings is how the fleet drives one co-reviewer: whether crq posts +// its command, what it posts, and how long a self-heal nudge waits first. +// +// Required-ness is deliberately NOT here — required-bots owns that list, and two +// settings answering the same question is how they end up disagreeing. +func coBotFleetSettings(co dialect.CoReviewer) map[string]fleetSetting { + name := co.Name + env := "CRQ_COBOT_" + strings.ToUpper(name) + return map[string]fleetSetting{ + "cobot-" + name + "-trigger": { + Doc: "when crq posts " + name + "'s command: never, selfheal or always (empty leaves it to how the bot is required)", + Env: env + "_TRIGGER", + Apply: func(cfg *Config, v string) error { + value := strings.ToLower(strings.TrimSpace(v)) + if value == "" { + // The environment's "unset" — the registry default for how the + // bot is required, recomputed rather than frozen. + updateCoBot(cfg, name, func(cb *CoBotConfig) { cb.TriggerExplicit = false }) + return nil + } + mode := engine.TriggerMode(value) + switch mode { + case engine.TriggerNever, engine.TriggerSelfHeal, engine.TriggerAlways: + default: + return fmt.Errorf("trigger must be never, selfheal or always, got %q", v) + } + updateCoBot(cfg, name, func(cb *CoBotConfig) { + cb.Trigger, cb.TriggerExplicit = mode, true + }) + return nil + }, + Show: func(cfg Config) string { + if cb := coBotOf(cfg, name); cb.TriggerExplicit { + return string(cb.Trigger) + } + return "" + }, + }, + "cobot-" + name + "-cmd": { + Doc: "the comment that triggers " + name + " (empty means crq never posts one)", + Env: env + "_CMD", + Apply: func(cfg *Config, v string) error { + command := strings.TrimSpace(v) + updateCoBot(cfg, name, func(cb *CoBotConfig) { cb.Command = command }) + return nil + }, + Show: func(cfg Config) string { return coBotOf(cfg, name).Command }, + }, + "cobot-" + name + "-grace": { + Doc: "how long a selfheal trigger waits for " + name + " to show up on its own", + Env: env + "_GRACE", + Apply: func(cfg *Config, v string) error { + d, err := parseFleetDuration(v) + if err != nil { + return err + } + updateCoBot(cfg, name, func(cb *CoBotConfig) { cb.SelfHealGrace = d }) + return nil + }, + Show: func(cfg Config) string { return coBotOf(cfg, name).SelfHealGrace.String() }, + }, } } +// coBotOf is how this host currently drives a co-reviewer: its enabled entry +// when it has one, otherwise what its environment resolved for a bot the fleet +// leaves disabled — which is still the configuration a repository override +// would pick it up with. +func coBotOf(cfg Config, name string) CoBotConfig { + for _, cb := range cfg.CoBots { + if strings.EqualFold(cb.Name, name) { + return cb + } + } + cb, _ := cfg.knownCoBot(name) + return cb +} + +// updateCoBot changes one co-reviewer wherever its configuration is held: the +// enabled set crq drives, and the registry-wide set a per-repo override draws +// from for a bot the fleet does not enable. Both are copied first — the caller's +// Config shares these slices, and a setting that fails to parse must leave it +// untouched. +func updateCoBot(cfg *Config, name string, mutate func(*CoBotConfig)) { + cfg.CoBots = updateCoBotIn(cfg.CoBots, name, mutate) + // The registry-wide set is what a bot this host never resolved is enabled + // from — by the fleet's own cobots setting, or by a repository override — so + // it has to hold the policy even when there is no entry to change yet. + // Without this the fleet's answer would be silently dropped, which is the + // failure the shared registry exists to prevent. + if !hasCoBot(cfg.KnownCoBots, name) { + if co, ok := dialect.CoReviewerByName(name); ok { + cfg.KnownCoBots = append(append([]CoBotConfig(nil), cfg.KnownCoBots...), defaultCoBot(co, false)) + } + } + cfg.KnownCoBots = updateCoBotIn(cfg.KnownCoBots, name, mutate) +} + +func hasCoBot(bots []CoBotConfig, name string) bool { + for _, cb := range bots { + if strings.EqualFold(cb.Name, name) { + return true + } + } + return false +} + +func updateCoBotIn(bots []CoBotConfig, name string, mutate func(*CoBotConfig)) []CoBotConfig { + out := append([]CoBotConfig(nil), bots...) + for i, cb := range out { + if !strings.EqualFold(cb.Name, name) { + continue + } + mutate(&cb) + // Re-derive what the change implies, exactly as the environment parse + // does: an implicit trigger follows the registry default for how the bot + // is required, and no command means crq can never post one. + cb = reconcileTrigger(cb) + if cb.Command == "" { + cb.Trigger = engine.TriggerNever + } + out[i] = cb + } + return out +} + +// fleetCoBotNames resolves a recorded co-reviewer list to registry names, +// refusing one this binary does not know rather than skipping it: a silently +// dropped bot looks exactly like one that is simply never triggered. +func fleetCoBotNames(value string) ([]string, error) { + var names, unknown []string + for _, item := range splitList(value) { + co, ok := dialect.CoReviewerByName(item) + if !ok { + unknown = append(unknown, item) + continue + } + names = append(names, co.Name) + } + if len(unknown) > 0 { + known := make([]string, 0, 3) + for _, co := range dialect.KnownCoReviewers() { + known = append(known, co.Name) + } + return nil, fmt.Errorf("unknown co-reviewer %s (known: %s)", + strings.Join(unknown, ", "), strings.Join(known, ", ")) + } + return names, nil +} + +// isReviewerFleetKey reports whether a setting reshapes who reviews, or how a +// co-reviewer is driven. Those are the ones whose derived views have to be +// rebuilt, and whose changes existing rounds may have to be reconciled against. +func isReviewerFleetKey(key string) bool { + return key == "required-bots" || key == "cobots" || strings.HasPrefix(key, "cobot-") +} + // FleetKeys lists every setting the fleet owns, in a stable order. func FleetKeys() []string { settings := fleetSettings() @@ -176,6 +378,30 @@ func parseFleetDuration(v string) (time.Duration, error) { return d, nil } +// parseFleetBool reads the spellings the environment already accepts, so a +// seeded value and a hand-typed one mean the same thing. +func parseFleetBool(v string) (bool, error) { + switch value := strings.ToLower(strings.TrimSpace(v)); value { + case "on": + return true, nil + case "off": + return false, nil + default: + b, err := strconv.ParseBool(value) + if err != nil { + return false, fmt.Errorf("not a switch (try 1 or 0): %w", err) + } + return b, nil + } +} + +func fleetBool(on bool) string { + if on { + return "1" + } + return "0" +} + // ValidateFleetSetting reports whether key is a fleet setting and value is one // it can hold. Set fails on a bad value rather than writing it: the state ref // is shared, so a value only one host can parse is a value that breaks the @@ -228,10 +454,15 @@ func applyFleet(cfg Config, fleet map[string]string, warn func(string)) Config { } cfg = candidate } - // RequiredBots is the input to the derived reviewer views. Applying it as a - // scalar without rebuilding those views can leave a required co-reviewer - // disabled or carrying its optional trigger mode. - if _, ok := fleet["required-bots"]; ok { + // The reviewer settings are the inputs to the derived reviewer views. + // Applying one as a scalar without rebuilding those views can leave a + // required co-reviewer disabled, carrying its optional trigger mode, or + // still driven by the command this host was started with. + if touchesReviewers(fleet) { + // A primary that is also a registry bot is triggered as the primary; + // asking it twice is the bug, and a fleet trigger for it must not undo + // the silencing LoadConfig applied. + cfg.CoBots = silenceTrigger(cfg.CoBots, cfg.Bot) enabled := make([]string, 0, len(cfg.CoBots)) for _, cb := range cfg.CoBots { enabled = append(enabled, cb.Login) @@ -246,6 +477,15 @@ func applyFleet(cfg Config, fleet map[string]string, warn func(string)) Config { return cfg } +func touchesReviewers(fleet map[string]string) bool { + for key := range fleet { + if isReviewerFleetKey(key) { + return true + } + } + return false +} + func sortedKeys(m map[string]string) []string { out := make([]string, 0, len(m)) for k := range m { diff --git a/internal/crq/fleetconfig_test.go b/internal/crq/fleetconfig_test.go index 5be5aa82..dbfd3189 100644 --- a/internal/crq/fleetconfig_test.go +++ b/internal/crq/fleetconfig_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/engine" ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) @@ -167,6 +168,122 @@ func TestFleetRequiredBotsRebuildsDerivedReviewers(t *testing.T) { } } +// Which co-reviewers run, how crq drives them, and whether an account-blocked +// round degrades to them are decisions, not machine facts. Two hosts answering +// them differently is the same divergence as one excluding a repository the +// other reviews: both behave correctly, and the PRs disagree. +func TestFleetOwnsTheCoReviewerPolicy(t *testing.T) { + ctx := context.Background() + cfg := isolatedConfig(t, map[string]string{ + "CRQ_COBOTS": "codex", + "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", + "CRQ_COBOT_BUGBOT_CMD": "bugbot run", + "CRQ_COBOT_BUGBOT_GRACE": "10m", + "CRQ_COBOT_CODEX_TRIGGER": "always", + "CRQ_RL_CO_DEGRADE": "0", + }) + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + for _, set := range []struct{ key, value string }{ + {"cobots", "bugbot"}, + {"cobot-bugbot-trigger", "always"}, + {"cobot-bugbot-cmd", "bugbot run now"}, + {"cobot-bugbot-grace", "30m"}, + {"rate-limit-co-degrade", "1"}, + } { + if err := svc.SetFleetConfig(ctx, set.key, set.value); err != nil { + t.Fatalf("set %s: %v", set.key, err) + } + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + got := svc.fleetCfg(st) + + if len(got.CoBots) != 1 || got.CoBots[0].Name != "bugbot" { + t.Fatalf("CoBots = %+v, want only the fleet's bugbot", got.CoBots) + } + bugbot := got.CoBots[0] + if bugbot.Trigger != engine.TriggerAlways || bugbot.Command != "bugbot run now" || bugbot.SelfHealGrace != 30*time.Minute { + t.Errorf("bugbot = %+v, want the fleet's trigger, command and grace", bugbot) + } + if !got.RateLimitCoDegrade { + t.Error("the fleet turned co-reviewer degradation on and this host stayed off") + } + // The derived views have to follow, or which co-reviewer runs depends on + // which view crq happens to ask. + var seen bool + for _, r := range got.Reviewers { + if !sameBot(r.Login, bugbot.Login) { + continue + } + seen = true + if r.Trigger != engine.TriggerAlways || r.Command != "bugbot run now" { + t.Errorf("reviewer %+v disagrees with the fleet's co-reviewer policy", r) + } + } + if !seen { + t.Errorf("Reviewers = %+v, want the fleet's co-reviewer among them", got.Reviewers) + } +} + +// A value only some hosts can read breaks the fleet from the inside, so the +// co-reviewer settings are refused where they are set like every other one. +func TestFleetRefusesCoReviewerPolicyItCannotRead(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) + + for _, bad := range []struct{ key, value string }{ + {"cobots", "codex,nosuchbot"}, + {"cobot-codex-trigger", "sometimes"}, + {"cobot-codex-grace", "-5m"}, + {"rate-limit-co-degrade", "maybe"}, + } { + if err := svc.SetFleetConfig(ctx, bad.key, bad.value); err == nil { + t.Errorf("%s accepted %q", bad.key, bad.value) + } + } + // Explicitly none is a real answer, not a malformed one. + if err := svc.SetFleetConfig(ctx, "cobots", ""); err != nil { + t.Errorf("cobots refused an empty set: %v", err) + } +} + +// Enabling a co-reviewer fleet-wide is a reviewer change like requiring one: +// the heads already reviewed were reviewed without it. +func TestFleetCoBotChangeReopensCompletedRounds(t *testing.T) { + ctx := context.Background() + cfg := isolatedConfig(t, map[string]string{ + "CRQ_COBOTS": "codex", + "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", + }) + repo, pr := "owner/repo", 7 + gh := newFakeGitHub() + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.PutRound(Round{Repo: repo, PR: pr, Head: "abcdef123", Phase: PhaseCompleted}) + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, gh, store, nil) + if err := svc.SetFleetConfig(ctx, "cobots", "codex,bugbot"); err != nil { + t.Fatal(err) + } + st, _, _ := store.Load(ctx) + round := st.Round(repo, pr) + if round == nil || round.Phase != PhaseQueued { + t.Fatalf("round = %+v, want the completed round reopened for the new co-reviewer", round) + } + if !containsBot(round.ForceCoReviewers, dialect.BugbotLogin) { + t.Errorf("ForceCoReviewers = %v, want the newly enabled self-heal bot nudged once", round.ForceCoReviewers) + } +} + func TestFleetMutationsAreInertInDryRun(t *testing.T) { ctx := context.Background() cfg := firingConfig() diff --git a/internal/crq/state.go b/internal/crq/state.go index 5d470507..107b4ac8 100644 --- a/internal/crq/state.go +++ b/internal/crq/state.go @@ -97,8 +97,18 @@ func (c Config) coReviewerSummary() string { // NewGitStateStore builds the git-ref-backed store. The logger surfaces the // loud auto-reinit line when a stale-schema payload is loaded. +// +// The store renders dashboard.md on every state commit, and it renders it with +// the fleet's policy rather than this host's startup settings — otherwise the +// file in the state ref would carry whichever co-reviewer set the writing +// machine happened to be configured with, while the gate issue shows the +// fleet's. func NewGitStateStore(cfg Config, gh *ghapi.GitHub, log Logger) *crqstate.GitStateStore { - return crqstate.NewGitStateStore(cfg.storeConfig(), gh, log) + store := crqstate.NewGitStateStore(cfg.storeConfig(), gh, log) + store.SetRenderConfig(func(st State) StoreConfig { + return applyFleet(cfg, st.FleetConfig, nil).storeConfig() + }) + return store } func NewMemoryStore(cfg Config) *crqstate.MemoryStore { diff --git a/internal/state/dashboard_sync_test.go b/internal/state/dashboard_sync_test.go index d5f10dba..d96f4fda 100644 --- a/internal/state/dashboard_sync_test.go +++ b/internal/state/dashboard_sync_test.go @@ -188,3 +188,61 @@ func TestSyncDashboardUsesTheSuppliedEffectiveRenderingConfig(t *testing.T) { t.Fatalf("dashboard body did not use the effective reviewer summary:\n%s", srv.body) } } + +// dashboard.md is rewritten on EVERY state commit, so it has to be rendered +// with the same effective policy the gate issue is. Rendering it from the +// writing host's startup configuration would put that machine's co-reviewer set +// in the shared file — disagreeing with the issue, and flapping between hosts +// that are each configured differently. +func TestStateCommitsRenderTheDashboardFileWithTheEffectiveConfig(t *testing.T) { + var blobs []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + switch { + case strings.Contains(r.URL.Path, "/git/ref/"): + http.NotFound(w, r) // no state ref yet: the first write creates it + case strings.HasSuffix(r.URL.Path, "/git/blobs"): + var payload struct { + Content string `json:"content"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + blobs = append(blobs, payload.Content) + enc.Encode(map[string]string{"sha": "blobsha"}) + case strings.HasSuffix(r.URL.Path, "/git/trees"): + enc.Encode(map[string]string{"sha": "treesha"}) + case strings.HasSuffix(r.URL.Path, "/git/commits"): + enc.Encode(map[string]string{"sha": "commitsha"}) + case strings.HasSuffix(r.URL.Path, "/git/refs"): + enc.Encode(map[string]any{"object": map[string]string{"sha": "commitsha"}}) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + startup := StoreConfig{ + GateRepo: "owner/state", StateRef: "crq-state-v3", DashboardIssue: 1, + Scope: []string{"owner"}, CoReviewers: "codex (selfheal)", + } + store := NewGitStateStore(startup, gh.NewTestClient(srv.URL, srv.Client()), nil) + store.SetRenderConfig(func(State) StoreConfig { + effective := startup + effective.CoReviewers = "codex (required, always)" + return effective + }) + if _, err := store.Update(context.Background(), func(st *State) error { + _, err := st.NewRound("owner/repo", 7, "abcdef123", t0) + return err + }); err != nil { + t.Fatal(err) + } + + written := strings.Join(blobs, "\n") + if !strings.Contains(written, "codex (required, always)") || strings.Contains(written, "codex (selfheal)") { + t.Fatalf("the committed dashboard did not use the effective reviewer summary:\n%s", written) + } +} diff --git a/internal/state/dashboard_test.go b/internal/state/dashboard_test.go index bb319e75..17e50815 100644 --- a/internal/state/dashboard_test.go +++ b/internal/state/dashboard_test.go @@ -939,6 +939,13 @@ func TestStatusLine(t *testing.T) { if got := StatusLine(fleetPaced, StoreConfig{MinInterval: 0}); strings.Contains(got, "next #7") { t.Errorf("fleet pacing must keep status from claiming the round is ready: %q", got) } + // The recorded value is whatever an operator typed, and the parser that + // accepted it at `crq config set` trims. One that queue decisions honour must + // not leave this renderer quietly falling back to the host's interval. + fleetPaced.SetFleetValue("min-interval", " 1h ") + if got := StatusLine(fleetPaced, StoreConfig{MinInterval: 0}); strings.Contains(got, "next #7") { + t.Errorf("a padded fleet interval must pace status like an unpadded one: %q", got) + } // Blocked: the countdown is the useful part. blockedState := stateWith(queuedRound("kristofferr/a", 7, 1, now)) diff --git a/internal/state/fleet.go b/internal/state/fleet.go index 2629af32..1e15789f 100644 --- a/internal/state/fleet.go +++ b/internal/state/fleet.go @@ -2,6 +2,7 @@ package state import ( "sort" + "strings" "time" ) @@ -61,9 +62,13 @@ func (s *State) FleetKeys() []string { // withFleet applies the state-backed settings the state package itself renders. // The full policy is interpreted by crq; MinInterval also shapes queue // readiness in dashboards/status, including GitStateStore's background sync. +// +// Trimmed the way crq's own fleet parser trims, because the recorded value is +// whatever an operator typed: a stored " 5m " that queue decisions honour must +// not leave the dashboard and status line advertising this host's interval. func (c StoreConfig) withFleet(s State) StoreConfig { if value, ok := s.FleetValue("min-interval"); ok { - if interval, err := time.ParseDuration(value); err == nil && interval >= 0 { + if interval, err := time.ParseDuration(strings.TrimSpace(value)); err == nil && interval >= 0 { c.MinInterval = interval } } diff --git a/internal/state/store.go b/internal/state/store.go index 3c2d3b3f..31fb9d55 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -98,6 +98,15 @@ type GitStateStore struct { gh *gh.GitHub log Logger + // renderCfg resolves the configuration a dashboard render should use for a + // given state, defaulting to this process's own. crq replaces it with one + // that applies fleet policy: dashboard.md is rewritten on EVERY state + // commit, so rendering it from startup configuration would have each host + // write its own reviewer set into the shared file — flapping between + // machines, and disagreeing with the gate issue, which SyncDashboard already + // renders from the effective policy. + renderCfg func(State) StoreConfig + // syncMu serializes the read-then-write in SyncDashboard, so concurrent // syncs cannot both see a stale gate issue and both write it. syncMu sync.Mutex @@ -107,6 +116,21 @@ func NewGitStateStore(cfg StoreConfig, client *gh.GitHub, log Logger) *GitStateS return &GitStateStore{cfg: cfg, gh: client, log: log} } +// SetRenderConfig installs the resolver compareAndSwap renders dashboard.md +// with. It is set once, before the store is used: the state package holds no bot +// registry, so only crq can turn recorded fleet policy into a rendering +// configuration. +func (s *GitStateStore) SetRenderConfig(resolve func(State) StoreConfig) { + s.renderCfg = resolve +} + +func (s *GitStateStore) renderConfig(st State) StoreConfig { + if s.renderCfg == nil { + return s.cfg + } + return s.renderCfg(st) +} + func (s *GitStateStore) logf(format string, args ...any) { if s.log != nil { s.log.Printf(format, args...) @@ -228,7 +252,7 @@ func (s *GitStateStore) Update(ctx context.Context, mutate func(*State) error) ( } func (s *GitStateStore) compareAndSwap(ctx context.Context, st *State, rev Revision) error { - dashboard := RenderDashboard(*st, s.cfg) + dashboard := RenderDashboard(*st, s.renderConfig(*st)) st.DashboardSHA = hashString(dashboard) stateJSON, err := json.MarshalIndent(*st, "", " ") if err != nil { From dfabaf9b0a1b9b4f1a2d0a36b5e735c64a4b47e0 Mon Sep 17 00:00:00 2001 From: kristofferR <481270+kristofferR@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:28:02 +0200 Subject: [PATCH 27/37] Close fleet policy gaps in exclusion, seeding and feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three holes in the fleet registry, one per path: Excluding a repository while a fire is reserved left the reserved round untouched, and the firing goroutine went on to post the command it had already authorized — spending account quota on a repository crq was just told never to review. Refuse the exclusion while a trigger post is claimed, as a hold does: both are CAS writes, so either ordering is safe. Seeding compared reviewer policy against the seeding host's own values, which are the values being recorded — so the comparison could never see a change. Another host may have completed a head without a reviewer the seed makes fleet-required, and its completed round is the dedup marker that keeps the new bot from ever being triggered. Reconcile a seeded reviewer key against the fleet having had no reviewers at all. CRQ_FEEDBACK_BOTS decides whose findings are read, and so whether a head comes back as `fix` or clean, but had no entry in the registry: an explicit value kept winning per host. Record it as fleet policy, with an empty value meaning the derived set. --- README.md | 2 +- internal/crq/fleetcmd.go | 73 ++++++++++++++++-- internal/crq/fleetconfig.go | 35 ++++++++- internal/crq/fleetconfig_test.go | 127 +++++++++++++++++++++++++++++++ 4 files changed, 228 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 676c19ee..5fcefbc6 100644 --- a/README.md +++ b/README.md @@ -491,7 +491,7 @@ Set these in `~/.config/crq/env` (sourced automatically) or as environment varia ### One configuration for the fleet, not one per machine These settings belong to the whole fleet and live in the state ref, not in the table below: -`scope`, `repos`, `exclude`, `required-bots`, `cobots`, `rate-limit-co-degrade`, +`scope`, `repos`, `exclude`, `required-bots`, `cobots`, `feedback-bots`, `rate-limit-co-degrade`, `min-interval`, `inflight-timeout`, `rate-limit-fallback`, `calibrate-ttl`, `settle`, `skip-marker`, `skip-authors`, and one family per co-reviewer — `cobot--trigger`, `cobot--cmd`, `cobot--grace`. diff --git a/internal/crq/fleetcmd.go b/internal/crq/fleetcmd.go index b615ccb3..d65c2cfa 100644 --- a/internal/crq/fleetcmd.go +++ b/internal/crq/fleetcmd.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "sort" "strings" ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" @@ -100,8 +101,7 @@ func (s *Service) SetFleetConfig(ctx context.Context, key, value string) error { return ErrNoChange } st.SetFleetValue(key, value) - s.reconcileFleetChange(st, before, s.fleetCfg(*st), open) - return nil + return s.reconcileFleetChange(st, before, s.fleetCfg(*st), open) }) return err } @@ -135,8 +135,7 @@ func (s *Service) UnsetFleetConfig(ctx context.Context, key string) (bool, error if !dropped { return ErrNoChange } - s.reconcileFleetChange(st, before, s.fleetCfg(*st), open) - return nil + return s.reconcileFleetChange(st, before, s.fleetCfg(*st), open) }) return dropped, err } @@ -188,8 +187,7 @@ func (s *Service) SeedFleetConfig(ctx context.Context) ([]string, error) { if len(seeded) == 0 { return ErrNoChange } - s.reconcileFleetChange(st, before, s.fleetCfg(*st), open) - return nil + return s.reconcileFleetChange(st, seedBefore(before, seeded), s.fleetCfg(*st), open) }) return seeded, err } @@ -286,6 +284,32 @@ func seedsReviewers(st State) bool { return false } +// seedBefore is the before-state a seed has to be reconciled against. +// +// Seeding records THIS host's own values, so comparing the reviewer policy it +// writes against the policy this host was already using can only ever say +// "nothing changed". The fleet-wide before-state is the thing seeding exists to +// end: another host may have completed a head without a reviewer the seed now +// makes fleet-required, and that host's completed round is the dedup marker +// that would keep the newly required bot from ever being triggered. +// +// So when a seed records a reviewer key, treat the fleet's previous reviewer +// set as unknown — no required bot, no co-reviewer — and let the ordinary +// reviewer-change reconciliation requeue against it. It is conservative, not +// expensive: a reopened round the primary already answered dedupes at +// DecideFire's already-reviewed gate instead of buying a second review. A +// repository that pins its own reviewers still decides its effective set, so +// its rounds compare equal and are left alone. +func seedBefore(before Config, seeded []string) Config { + for _, key := range seeded { + if isReviewerFleetKey(key) { + before.RequiredBots, before.CoBots, before.Reviewers = nil, nil, nil + break + } + } + return before +} + func (s *Service) openFleetPRs(ctx context.Context, st State) (map[string]map[int]bool, error) { repos := map[string]bool{} for _, round := range st.Rounds { @@ -323,7 +347,12 @@ func inaccessibleRepoLookup(err error) bool { return errors.As(err, &apiErr) && apiErr.Status == http.StatusForbidden } -func (s *Service) reconcileFleetChange(st *State, before, after Config, open map[string]map[int]bool) { +// reconcileFleetChange applies what a policy change invalidates — and refuses +// the change outright when there is a network effect it cannot take back. +func (s *Service) reconcileFleetChange(st *State, before, after Config, open map[string]map[int]bool) error { + if err := checkExcludedTriggerPosts(st, before, after); err != nil { + return err + } if !sameFoldedSet(before.Scope, after.Scope) { st.Account = AccountQuota{ Scope: strings.Join(after.Scope, ","), @@ -341,6 +370,36 @@ func (s *Service) reconcileFleetChange(st *State, before, after Config, open map releaseSlot(st, QueueKey(round.Repo, round.PR)) } s.reopenForFleetReviewerChange(st, before, after, open) + return nil +} + +// checkExcludedTriggerPosts refuses an exclusion that would race a trigger post +// already claimed for the repository being excluded. +// +// The reservation is a fire's commit point, but the command goes to GitHub +// after it: a round left reserved here is one whose post is already authorized, +// so retiring it would not stop the excluded repository receiving a review +// command — it would only destroy the record of quota the account is about to +// be charged for. As with a hold, the claim and this write are both CAS, which +// makes either ordering safe: an existing claim rejects the exclusion, and an +// exclusion recorded first stops the next fire. +func checkExcludedTriggerPosts(st *State, before, after Config) error { + var blocked []string + for _, round := range st.Rounds { + repo := NormalizeRepo(round.Repo) + if !after.ExcludeRepos[repo] || before.ExcludeRepos[repo] { + continue + } + if triggerPostClaimed(&round) { + blocked = append(blocked, QueueKey(round.Repo, round.PR)) + } + } + if len(blocked) == 0 { + return nil + } + sort.Strings(blocked) + return fmt.Errorf("a review trigger is already being posted for %s; wait for it to finish before excluding it", + strings.Join(blocked, ", ")) } func sameFoldedSet(a, b []string) bool { diff --git a/internal/crq/fleetconfig.go b/internal/crq/fleetconfig.go index 0c68e4de..5fb99391 100644 --- a/internal/crq/fleetconfig.go +++ b/internal/crq/fleetconfig.go @@ -84,6 +84,27 @@ func fleetSettings() map[string]fleetSetting { }, Show: func(cfg Config) string { return strings.Join(cfg.RequiredBots, ",") }, }, + feedbackBotsKey: { + Doc: "logins whose findings crq surfaces beyond the ones it waits for (empty follows required-bots and cobots)", + Env: "CRQ_FEEDBACK_BOTS", + Apply: func(cfg *Config, v string) error { + logins := splitList(v) + // Present-but-empty is not a choice here either: it is the + // environment's "unset", so the surfaced set goes back to being + // derived from who reviews, recomputed rather than frozen. + cfg.FeedbackBots, cfg.FeedbackBotsExplicit = logins, len(logins) > 0 + if !cfg.FeedbackBotsExplicit { + cfg.FeedbackBots = cfg.reviewerLogins(func(r Reviewer) bool { return r.Required || !r.Metered() }) + } + return nil + }, + Show: func(cfg Config) string { + if !cfg.FeedbackBotsExplicit { + return "" + } + return strings.Join(cfg.FeedbackBots, ",") + }, + }, "min-interval": { Doc: "floor between two metered reviews", Env: "CRQ_MIN_INTERVAL", @@ -346,6 +367,14 @@ func fleetCoBotNames(value string) ([]string, error) { return names, nil } +// feedbackBotsKey is the one reviewer-view input that does not reshape who +// reviews: it widens whose findings are read, which decides whether a head +// comes back as `fix` or as clean. Two hosts answering it differently is the +// same divergence as one excluding a repository the other reviews — one queue +// driver reports findings the next considers absent — so it is fleet policy, +// but it never requeues a round. +const feedbackBotsKey = "feedback-bots" + // isReviewerFleetKey reports whether a setting reshapes who reviews, or how a // co-reviewer is driven. Those are the ones whose derived views have to be // rebuilt, and whose changes existing rounds may have to be reconciled against. @@ -477,9 +506,13 @@ func applyFleet(cfg Config, fleet map[string]string, warn func(string)) Config { return cfg } +// touchesReviewers reports whether the recorded policy holds anything the +// derived reviewer views are built from. feedback-bots is one of those inputs +// without reshaping who reviews: dropping it has to return the surfaced set to +// the derived one, which only the rebuild can compute. func touchesReviewers(fleet map[string]string) bool { for key := range fleet { - if isReviewerFleetKey(key) { + if isReviewerFleetKey(key) || key == feedbackBotsKey { return true } } diff --git a/internal/crq/fleetconfig_test.go b/internal/crq/fleetconfig_test.go index dbfd3189..dbf1b654 100644 --- a/internal/crq/fleetconfig_test.go +++ b/internal/crq/fleetconfig_test.go @@ -429,6 +429,133 @@ func TestFleetExcludeRetiresQueuedRounds(t *testing.T) { } } +// A reservation is a fire's commit point, but the review command reaches GitHub +// after it. Excluding the repository in that window cannot take the post back: +// retiring the round would only destroy the record of the quota the account is +// about to be charged for, on a repository crq is no longer meant to touch. +func TestFleetExcludeRefusesToRaceAClaimedTriggerPost(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.PutRound(Round{Repo: "owner/repo", PR: 7, Head: "abcdef123", Phase: PhaseReserved, Token: "tok"}) + return nil + }); err != nil { + t.Fatal(err) + } + + svc := NewService(cfg, newFakeGitHub(), store, nil) + err := svc.SetFleetConfig(ctx, "exclude", "owner/repo") + if err == nil || !strings.Contains(err.Error(), "already being posted") { + t.Fatalf("exclude during a claimed trigger post = %v, want a refusal", err) + } + st, _, _ := store.Load(ctx) + if value, ok := st.FleetValue("exclude"); ok { + t.Errorf("exclude = %q, want the refused change not written", value) + } + if round := st.Round("owner/repo", 7); round == nil || round.Phase != PhaseReserved { + t.Fatalf("round = %+v, want the reserved round left alone", round) + } + + // A co-reviewer claim is the same promise, and both clear once the post is + // recorded — then the exclusion goes through. + if _, err := store.Update(ctx, func(st *State) error { + r := st.Round("owner/repo", 7) + r.Phase = PhaseQueued + st.PutRound(*r) + return nil + }); err != nil { + t.Fatal(err) + } + if err := svc.SetFleetConfig(ctx, "exclude", "owner/repo"); err != nil { + t.Fatalf("exclude after the post finished = %v, want it recorded", err) + } +} + +// Whose findings crq reads decides whether a head comes back as `fix` or as +// clean, which makes it fleet policy: one queue driver reporting findings the +// next considers absent is the same divergence as one host excluding a +// repository the other reviews. +func TestFleetOwnsTheFeedbackBots(t *testing.T) { + ctx := context.Background() + cfg := isolatedConfig(t, map[string]string{ + "CRQ_COBOTS": "codex", + "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", + "CRQ_FEEDBACK_BOTS": "coderabbitai[bot]", + }) + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + if err := svc.SetFleetConfig(ctx, feedbackBotsKey, "coderabbitai[bot],"+dialect.CodexBotLogin); err != nil { + t.Fatal(err) + } + st, _, _ := store.Load(ctx) + got := svc.fleetCfg(st) + if !containsBot(got.FeedbackBots, dialect.CodexBotLogin) { + t.Errorf("FeedbackBots = %v, want the fleet's list to win over this host's", got.FeedbackBots) + } + // And `crq doctor` names the variable still set locally, or the host looks + // healthy while it reads a different set. + diverged, err := svc.FleetDivergence(ctx) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(diverged, "\n"), "CRQ_FEEDBACK_BOTS") { + t.Errorf("divergence = %v, want the overridden host variable named", diverged) + } + + // Recording the empty value is the fleet saying "derive it", which has to + // recompute the surfaced set rather than leave this host's explicit one. + if err := svc.SetFleetConfig(ctx, feedbackBotsKey, ""); err != nil { + t.Fatal(err) + } + st, _, _ = store.Load(ctx) + got = svc.fleetCfg(st) + if got.FeedbackBotsExplicit || !containsBot(got.FeedbackBots, dialect.CodexBotLogin) { + t.Fatalf("FeedbackBots = %v (explicit %v), want the derived set including the enabled co-reviewer", + got.FeedbackBots, got.FeedbackBotsExplicit) + } +} + +// Seeding writes THIS host's answers, so comparing the reviewer policy it +// records against the policy this host was already using can only ever say +// "nothing changed" — while the host that completed the head may have been +// running an entirely different set. That divergence is what seeding exists to +// end, so a seeded reviewer key reconciles as if the fleet had no reviewers. +func TestSeedingReviewerPolicyReopensCompletedRounds(t *testing.T) { + ctx := context.Background() + cfg := isolatedConfig(t, map[string]string{ + "CRQ_REPOS": "", + "CRQ_EXCLUDE": "", + "CRQ_COBOTS": "codex,bugbot", + "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", + "CRQ_COBOT_BUGBOT_CMD": "bugbot run", + "CRQ_COBOT_BUGBOT_TRIGGER": "selfheal", + }) + repo, pr := "owner/repo", 7 + gh := newFakeGitHub() + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.PutRound(Round{Repo: repo, PR: pr, Head: "abcdef123", Phase: PhaseCompleted}) + return nil + }); err != nil { + t.Fatal(err) + } + + if _, err := NewService(cfg, gh, store, nil).SeedFleetConfig(ctx); err != nil { + t.Fatal(err) + } + st, _, _ := store.Load(ctx) + round := st.Round(repo, pr) + if round == nil || round.Phase != PhaseQueued { + t.Fatalf("round = %+v, want the completed round reopened by the seed", round) + } + if !containsBot(round.ForceCoReviewers, dialect.BugbotLogin) { + t.Errorf("ForceCoReviewers = %v, want the seeded self-heal bot nudged once", round.ForceCoReviewers) + } +} + func TestFleetDivergenceIncludesConfigFileValues(t *testing.T) { ctx := context.Background() cfg := firingConfig() From bb92a38b898282d2cc393a10d60a997f65a79916 Mon Sep 17 00:00:00 2001 From: kristofferR <481270+kristofferR@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:53:48 +0200 Subject: [PATCH 28/37] Judge first fleet values as the policy changes they are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fleetCfg fills an unrecorded setting from the host running the command, so the first `crq config set` of a value this machine already used compared equal to itself and skipped reconciliation entirely — while every other host may have been acting under a different policy all along. Seeding already had its own before-state for this; generalise it into fleetChange, which carries the keys the fleet had no answer for until now, and reconcile an adopted key against an unknown fleet baseline instead of this host's fallback: reviewer keys reopen completed rounds, an adopted exclusion still meets the claimed-trigger refusal, and an adopted scope is judged against the scope the recorded quota actually belongs to. Also fence what an old CLI may delete. Dropping a recorded key this binary cannot read stays the remedy `crq doctor` names, but the capability check only asks for this binary's own CapsFleetPolicy, which a newer driver passes by definition — so refuse it while AdvancedWriters names a queue driver running ahead of this build, and point at upgrading instead. And reject operands after `crq config seed`: it takes none and records every unset key, so a stray token is a typo for something narrower, not a selector. --- cmd/crq/main.go | 10 ++- internal/crq/fleetcmd.go | 121 +++++++++++++++++++++++++------ internal/crq/fleetconfig_test.go | 112 ++++++++++++++++++++++++++++ internal/crq/state.go | 2 + internal/state/state.go | 43 ++++++++--- internal/state/writers_test.go | 25 +++++++ 6 files changed, 279 insertions(+), 34 deletions(-) diff --git a/cmd/crq/main.go b/cmd/crq/main.go index 16e451c5..d9adcc59 100644 --- a/cmd/crq/main.go +++ b/cmd/crq/main.go @@ -575,6 +575,12 @@ func run(ctx context.Context, args []string) int { printJSON(map[string]any{"setting": rest[1], "cleared": dropped}) return 0 case rest[0] == "seed": + // Seeding takes no operand and records every unset fleet key, so a + // stray token is a typo for something narrower, not a selector. + if len(rest) != 1 { + fatal(errors.New("usage: crq config seed")) + return 1 + } seeded, serr := service.SeedFleetConfig(ctx) if serr != nil { fatal(serr) @@ -1141,7 +1147,9 @@ configuration is the one you mean. A value this crq cannot read is refused at "set", and one that arrives anyway from a newer binary leaves the host on its own value rather than acting on half -a policy. "crq doctor" reports both that and any variable still set here that +a policy. "crq config unset" drops such a setting from any host — except while a +newer crq is driving the queue, since that binary is the one that can reconcile +its removal. "crq doctor" reports both that and any variable still set here that the fleet overrides. `) case "doctor": diff --git a/internal/crq/fleetcmd.go b/internal/crq/fleetcmd.go index d65c2cfa..1200d3bb 100644 --- a/internal/crq/fleetcmd.go +++ b/internal/crq/fleetcmd.go @@ -97,11 +97,16 @@ func (s *Service) SetFleetConfig(ctx context.Context, key, value string) error { return err } before := s.fleetCfg(*st) - if current, ok := st.FleetValue(key); ok && current == value { + current, recorded := st.FleetValue(key) + if recorded && current == value { return ErrNoChange } st.SetFleetValue(key, value) - return s.reconcileFleetChange(st, before, s.fleetCfg(*st), open) + change := fleetChange{before: before, after: s.fleetCfg(*st)} + if !recorded { + change.adopted = []string{key} + } + return s.reconcileFleetChange(st, change, open) }) return err } @@ -112,7 +117,8 @@ func (s *Service) SetFleetConfig(ctx context.Context, key, value string) error { // A key this binary does not know is refused only when the fleet has not // recorded it either. Dropping one it HAS recorded is the remedy `crq doctor` // names for a setting a newer crq wrote and this host ignores, and refusing it -// would leave that key unremovable from every host but the newest. +// would leave that key unremovable from every host but the newest — but only +// while no newer binary is driving the queue, see requireNoAdvancedDrivers. func (s *Service) UnsetFleetConfig(ctx context.Context, key string) (bool, error) { known := false if _, ok := fleetSettings()[key]; ok { @@ -130,12 +136,17 @@ func (s *Service) UnsetFleetConfig(ctx context.Context, key string) (bool, error if err := s.requireFleetCapableDrivers(st); err != nil { return err } + if !known { + if err := s.requireNoAdvancedDrivers(st, key); err != nil { + return err + } + } before := s.fleetCfg(*st) dropped = st.UnsetFleetValue(key) if !dropped { return ErrNoChange } - return s.reconcileFleetChange(st, before, s.fleetCfg(*st), open) + return s.reconcileFleetChange(st, fleetChange{before: before, after: s.fleetCfg(*st)}, open) }) return dropped, err } @@ -187,7 +198,7 @@ func (s *Service) SeedFleetConfig(ctx context.Context) ([]string, error) { if len(seeded) == 0 { return ErrNoChange } - return s.reconcileFleetChange(st, seedBefore(before, seeded), s.fleetCfg(*st), open) + return s.reconcileFleetChange(st, fleetChange{before: before, after: s.fleetCfg(*st), adopted: seeded}, open) }) return seeded, err } @@ -258,6 +269,25 @@ func (s *Service) requireFleetCapableDrivers(st *State) error { return nil } +// requireNoAdvancedDrivers refuses to drop a setting this binary cannot +// interpret while a newer one is driving the queue. +// +// Removing a recorded key is the remedy `crq doctor` names for a setting a newer +// crq wrote, and it has to keep working — an unremovable key would be worse. But +// the capability fence above only asks for THIS binary's fixed CapsFleetPolicy, +// which a newer driver passes by definition, so nothing else stops an old CLI +// deleting a policy it can neither validate nor reconcile. If that key drives +// reviewers, exclusions or quota, the newer driver sees it vanish without the +// cleanup its removal calls for. The setting's own binary is the one that may +// drop it; here the remedy is to run the unset from an upgraded host. +func (s *Service) requireNoAdvancedDrivers(st *State, key string) error { + if ahead := st.AdvancedWriters(WriterCaps, s.clock()); len(ahead) > 0 { + return fmt.Errorf("cannot unset %q from this crq: it is not a setting this binary understands, and newer queue drivers are running: %v; upgrade crq on this host and unset it there", + key, ahead) + } + return nil +} + func (s *Service) prepareFleetReviewerChange(ctx context.Context, key string) (map[string]map[int]bool, error) { if !isReviewerFleetKey(key) { return nil, nil @@ -284,27 +314,53 @@ func seedsReviewers(st State) bool { return false } -// seedBefore is the before-state a seed has to be reconciled against. +// fleetChange is one policy mutation as reconciliation sees it: the effective +// configuration before and after it, plus the keys the fleet had no answer for +// until now. // -// Seeding records THIS host's own values, so comparing the reviewer policy it -// writes against the policy this host was already using can only ever say -// "nothing changed". The fleet-wide before-state is the thing seeding exists to -// end: another host may have completed a head without a reviewer the seed now -// makes fleet-required, and that host's completed round is the dedup marker -// that would keep the newly required bot from ever being triggered. +// Those adopted keys are why `before` cannot be taken at face value. fleetCfg +// fills an unrecorded setting from THIS host, so the first `crq config set` or +// `crq config seed` of a value this machine was already using renders before and +// after identical — while every other host may have been acting under a +// different one all along. For an adopted key the fleet-wide baseline is +// unknown, and reconciliation has to assume it differed rather than assume it +// matched. +type fleetChange struct { + before, after Config + adopted []string +} + +func (c fleetChange) adopts(key string) bool { + for _, adopted := range c.adopted { + if adopted == key { + return true + } + } + return false +} + +// baseline is the pre-change configuration with each adopted key's host fallback +// removed, so a policy the fleet is only now recording is reconciled as the +// change it is for everyone else. // -// So when a seed records a reviewer key, treat the fleet's previous reviewer -// set as unknown — no required bot, no co-reviewer — and let the ordinary -// reviewer-change reconciliation requeue against it. It is conservative, not -// expensive: a reopened round the primary already answered dedupes at -// DecideFire's already-reviewed gate instead of buying a second review. A -// repository that pins its own reviewers still decides its effective set, so -// its rounds compare equal and are left alone. -func seedBefore(before Config, seeded []string) Config { - for _, key := range seeded { +// A reviewer key drops the whole reviewer set: another host may have completed a +// head without a bot the adoption makes fleet-required, and that host's +// completed round is the dedup marker that would keep the newly required bot +// from ever being triggered. It is conservative, not expensive — a reopened +// round the primary already answered dedupes at DecideFire's already-reviewed +// gate instead of buying a second review, and a repository that pins its own +// reviewers still decides its effective set, so its rounds compare equal and are +// left alone. Adopting `exclude` likewise drops the baseline exclusions, so a +// repository this host already skipped still has to pass the claimed-trigger +// refusal that the rest of the fleet never applied to it. +func (c fleetChange) baseline() Config { + before := c.before + for _, key := range c.adopted { if isReviewerFleetKey(key) { before.RequiredBots, before.CoBots, before.Reviewers = nil, nil, nil - break + } + if key == "exclude" { + before.ExcludeRepos = nil } } return before @@ -349,11 +405,12 @@ func inaccessibleRepoLookup(err error) bool { // reconcileFleetChange applies what a policy change invalidates — and refuses // the change outright when there is a network effect it cannot take back. -func (s *Service) reconcileFleetChange(st *State, before, after Config, open map[string]map[int]bool) error { +func (s *Service) reconcileFleetChange(st *State, change fleetChange, open map[string]map[int]bool) error { + before, after := change.baseline(), change.after if err := checkExcludedTriggerPosts(st, before, after); err != nil { return err } - if !sameFoldedSet(before.Scope, after.Scope) { + if fleetScopeMoved(st, change) { st.Account = AccountQuota{ Scope: strings.Join(after.Scope, ","), Source: "fleet scope changed", @@ -402,6 +459,22 @@ func checkExcludedTriggerPosts(st *State, before, after Config) error { strings.Join(blocked, ", ")) } +// fleetScopeMoved reports whether the recorded account quota belongs to an +// account other than the one the fleet now scans — which is what makes it +// meaningless rather than merely stale. +// +// Ordinarily that is just "did the scope change". Adopting the scope needs the +// other question: fleetCfg fills an unrecorded setting from THIS host, so the +// first `crq config set scope` compares equal to itself even when the fleet was +// scanning something else entirely. The quota's own recorded scope is the one +// piece of that account's identity that survived, so ask it instead. +func fleetScopeMoved(st *State, change fleetChange) bool { + if change.adopts("scope") { + return !sameFoldedSet(splitList(st.Account.Scope), change.after.Scope) + } + return !sameFoldedSet(change.before.Scope, change.after.Scope) +} + func sameFoldedSet(a, b []string) bool { if len(a) != len(b) { return false diff --git a/internal/crq/fleetconfig_test.go b/internal/crq/fleetconfig_test.go index dbf1b654..ca5263ed 100644 --- a/internal/crq/fleetconfig_test.go +++ b/internal/crq/fleetconfig_test.go @@ -556,6 +556,118 @@ func TestSeedingReviewerPolicyReopensCompletedRounds(t *testing.T) { } } +// The first `crq config set` of a key is the same adoption a seed performs, and +// the pre-change baseline it is reconciled against comes from THIS host — so +// recording the value this machine was already using looks like no change at +// all, while the host that completed the head may have been running another set +// entirely. +func TestFirstFleetReviewerValueReopensCompletedRounds(t *testing.T) { + ctx := context.Background() + required := "coderabbitai[bot]," + dialect.CodexBotLogin + cfg := isolatedConfig(t, map[string]string{ + "CRQ_COBOTS": "codex", + "CRQ_REQUIRED_BOTS": required, + }) + repo, pr := "owner/repo", 7 + gh := newFakeGitHub() + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.PutRound(Round{Repo: repo, PR: pr, Head: "abcdef123", Phase: PhaseCompleted}) + return nil + }); err != nil { + t.Fatal(err) + } + + // The same value this host already had: only its absence from the fleet + // makes it a change, and that is the change that has to be reconciled. + if err := NewService(cfg, gh, store, nil).SetFleetConfig(ctx, "required-bots", required); err != nil { + t.Fatal(err) + } + st, _, _ := store.Load(ctx) + if round := st.Round(repo, pr); round == nil || round.Phase != PhaseQueued { + t.Fatalf("round = %+v, want the completed round reopened by the adoption", round) + } +} + +// Adopting an exclusion this host already applied is a change for every host +// that did not, so it still has to pass the claimed-trigger refusal. +func TestFirstFleetExcludeStillRefusesAClaimedTriggerPost(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.ExcludeRepos = map[string]bool{"owner/repo": true} + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.PutRound(Round{Repo: "owner/repo", PR: 7, Head: "abcdef123", Phase: PhaseReserved, Token: "tok"}) + return nil + }); err != nil { + t.Fatal(err) + } + + err := NewService(cfg, newFakeGitHub(), store, nil).SetFleetConfig(ctx, "exclude", "owner/repo") + if err == nil || !strings.Contains(err.Error(), "already being posted") { + t.Fatalf("adopting an exclusion during a claimed trigger post = %v, want a refusal", err) + } +} + +// The recorded quota belongs to whichever account the fleet was scanning, which +// this host's own scope says nothing about — so adopting the scope is judged +// against the quota's own recorded one. +func TestFirstFleetScopeDropsQuotaFromAnotherAccount(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.Scope = []string{"acme"} + store := NewMemoryStore(cfg) + blocked := time.Date(2026, 7, 27, 13, 0, 0, 0, time.UTC) + if _, err := store.Update(ctx, func(st *State) error { + st.Account.BlockedUntil = &blocked + st.Account.Scope = "other-org" + return nil + }); err != nil { + t.Fatal(err) + } + + if err := NewService(cfg, newFakeGitHub(), store, nil).SetFleetConfig(ctx, "scope", "acme"); err != nil { + t.Fatal(err) + } + st, _, _ := store.Load(ctx) + if st.Account.BlockedUntil != nil || st.Account.Scope != "acme" { + t.Fatalf("account = %+v, want another account's block dropped", st.Account) + } +} + +// Dropping a recorded key this binary cannot read is the remedy doctor names, +// but it is not this binary's to make while the crq that wrote it is driving the +// queue: the removal may need cleanup only that version knows how to do. +func TestUnsettingAnUnknownKeyWaitsForTheNewerDriver(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + cfg := firingConfig() + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.SetFleetValue("some-future-knob", "7") + st.Leader = &LeaderLease{Owner: "new-daemon", ExpiresAt: now.Add(time.Minute)} + st.NoteWriter("new-daemon", WriterCaps+1, now) + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, newFakeGitHub(), store, nil) + svc.now = func() time.Time { return now } + + if _, err := svc.UnsetFleetConfig(ctx, "some-future-knob"); err == nil || !strings.Contains(err.Error(), "newer queue drivers") { + t.Fatalf("unset of an uninterpretable key = %v, want a refusal naming the newer driver", err) + } + // A setting this binary does understand is still its own to drop: it can + // reconcile that removal itself. + if err := svc.SetFleetConfig(ctx, "min-interval", "5m"); err != nil { + t.Fatal(err) + } + if dropped, err := svc.UnsetFleetConfig(ctx, "min-interval"); err != nil || !dropped { + t.Fatalf("unset of a known key = %v %v, want it dropped", dropped, err) + } +} + func TestFleetDivergenceIncludesConfigFileValues(t *testing.T) { ctx := context.Background() cfg := firingConfig() diff --git a/internal/crq/state.go b/internal/crq/state.go index 107b4ac8..23a57286 100644 --- a/internal/crq/state.go +++ b/internal/crq/state.go @@ -52,6 +52,8 @@ const ( CapsRepoOverrides = crqstate.CapsRepoOverrides // CapsFleetPolicy is the binary capability state-backed fleet policy needs. CapsFleetPolicy = crqstate.CapsFleetPolicy + // WriterCaps is what THIS binary understands of the shared state. + WriterCaps = crqstate.WriterCaps ) var ( diff --git a/internal/state/state.go b/internal/state/state.go index cc5cc81d..483a2a5b 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -533,6 +533,39 @@ func (s *State) NoteWriter(host string, caps int, now time.Time) { // on this actually honour it?" An old binary loads an unknown field, writes it // back untouched, and keeps deciding from its own fleet-wide configuration. func (s *State) LaggingWriters(caps int, now time.Time) []string { + var out []string + for host := range s.actingWriters(now) { + if seen, ok := s.Writers[host]; ok && seen.Caps >= caps && now.Sub(seen.At) <= writerTTL { + continue + } + out = append(out, host) + } + sort.Strings(out) + return out +} + +// AdvancedWriters names the hosts driving this queue with a capability HIGHER +// than caps — the fleet running a newer binary than the caller's. +// +// It is the mirror of LaggingWriters, and answers the question an old CLI has to +// ask before it deletes a recorded setting it cannot interpret: is anyone acting +// on it? WriterCaps is bumped exactly when a state field starts changing +// decisions, so a driver announcing more than this binary knows is the one that +// wrote that setting and owns whatever cleanup dropping it needs. +func (s *State) AdvancedWriters(caps int, now time.Time) []string { + var out []string + for host := range s.actingWriters(now) { + if seen, ok := s.Writers[host]; ok && seen.Caps > caps && now.Sub(seen.At) <= writerTTL { + out = append(out, host) + } + } + sort.Strings(out) + return out +} + +// actingWriters names the processes DRIVING this queue: holding the leader lease +// or the fire slot. +func (s *State) actingWriters(now time.Time) map[string]bool { acting := map[string]bool{} // The leader identifies itself as "host= pid= run=", which is // exactly the process identity capabilities are recorded under — the run @@ -543,15 +576,7 @@ func (s *State) LaggingWriters(caps int, now time.Time) []string { if slot := s.SlotRound(); slot != nil && slot.ByHost != "" { acting[slot.ByHost] = true } - var out []string - for host := range acting { - if seen, ok := s.Writers[host]; ok && seen.Caps >= caps && now.Sub(seen.At) <= writerTTL { - continue - } - out = append(out, host) - } - sort.Strings(out) - return out + return acting } // RepoReviewers overrides which reviewers run on one repository. A nil slice diff --git a/internal/state/writers_test.go b/internal/state/writers_test.go index e5340d4c..6759cb38 100644 --- a/internal/state/writers_test.go +++ b/internal/state/writers_test.go @@ -99,6 +99,31 @@ func TestLaggingWritersMatchesTheFireSlotOwner(t *testing.T) { } } +// The mirror question: an old binary about to drop a setting it cannot read has +// to know whether the crq that wrote it is still driving the queue. +func TestAdvancedWritersNamesTheNewerDriver(t *testing.T) { + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + st := New() + st.Leader = &LeaderLease{Owner: "host=new-mac pid=7", ExpiresAt: now.Add(time.Minute)} + + // A leader running this version is not ahead of it. + st.NoteWriter("host=new-mac pid=7", WriterCaps, now) + if got := st.AdvancedWriters(WriterCaps, now); len(got) != 0 { + t.Errorf("advanced = %v, want none for a leader on this version", got) + } + + st.NoteWriter("host=new-mac pid=7", WriterCaps+1, now) + if got := st.AdvancedWriters(WriterCaps, now); len(got) != 1 || got[0] != "host=new-mac pid=7" { + t.Fatalf("advanced = %v, want the newer leader named", got) + } + + // And only while it is driving: a stale stamp says nothing about now. + st.Leader.ExpiresAt = now + if got := st.AdvancedWriters(WriterCaps, now); len(got) != 0 { + t.Errorf("advanced = %v, want none once the lease has lapsed", got) + } +} + // Reopening a round is not a failed attempt. Moving LastAttemptAt would raise // the adoption floor past a newly required co-reviewer's own unanswered trigger, // so crq would post that bot a second request for the round it is reopening to From c6966583b81918412ed3463fdb8442205398dc9f Mon Sep 17 00:00:00 2001 From: kristofferR <481270+kristofferR@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:43:40 +0200 Subject: [PATCH 29/37] Judge every host input that carries fleet policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `crq config show ` printed the whole configuration and exited 0, reporting success for an invocation that asked for something else. It now takes no operand, like the seed branch beside it. `crq doctor` only looked for each setting's canonical variable, so a host still exporting a legacy alias or a per-bot key — CRQ_RL_CODEX_DEGRADE, CRQ_CODEX_CMD, CRQ_COBOT__REQUIRED — was called in step with the fleet while holding the value it would silently fall back to the moment the fleet key was unset. A setting now declares every name it can be fed from, ExplicitFleetEnv records the one the host actually used, and the remedy names that variable rather than the canonical one. A fleet repo slug accepted anything with a single slash, so `owner/re po` recorded an exclusion GitHub can never return a key for: a rule that reports success and protects nothing. Slugs are now held to the character class GitHub itself allows. --- cmd/crq/main.go | 7 ++++ internal/crq/config.go | 12 ++++-- internal/crq/drainswitch.go | 16 +++++++- internal/crq/fleetcmd.go | 9 ++++- internal/crq/fleetconfig.go | 63 +++++++++++++++++++++++++++++--- internal/crq/fleetconfig_test.go | 59 +++++++++++++++++++++++++++++- 6 files changed, 150 insertions(+), 16 deletions(-) diff --git a/cmd/crq/main.go b/cmd/crq/main.go index d9adcc59..038cde0c 100644 --- a/cmd/crq/main.go +++ b/cmd/crq/main.go @@ -544,6 +544,13 @@ func run(ctx context.Context, args []string) int { rest := args[1:] switch { case len(rest) == 0 || rest[0] == "show": + // Showing takes no operand either: a trailing token is a misspelled + // setting or subcommand, and printing the whole configuration would + // report success for an invocation that asked for something else. + if len(rest) > 1 { + fatal(errors.New("usage: crq config show")) + return 1 + } settings, cerr := service.FleetConfig(ctx) if cerr != nil { fatal(cerr) diff --git a/internal/crq/config.go b/internal/crq/config.go index c49608d2..94bd6927 100644 --- a/internal/crq/config.go +++ b/internal/crq/config.go @@ -89,8 +89,10 @@ type Config struct { // that commits a decision. FleetRevision string // ExplicitFleetEnv records fleet-owned variables supplied by either the - // config file or the process environment. LoadConfig does not export normal - // config-file values, so os.Getenv alone cannot detect that divergence. + // config file or the process environment, under the name the host actually + // used — a legacy alias or per-bot key counts, since the fleet overrides it + // just the same. LoadConfig does not export normal config-file values, so + // os.Getenv alone cannot detect that divergence. ExplicitFleetEnv map[string]bool RateLimitCommand string RateLimitMarker string @@ -271,8 +273,10 @@ func LoadConfig() (Config, error) { } cfg.ExplicitFleetEnv = map[string]bool{} for _, setting := range fleetSettings() { - if _, ok := env[setting.Env]; ok { - cfg.ExplicitFleetEnv[setting.Env] = true + for _, name := range setting.envNames() { + if _, ok := env[name]; ok { + cfg.ExplicitFleetEnv[name] = true + } } } if len(cfg.Scope) == 0 && cfg.GateRepo != "" { diff --git a/internal/crq/drainswitch.go b/internal/crq/drainswitch.go index a45aa772..0d0f2902 100644 --- a/internal/crq/drainswitch.go +++ b/internal/crq/drainswitch.go @@ -107,11 +107,23 @@ func validRepoSlug(repo string) bool { return false } // Same segment rules the workspace package applies to a path: "." and ".." - // are not repository names, and a second slash means this is not one either. + // are not repository names. The character class is GitHub's: an owner or + // repository name is letters, digits, hyphen, underscore and dot, so a slug + // holding anything else — a space, a second slash, a control character — is + // one no scan result can ever normalize to. Recorded, it reads as a rule + // covering a repository while covering nothing at all. for _, part := range []string{owner, name} { - if part == "." || part == ".." || strings.ContainsAny(part, `/\`) { + if part == "." || part == ".." { return false } + for _, r := range part { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + case r == '-', r == '_', r == '.': + default: + return false + } + } } return true } diff --git a/internal/crq/fleetcmd.go b/internal/crq/fleetcmd.go index 1200d3bb..7bb496c3 100644 --- a/internal/crq/fleetcmd.go +++ b/internal/crq/fleetcmd.go @@ -246,7 +246,12 @@ func (s *Service) FleetDivergence(ctx context.Context) ([]string, error) { return nil, err } var out []string + settings := fleetSettings() for _, item := range items { + // Every name the host is still feeding this policy from, not just the + // canonical one: a legacy alias left behind is what the host falls back to + // if the fleet key is ever unset. + hostEnv := explicitEnv(s.cfg, settings[item.Key]) switch { case item.Unknown: out = append(out, fmt.Sprintf("%s is recorded for the fleet but is not a setting this crq understands; upgrade crq on this host or run crq config unset %s", @@ -254,9 +259,9 @@ func (s *Service) FleetDivergence(ctx context.Context) ([]string, error) { case item.Error != "": out = append(out, fmt.Sprintf("%s: the fleet's value is one this crq cannot read (%s); using this host's %q", item.Key, item.Error, item.Value)) - case item.HostValue != nil && s.cfg.ExplicitFleetEnv[item.Env]: + case item.HostValue != nil && len(hostEnv) > 0: out = append(out, fmt.Sprintf("%s is %q for the fleet, but %s is set to %q on this host; remove it or run crq config set %s", - item.Key, item.Value, item.Env, *item.HostValue, item.Key)) + item.Key, item.Value, strings.Join(hostEnv, ", "), *item.HostValue, item.Key)) } } return out, nil diff --git a/internal/crq/fleetconfig.go b/internal/crq/fleetconfig.go index 5fb99391..a8378ea8 100644 --- a/internal/crq/fleetconfig.go +++ b/internal/crq/fleetconfig.go @@ -22,6 +22,12 @@ type fleetSetting struct { Doc string // Env names the variable it used to come from, so an operator can find it. Env string + // AltEnv names the other variables a host may still feed this policy from: + // legacy aliases and per-bot keys. A host exporting one of these diverges + // from the fleet exactly as the canonical name does, and `crq doctor` has to + // say so — otherwise the stale setting stays, invisible, and the host falls + // back to it the moment the fleet key is unset. + AltEnv []string // Apply writes value onto cfg, rejecting a value it cannot parse. A bad // value must fail where it is SET, but it is validated here too: the state // ref is shared, and a host reading a value it cannot use has to say so @@ -73,8 +79,9 @@ func fleetSettings() map[string]fleetSetting { Show: func(cfg Config) string { return strings.Join(sortedSetKeys(cfg.ExcludeRepos), ",") }, }, "required-bots": { - Doc: "logins that must review a head before a round converges", - Env: "CRQ_REQUIRED_BOTS", + Doc: "logins that must review a head before a round converges", + Env: "CRQ_REQUIRED_BOTS", + AltEnv: coBotRequiredEnvs(), Apply: func(cfg *Config, v string) error { cfg.RequiredBots = splitList(v) if len(cfg.RequiredBots) == 0 { @@ -176,6 +183,9 @@ func fleetSettings() map[string]fleetSetting { "cobots": { Doc: "co-reviewers crq surfaces and triggers (empty disables all)", Env: "CRQ_COBOTS", + // A required bot is enabled whatever CRQ_COBOTS lists, so the per-bot + // required keys shape this set too. + AltEnv: coBotRequiredEnvs(), Apply: func(cfg *Config, v string) error { names, err := fleetCoBotNames(v) if err != nil { @@ -193,8 +203,9 @@ func fleetSettings() map[string]fleetSetting { }, }, "rate-limit-co-degrade": { - Doc: "run co-reviewer-only rounds while the CodeRabbit account is blocked", - Env: "CRQ_RL_CO_DEGRADE", + Doc: "run co-reviewer-only rounds while the CodeRabbit account is blocked", + Env: "CRQ_RL_CO_DEGRADE", + AltEnv: []string{"CRQ_RL_CODEX_DEGRADE"}, Apply: func(cfg *Config, v string) error { on, err := parseFleetBool(v) if err != nil { @@ -225,6 +236,10 @@ func fleetSettings() map[string]fleetSetting { func coBotFleetSettings(co dialect.CoReviewer) map[string]fleetSetting { name := co.Name env := "CRQ_COBOT_" + strings.ToUpper(name) + var legacyCommand []string + if co.LegacyCommandEnv != "" { + legacyCommand = []string{co.LegacyCommandEnv} + } return map[string]fleetSetting{ "cobot-" + name + "-trigger": { Doc: "when crq posts " + name + "'s command: never, selfheal or always (empty leaves it to how the bot is required)", @@ -256,8 +271,9 @@ func coBotFleetSettings(co dialect.CoReviewer) map[string]fleetSetting { }, }, "cobot-" + name + "-cmd": { - Doc: "the comment that triggers " + name + " (empty means crq never posts one)", - Env: env + "_CMD", + Doc: "the comment that triggers " + name + " (empty means crq never posts one)", + Env: env + "_CMD", + AltEnv: legacyCommand, Apply: func(cfg *Config, v string) error { command := strings.TrimSpace(v) updateCoBot(cfg, name, func(cb *CoBotConfig) { cb.Command = command }) @@ -281,6 +297,41 @@ func coBotFleetSettings(co dialect.CoReviewer) map[string]fleetSetting { } } +// coBotRequiredEnvs is every per-bot required key. Required-ness is not a fleet +// setting of its own (required-bots owns the list), but the environment still +// lets a host answer it per bot — so those variables belong to the settings that +// carry the answer, not to none of them. +func coBotRequiredEnvs() []string { + out := make([]string, 0, 3) + for _, co := range dialect.KnownCoReviewers() { + out = append(out, "CRQ_COBOT_"+strings.ToUpper(co.Name)+"_REQUIRED") + } + return out +} + +// envNames is every variable this host may be feeding the setting from, canonical +// name first. +func (s fleetSetting) envNames() []string { + return append([]string{s.Env}, s.AltEnv...) +} + +// explicitEnv names the variables this host actually set for one fleet setting, +// canonical name first. Empty for a setting the host leaves to the fleet — and +// for the zero fleetSetting an unknown key resolves to, whose empty Env no +// environment can hold. +func explicitEnv(cfg Config, setting fleetSetting) []string { + var out []string + for _, name := range setting.envNames() { + if name == "" { + continue + } + if cfg.ExplicitFleetEnv[name] { + out = append(out, name) + } + } + return out +} + // coBotOf is how this host currently drives a co-reviewer: its enabled entry // when it has one, otherwise what its environment resolved for a bot the fleet // leaves disabled — which is still the configuration a repository override diff --git a/internal/crq/fleetconfig_test.go b/internal/crq/fleetconfig_test.go index ca5263ed..64f78df4 100644 --- a/internal/crq/fleetconfig_test.go +++ b/internal/crq/fleetconfig_test.go @@ -79,8 +79,12 @@ func TestFleetRefusesWhatItCannotRead(t *testing.T) { t.Error("an unknown setting was accepted") } for _, key := range []string{"repos", "exclude"} { - if err := svc.SetFleetConfig(ctx, key, "owner-repo"); err == nil { - t.Errorf("%s accepted a malformed repository slug", key) + // A slug GitHub can never return is a rule covering nothing, whether it + // is missing the slash or holds a character no repository name can. + for _, slug := range []string{"owner-repo", "owner/re po", "owner/re\tpo", "owner/repo?"} { + if err := svc.SetFleetConfig(ctx, key, slug); err == nil { + t.Errorf("%s accepted the malformed repository slug %q", key, slug) + } } } if err := svc.SetFleetConfig(ctx, "required-bots", ""); err == nil { @@ -710,6 +714,57 @@ func TestFleetDivergenceIncludesAnExplicitlyEmptyHostValue(t *testing.T) { } } +// A policy a host feeds from a legacy alias or a per-bot key diverges exactly as +// the canonical variable does — and the remedy names the variable that is +// actually there, since that is the one still waiting to be fallen back on. +func TestFleetDivergenceNamesTheVariableTheHostSet(t *testing.T) { + for _, tc := range []struct { + name string + key string + value string + env string + host func(*Config) + }{ + { + name: "legacy alias", + key: "rate-limit-co-degrade", + value: "1", + env: "CRQ_RL_CODEX_DEGRADE", + host: func(cfg *Config) { cfg.RateLimitCoDegrade = false }, + }, + { + name: "per-bot required key", + key: "required-bots", + value: "coderabbitai[bot]", + env: "CRQ_COBOT_BUGBOT_REQUIRED", + host: func(cfg *Config) { + cfg.RequiredBots = []string{"coderabbitai[bot]", dialect.BugbotLogin} + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + tc.host(&cfg) + cfg.ExplicitFleetEnv = map[string]bool{tc.env: true} + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.SetFleetValue(tc.key, tc.value) + return nil + }); err != nil { + t.Fatal(err) + } + got, err := NewService(cfg, newFakeGitHub(), store, nil).FleetDivergence(ctx) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || !strings.Contains(got[0], tc.env) { + t.Fatalf("divergence = %v, want it to name %s", got, tc.env) + } + }) + } +} + func TestFleetRevisionInvalidatesAStaleDecision(t *testing.T) { cfg := firingConfig() st := DefaultState(cfg) From d35b0c51a97057c20d2a0ed2536d50cce5109c1d Mon Sep 17 00:00:00 2001 From: kristofferR <481270+kristofferR@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:12:28 +0200 Subject: [PATCH 30/37] Reread fleet policy where a stale copy would spend a review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps where a decision outlived the configuration it was made from. The autoreview pass sorted its target list in place. Without a fleet `scope` that list IS this host's configured one, so a pass reordered CRQ_SCOPE for every later reader of it and raced any concurrent one; the allow-repository branch already built its own, so only this path aliased. fireCoReviewWait committed without revalidating the reviewer set, unlike every other fire path. It is the one verdict with no later reconciliation to undo it: the round is still queued when SetReviewers lands, requeuing a queued round is a no-op, and the stale decision then parks it in reviewing with commands adopted for a set that no longer gates the PR. The watcher read the fleet policy once per pass and then called Next — a mutating oracle that deliberately does not enforce the body marker — for every candidate. A skip marker the fleet adopted during a long scan took effect a whole pass late, spending metered reviews on PRs already opted out. The state ref is ETag'd, so rereading it costs a conditional GET against a call that already re-observes the PR. --- internal/crq/auto.go | 7 +++- internal/crq/repoconfig_test.go | 39 ++++++++++++++++++++++ internal/crq/service.go | 7 +++- internal/crq/service_test.go | 26 +++++++++++++++ internal/crq/watch.go | 25 +++++++++++++- internal/crq/watch_test.go | 58 +++++++++++++++++++++++++++++++++ 6 files changed, 159 insertions(+), 3 deletions(-) diff --git a/internal/crq/auto.go b/internal/crq/auto.go index 228303cb..9f59b64a 100644 --- a/internal/crq/auto.go +++ b/internal/crq/auto.go @@ -236,7 +236,12 @@ func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, t return err } cfg := s.fleetCfg(state) - targets := cfg.Scope + // Copied, never sorted in place. Without a fleet `scope` the configuration is + // this host's own, so the slice shares its backing array with s.cfg.Scope and + // the sort below would silently reorder the operator's configured scan order + // — and race any concurrent reader of it. The allow-repository branch already + // builds its own. + targets := append([]string(nil), cfg.Scope...) byRepo := false if len(cfg.AllowRepos) > 0 { targets = make([]string, 0, len(cfg.AllowRepos)) diff --git a/internal/crq/repoconfig_test.go b/internal/crq/repoconfig_test.go index b253de57..7d0e590f 100644 --- a/internal/crq/repoconfig_test.go +++ b/internal/crq/repoconfig_test.go @@ -1028,3 +1028,42 @@ func TestSelfHealDoesNotTriggerForFleetExcludedRepository(t *testing.T) { t.Fatalf("excluded repository retained a co-review claim: %+v", c) } } + +// The co-review wait is the one fire verdict with no later reconciliation to +// undo it: the round is still queued when a reviewer change lands, requeuing a +// queued round is a no-op, and the stale decision then parks it in reviewing +// with commands adopted for the reviewer set that no longer gates it. +func TestCoReviewWaitIsRevalidatedInsideItsWrite(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.CoBots = codexCoBots(nil) + store := NewMemoryStore(cfg) + hooked := &hookedStore{StateStore: store} + svc := NewService(cfg, newFakeGitHub(), hooked, nil) + now := time.Now().UTC() + repo, pr, head := "o/r", 12, "aaaaaaaa2" + seedRound(t, store, cfg, repo, pr, head, PhaseQueued, now, 0) + + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + round := *st.Round(repo, pr) + hooked.hook = func() { + if _, err := svc.SetReviewers(ctx, repo, []string{"codex"}, []string{"codex"}); err != nil { + t.Error(err) + } + } + wait := engine.FireDecision{Verdict: engine.FireCoReviewWait, Reason: "awaiting co-review"} + obs := engine.Observation{Open: true, Head: head, HeadAt: now.Add(-time.Minute)} + result, err := svc.applyFire(ctx, svc.cfgFor(st, repo), round, obs, wait, now) + if err != nil { + t.Fatal(err) + } + if result.Action != "lost_race" { + t.Errorf("action = %q, want the wait voided by the override that beat its write", result.Action) + } + if got := roundPhase(t, store, repo, pr); got != PhaseQueued { + t.Fatalf("phase = %s, want the round left queued so the new reviewer set decides it", got) + } +} diff --git a/internal/crq/service.go b/internal/crq/service.go index 59c07671..f71fbb6f 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -1576,7 +1576,12 @@ func (s *Service) fireCoReviewWait(ctx context.Context, cfg Config, round Round, updated, err := s.store.Update(ctx, func(st *State) error { changed = false r := st.Round(round.Repo, round.PR) - if !sameRound(r, round) || !firable(st, r, now) { + // This is the wait's commit point, so the reviewer set that chose it must + // still be the configured one. A change committed since the decision has + // already reconciled the round while it was queued, and nothing later + // undoes this transition: moving it to reviewing with adopted commands + // would leave the round waiting on — and crediting — the former set. + if !sameRound(r, round) || !firable(st, r, now) || overrideChanged(st, round.Repo, cfg) { return ErrNoChange } deadline := now.Add(s.cfg.FeedbackWaitTimeout) diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index 76f2be05..08a173cc 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -682,6 +682,32 @@ func TestAutoReviewScanSkipsTheFleetsAuthors(t *testing.T) { } } +// Without a fleet `scope` the pass's configuration is this host's own, so its +// target list shares the backing array of CRQ_SCOPE. Sorting it in place +// rewrote the operator's configured scan order under every later reader of it, +// including a concurrent one. +func TestAutoReviewScanLeavesTheConfiguredScopeAlone(t *testing.T) { + ctx := context.Background() + cfg := Config{ + GateRepo: "o/gate", + Scope: []string{"zeta-org", "alpha-org"}, + Host: "h", + Bot: "coderabbitai[bot]", + ReviewCommand: "@coderabbitai review", + LeaderTTL: time.Minute, + AutoReviewMaxScan: 10, + } + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + if err := svc.AutoReview(ctx, AutoOptions{Once: true, Incremental: true}); err != nil { + t.Fatal(err) + } + if got := strings.Join(svc.cfg.Scope, ","); got != "zeta-org,alpha-org" { + t.Fatalf("Scope = %q, want the configured order untouched", got) + } +} + func TestAutoReviewScanSkipsMarkedPRs(t *testing.T) { ctx := context.Background() cfg := Config{ diff --git a/internal/crq/watch.go b/internal/crq/watch.go index c65a271c..2e613108 100644 --- a/internal/crq/watch.go +++ b/internal/crq/watch.go @@ -281,7 +281,30 @@ func (s *Service) watchPass(ctx context.Context, opts WatchOptions, pool *dispat // the shared quota. Next is a MUTATING oracle — it enqueues and can // fire — so the marker has to be honoured before calling it, not // after. - if cfg.SkipsReview(pull.Body) { + // + // The policy is re-read here rather than reused from the top of the + // pass. A fleet-wide scan is long, and Next does not enforce the body + // marker itself (it is the manual path), so a marker the fleet adopted + // mid-pass would otherwise spend a metered review on every candidate + // still to come in it. Rereading costs a conditional GET on the ETag'd + // state ref, against a call that already re-observes the PR. + live, _, lerr := s.store.Load(ctx) + if lerr != nil { + if _, throttled := ghapi.ThrottleWait(lerr); throttled { + return lerr + } + // Next reads the same ref and would fail the same way; treat it + // like any other unreadable candidate rather than firing under a + // policy crq could not confirm. + if s.log != nil { + s.log.Printf("watch: %s#%d: %v", repo, pull.Number, lerr) + } + if opts.Once { + failures = append(failures, fmt.Sprintf("%s#%d: %v", repo, pull.Number, lerr)) + } + continue + } + if s.fleetCfg(live).SkipsReview(pull.Body) { event := WatchEvent{ Repo: repo, PR: pull.Number, Action: "skipped", Reason: "fleet auto-review skip marker present", diff --git a/internal/crq/watch_test.go b/internal/crq/watch_test.go index c9f56354..4e3cf276 100644 --- a/internal/crq/watch_test.go +++ b/internal/crq/watch_test.go @@ -1124,3 +1124,61 @@ func TestWatchHonoursTheExcludedRepositories(t *testing.T) { t.Error("excluding one repository stopped the rest being watched") } } + +// loadHookedStore runs a hook once, immediately AFTER the first Load returns — +// the window between a pass reading the fleet policy and acting on the +// candidates it collected under it. +type loadHookedStore struct { + StateStore + hook func() +} + +func (l *loadHookedStore) Load(ctx context.Context) (State, Revision, error) { + st, rev, err := l.StateStore.Load(ctx) + if l.hook != nil { + hook := l.hook + l.hook = nil + hook() + } + return st, rev, err +} + +// A fleet-wide scan is long, and `Next` is a mutating oracle that does not +// enforce the body marker itself. Reusing the policy read at the top of the +// pass therefore spent a metered review on every candidate still to come after +// the fleet opted them out. +func TestWatchRereadsTheSkipMarkerBeforeEachCandidate(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"owner/repo": true} + gh := newFakeGitHub() + var pull ghapi.Pull + pull.State, pull.Number, pull.Head.SHA = "open", 1, "aaaaaaaa1" + pull.Body = "Tiny change.\n\n" + gh.pulls[fakeKey("owner/repo", 1)] = pull + + store := NewMemoryStore(cfg) + hooked := &loadHookedStore{StateStore: store} + svc := NewService(cfg, gh, hooked, nil) + hooked.hook = func() { + if err := svc.SetFleetConfig(ctx, "skip-marker", ""); err != nil { + t.Error(err) + } + } + + var seen []WatchEvent + if err := svc.watchPass(ctx, WatchOptions{}, newDispatchPool(0), + func(e WatchEvent) error { seen = append(seen, e); return nil }); err != nil { + t.Fatal(err) + } + if len(seen) != 1 || seen[0].Action != "skipped" { + t.Fatalf("events = %+v, want the marker adopted mid-pass to skip the candidate", seen) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round("owner/repo", 1); round != nil { + t.Fatalf("round = %+v, want an opted-out PR never queued", round) + } +} From 93ade4ed72ac227a35ba9ef2651f52cc6e024e70 Mon Sep 17 00:00:00 2001 From: kristofferR <481270+kristofferR@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:37:22 +0200 Subject: [PATCH 31/37] Hold fleet policy where a stale copy still decides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three places let a policy value act after it stopped being the fleet's answer, or let one act as more of a change than it is. `crq doctor` named a host variable only when its value differed from the fleet's. One that agrees is the same variable waiting to be fallen back on: `crq config unset` hands the setting back to whatever each host says, and by then there is no recorded value left for the report to compare against, so nothing would ever name it. Report it while the fleet still records the key — and only then, or a machine that has adopted nothing would have every variable it sets called a divergence. Adopting a fleet setting erases the reviewer baseline because the fleet may never have agreed on who reviews. A co-reviewer's command, trigger mode or self-heal grace is not that question: it says how an already-configured bot is asked, reconciliation compares membership, and ordinary updates to those keys reopen nothing. The first `config set` of one reopened every completed round in the fleet and forced a self-heal trigger post on every open PR, for a timing value. `crq watch` gathers its candidates under the policy the pass started with, and `Next` enforces no repository list — it is the manual path. A repository dropped from `repos` mid-pass therefore kept receiving enqueues and metered reviews for the rest of it. The per-candidate reread that already re-checks the skip marker now re-checks the lists too. --- internal/crq/fleetcmd.go | 33 ++++++++++---- internal/crq/fleetconfig.go | 13 +++++- internal/crq/fleetconfig_test.go | 78 ++++++++++++++++++++++++++++++++ internal/crq/watch.go | 45 ++++++++++++++---- internal/crq/watch_test.go | 39 ++++++++++++++++ 5 files changed, 190 insertions(+), 18 deletions(-) diff --git a/internal/crq/fleetcmd.go b/internal/crq/fleetcmd.go index 7bb496c3..926af8f5 100644 --- a/internal/crq/fleetcmd.go +++ b/internal/crq/fleetcmd.go @@ -233,8 +233,8 @@ func (s *Service) updateFleet(ctx context.Context, mutate func(*State) error) (S return st, err } -// FleetDivergence lists the settings whose value on this host differs from what -// the fleet records, for `crq doctor`. +// FleetDivergence lists the settings this host still answers for itself while +// the fleet records them, for `crq doctor`. // // A host that quietly disagrees is the failure this is about: everything looks // healthy on both machines, and a repository is excluded on one and reviewed by @@ -259,9 +259,19 @@ func (s *Service) FleetDivergence(ctx context.Context) ([]string, error) { case item.Error != "": out = append(out, fmt.Sprintf("%s: the fleet's value is one this crq cannot read (%s); using this host's %q", item.Key, item.Error, item.Value)) - case item.HostValue != nil && len(hostEnv) > 0: + case item.Source == "fleet" && item.HostValue != nil && len(hostEnv) > 0: out = append(out, fmt.Sprintf("%s is %q for the fleet, but %s is set to %q on this host; remove it or run crq config set %s", item.Key, item.Value, strings.Join(hostEnv, ", "), *item.HostValue, item.Key)) + case item.Source == "fleet" && len(hostEnv) > 0: + // A host copy that currently AGREES is still worth naming. Nothing + // misbehaves while the fleet records the key — but `crq config unset` + // hands the setting back to whatever each host says, and this host + // then falls back to a value nobody remembers setting, diverging from + // every host without it. That is the same failure, one command later, + // and by then there is no recorded value left to compare against and + // nothing here would report it. + out = append(out, fmt.Sprintf("%s is %q for the fleet and %s is still set to that value on this host; remove it, or unsetting %s silently returns this host to its own copy", + item.Key, item.Value, strings.Join(hostEnv, ", "), item.Key)) } } return out, nil @@ -348,20 +358,27 @@ func (c fleetChange) adopts(key string) bool { // removed, so a policy the fleet is only now recording is reconciled as the // change it is for everyone else. // -// A reviewer key drops the whole reviewer set: another host may have completed a -// head without a bot the adoption makes fleet-required, and that host's -// completed round is the dedup marker that would keep the newly required bot -// from ever being triggered. It is conservative, not expensive — a reopened +// A reviewer MEMBERSHIP key drops the whole reviewer set: another host may have +// completed a head without a bot the adoption makes fleet-required, and that +// host's completed round is the dedup marker that would keep the newly required +// bot from ever being triggered. It is conservative, not expensive — a reopened // round the primary already answered dedupes at DecideFire's already-reviewed // gate instead of buying a second review, and a repository that pins its own // reviewers still decides its effective set, so its rounds compare equal and are // left alone. Adopting `exclude` likewise drops the baseline exclusions, so a // repository this host already skipped still has to pass the claimed-trigger // refusal that the rest of the fleet never applied to it. +// +// The per-bot keys are deliberately not in that set. Adopting a co-reviewer's +// command, trigger mode or self-heal grace moves nobody in or out of the +// reviewer set, and reconciliation compares membership — so erasing the baseline +// for one would reopen every completed round in the fleet and force a self-heal +// trigger post on every open PR, for a timing value. Ordinary updates to the +// same key reopen nothing, and adoption has no more to reconcile than they do. func (c fleetChange) baseline() Config { before := c.before for _, key := range c.adopted { - if isReviewerFleetKey(key) { + if isReviewerMembershipFleetKey(key) { before.RequiredBots, before.CoBots, before.Reviewers = nil, nil, nil } if key == "exclude" { diff --git a/internal/crq/fleetconfig.go b/internal/crq/fleetconfig.go index a8378ea8..e42c6a31 100644 --- a/internal/crq/fleetconfig.go +++ b/internal/crq/fleetconfig.go @@ -430,7 +430,18 @@ const feedbackBotsKey = "feedback-bots" // co-reviewer is driven. Those are the ones whose derived views have to be // rebuilt, and whose changes existing rounds may have to be reconciled against. func isReviewerFleetKey(key string) bool { - return key == "required-bots" || key == "cobots" || strings.HasPrefix(key, "cobot-") + return isReviewerMembershipFleetKey(key) || strings.HasPrefix(key, "cobot-") +} + +// isReviewerMembershipFleetKey reports whether a setting decides WHO reviews: +// the reviewer set itself, or which of them a round waits for. The per-bot keys +// isReviewerFleetKey also covers only say how crq asks a co-reviewer that is +// already configured — its command, its trigger mode, its self-heal grace — and +// none of them adds or removes a reviewer. +// +// The distinction matters when the fleet adopts a key, see fleetChange.baseline. +func isReviewerMembershipFleetKey(key string) bool { + return key == "required-bots" || key == "cobots" } // FleetKeys lists every setting the fleet owns, in a stable order. diff --git a/internal/crq/fleetconfig_test.go b/internal/crq/fleetconfig_test.go index 64f78df4..68c0a062 100644 --- a/internal/crq/fleetconfig_test.go +++ b/internal/crq/fleetconfig_test.go @@ -594,6 +594,47 @@ func TestFirstFleetReviewerValueReopensCompletedRounds(t *testing.T) { } } +// Adoption erases the reviewer baseline because the fleet may never have agreed +// on WHO reviews. A co-reviewer's self-heal grace is not that question: it is +// how long an already-configured bot is given, every host was driving the same +// set before and after, and reconciliation compares membership. Erasing the +// baseline for it reopened every completed round in the fleet and forced a +// self-heal trigger post on every open PR, for a timing value. +func TestFirstFleetCoBotTimingValueLeavesCompletedRoundsAlone(t *testing.T) { + ctx := context.Background() + cfg := isolatedConfig(t, map[string]string{ + "CRQ_COBOTS": "codex,bugbot", + "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", + "CRQ_COBOT_BUGBOT_CMD": "bugbot run", + "CRQ_COBOT_BUGBOT_TRIGGER": "selfheal", + }) + repo, pr := "owner/repo", 7 + gh := newFakeGitHub() + gh.searchPRs = []ghapi.SearchPR{{Repo: repo, Number: pr}} + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + // Who reviews is already the fleet's answer; only the timing is not. + st.SetFleetValue("cobots", "codex,bugbot") + st.SetFleetValue("required-bots", "coderabbitai[bot]") + st.PutRound(Round{Repo: repo, PR: pr, Head: "abcdef123", Phase: PhaseCompleted}) + return nil + }); err != nil { + t.Fatal(err) + } + + if err := NewService(cfg, gh, store, nil).SetFleetConfig(ctx, "cobot-bugbot-grace", "10m"); err != nil { + t.Fatal(err) + } + st, _, _ := store.Load(ctx) + round := st.Round(repo, pr) + if round == nil || round.Phase != PhaseCompleted { + t.Fatalf("round = %+v, want the completed round left alone by a timing-only adoption", round) + } + if len(round.ForceCoReviewers) != 0 { + t.Errorf("ForceCoReviewers = %v, want no trigger forced by a timing-only adoption", round.ForceCoReviewers) + } +} + // Adopting an exclusion this host already applied is a change for every host // that did not, so it still has to pass the claimed-trigger refusal. func TestFirstFleetExcludeStillRefusesAClaimedTriggerPost(t *testing.T) { @@ -714,6 +755,43 @@ func TestFleetDivergenceIncludesAnExplicitlyEmptyHostValue(t *testing.T) { } } +// A host copy that agrees with the fleet is the same variable waiting to be +// fallen back on, and `crq config unset` is what makes it one: the setting goes +// back to whatever each host says, and by then there is no recorded value left +// for this report to compare against. A setting the fleet does not record is not +// that — it is simply this host's to answer, and saying so would name every +// variable on a machine that has yet to adopt anything. +func TestFleetDivergenceNamesAHostCopyThatAgrees(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.MinInterval = 5 * time.Minute + cfg.ExplicitFleetEnv = map[string]bool{"CRQ_MIN_INTERVAL": true} + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + got, err := svc.FleetDivergence(ctx) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("divergence = %v, want nothing reported for a setting the fleet leaves to this host", got) + } + + if _, err := store.Update(ctx, func(st *State) error { + st.SetFleetValue("min-interval", "5m") + return nil + }); err != nil { + t.Fatal(err) + } + got, err = svc.FleetDivergence(ctx) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || !strings.Contains(got[0], "CRQ_MIN_INTERVAL") { + t.Fatalf("divergence = %v, want the matching host copy named", got) + } +} + // A policy a host feeds from a legacy alias or a per-bot key diverges exactly as // the canonical variable does — and the remedy names the variable that is // actually there, since that is the one still waiting to be fallen back on. diff --git a/internal/crq/watch.go b/internal/crq/watch.go index 2e613108..e77d7c15 100644 --- a/internal/crq/watch.go +++ b/internal/crq/watch.go @@ -177,6 +177,26 @@ func (s *Service) Watch(ctx context.Context, opts WatchOptions, emit func(WatchE } } +// watchesRepo reports whether the policy in hand still covers this repository, +// the same two questions the pass asked when it gathered its candidates: +// `exclude` means "crq does not go here" everywhere, and the fleet's repository +// list decides the set whenever the pass had to build one. Repositories named on +// the command line are the operator's own request and outrank the list — but not +// the exclusion, exactly as at the top of the pass. +// +// An empty list is the fleet having no list, not a list that allows nothing: +// without one crq scans by scope, which is how autoReviewPass reads it too. +func watchesRepo(cfg Config, explicit []string, repo string) bool { + key := NormalizeRepo(repo) + if cfg.ExcludeRepos[key] { + return false + } + if len(explicit) > 0 || len(cfg.AllowRepos) == 0 { + return true + } + return cfg.AllowRepos[key] +} + func (s *Service) watchPass(ctx context.Context, opts WatchOptions, pool *dispatchPool, emit func(WatchEvent) error) error { var failures []string state, _, err := s.store.Load(ctx) @@ -278,16 +298,19 @@ func (s *Service) watchPass(ctx context.Context, opts WatchOptions, pool *dispat return err } // The fleet's skip marker suppresses review deliberately, to protect - // the shared quota. Next is a MUTATING oracle — it enqueues and can - // fire — so the marker has to be honoured before calling it, not - // after. + // the shared quota, and its repository lists say where crq goes at + // all. Next is a MUTATING oracle — it enqueues and can fire — and it + // enforces neither, because it is the manual path: its target is a + // request a person made. So both have to be honoured before calling + // it, not after. // // The policy is re-read here rather than reused from the top of the - // pass. A fleet-wide scan is long, and Next does not enforce the body - // marker itself (it is the manual path), so a marker the fleet adopted - // mid-pass would otherwise spend a metered review on every candidate - // still to come in it. Rereading costs a conditional GET on the ETag'd - // state ref, against a call that already re-observes the PR. + // pass. A fleet-wide scan is long, so a marker the fleet adopted — or + // a repository it dropped — mid-pass would otherwise spend a metered + // review on every candidate still to come in it, gathered under the + // policy the pass started with. Rereading costs a conditional GET on + // the ETag'd state ref, against a call that already re-observes the + // PR. live, _, lerr := s.store.Load(ctx) if lerr != nil { if _, throttled := ghapi.ThrottleWait(lerr); throttled { @@ -304,7 +327,11 @@ func (s *Service) watchPass(ctx context.Context, opts WatchOptions, pool *dispat } continue } - if s.fleetCfg(live).SkipsReview(pull.Body) { + liveCfg := s.fleetCfg(live) + if !watchesRepo(liveCfg, opts.Repos, repo) { + continue + } + if liveCfg.SkipsReview(pull.Body) { event := WatchEvent{ Repo: repo, PR: pull.Number, Action: "skipped", Reason: "fleet auto-review skip marker present", diff --git a/internal/crq/watch_test.go b/internal/crq/watch_test.go index 4e3cf276..b5690b5f 100644 --- a/internal/crq/watch_test.go +++ b/internal/crq/watch_test.go @@ -1182,3 +1182,42 @@ func TestWatchRereadsTheSkipMarkerBeforeEachCandidate(t *testing.T) { t.Fatalf("round = %+v, want an opted-out PR never queued", round) } } + +// The candidates are gathered under the policy the pass started with, and `Next` +// treats its target as a manual request — so a repository the fleet dropped +// mid-pass still had its PRs enqueued and reviewed by the pass that was already +// running, in a repository crq no longer watches. +func TestWatchRereadsTheRepositoryListBeforeEachCandidate(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"owner/repo": true} + gh := newFakeGitHub() + var pull ghapi.Pull + pull.State, pull.Number, pull.Head.SHA = "open", 1, "aaaaaaaa1" + gh.pulls[fakeKey("owner/repo", 1)] = pull + + store := NewMemoryStore(cfg) + hooked := &loadHookedStore{StateStore: store} + svc := NewService(cfg, gh, hooked, nil) + hooked.hook = func() { + if err := svc.SetFleetConfig(ctx, "repos", "owner/other"); err != nil { + t.Error(err) + } + } + + var seen []WatchEvent + if err := svc.watchPass(ctx, WatchOptions{}, newDispatchPool(0), + func(e WatchEvent) error { seen = append(seen, e); return nil }); err != nil { + t.Fatal(err) + } + if len(seen) != 0 { + t.Fatalf("events = %+v, want no work in a repository the fleet dropped mid-pass", seen) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round("owner/repo", 1); round != nil { + t.Fatalf("round = %+v, want an unwatched repository never queued", round) + } +} From 02a094ff171f9abf6d347df837f831a758ec5929 Mon Sep 17 00:00:00 2001 From: kristofferR <481270+kristofferR@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:05:11 +0200 Subject: [PATCH 32/37] Keep a policy decision from outliving what it was read for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four places where a fleet value was read too widely, or too narrowly, and the result was work crq should not have done. A co-reviewer's command, trigger mode or self-heal grace moves no reviewer membership, so reconciliation compares the same sets before and after and never reads the open-PR map that was built for it. Building one anyway spent a REST lookup on every repository ever recorded in Rounds, and made a purely local timing update fail whenever one of those historical lookups was throttled. The calibration write refused itself whenever ANY fleet setting moved under the read, but only the scope says which account the probe answered for. Update reports a refused write as success, so a still-standing account block the probe had just found was dropped and the same pump went on to post a metered review inside that window. Only a scope that moved voids the reading now, and that case already resets the quota. `crq watch` read an emptied repository list as "no list, so watch everything". It has no scope fallback — a pass with no repositories of its own refuses to run — so an emptied list meant the candidates of the pass already running were the only repositories crq would act on, each one no longer covered by the live policy, dispatching fixes for a repository the fleet had just dropped. Installing the drain froze every co-reviewer's resolved trigger into the unit, explicit or not. On the default optional Codex that writes `never`, which the reloaded service reads as an operator's choice — and a later fleet `required-bots` change can then never promote it to its registry-required `always`, leaving a required reviewer uncommanded and its round waiting forever. --- internal/crq/drain.go | 15 ++++++++- internal/crq/drain_test.go | 41 ++++++++++++++++++++++++- internal/crq/fleetcmd.go | 16 ++++++++-- internal/crq/fleetconfig_test.go | 36 ++++++++++++++++++++++ internal/crq/service.go | 12 +++++++- internal/crq/service_test.go | 42 ++++++++++++++++++++++++++ internal/crq/watch.go | 9 ++++-- internal/crq/watch_test.go | 52 ++++++++++++++++++++++++++++++++ 8 files changed, 214 insertions(+), 9 deletions(-) diff --git a/internal/crq/drain.go b/internal/crq/drain.go index f95a73e0..39cb07e6 100644 --- a/internal/crq/drain.go +++ b/internal/crq/drain.go @@ -424,7 +424,20 @@ func drainEnvFor(cfg Config, plan DrainInstall) map[string]string { coNames = append(coNames, co.Name) prefix := "CRQ_COBOT_" + strings.ToUpper(co.Name) env[prefix+"_CMD"] = co.Command - env[prefix+"_TRIGGER"] = string(co.Trigger) + // The explicitness bit has to survive the install, not just the mode. An + // implicit trigger is the registry default for how the bot is REQUIRED, + // recomputed whenever that changes; writing it out as a value makes the + // service read it back as an operator's explicit choice, and a later + // fleet `required-bots` change can then no longer promote the bot to its + // required trigger — leaving a required reviewer that is never commanded + // and a round that waits for it forever. Empty is the environment's + // "unset", and it is written rather than omitted so an inherited variable + // cannot supply an explicitness the installing host did not have. + trigger := "" + if co.TriggerExplicit { + trigger = string(co.Trigger) + } + env[prefix+"_TRIGGER"] = trigger env[prefix+"_REQUIRED"] = strconv.FormatBool(co.Required) env[prefix+"_GRACE"] = co.SelfHealGrace.String() } diff --git a/internal/crq/drain_test.go b/internal/crq/drain_test.go index 62f586e1..716d11bf 100644 --- a/internal/crq/drain_test.go +++ b/internal/crq/drain_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/kristofferR/coderabbit-queue/internal/dialect" "github.com/kristofferR/coderabbit-queue/internal/engine" ) @@ -266,7 +267,8 @@ func TestDrainUnitCarriesEffectiveReviewerConfiguration(t *testing.T) { cfg.SettleWindow = 17 * time.Second cfg.CoBots = []CoBotConfig{{ Name: "bugbot", Login: "cursor[bot]", Command: "bugbot run now", - Trigger: engine.TriggerAlways, Required: true, SelfHealGrace: 4 * time.Minute, + Trigger: engine.TriggerAlways, TriggerExplicit: true, + Required: true, SelfHealGrace: 4 * time.Minute, }} svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) plan := DrainInstall{Platform: "linux", Wrapper: "/tmp/crq drain", LogDir: "/tmp/crq logs"} @@ -297,6 +299,43 @@ func TestDrainUnitCarriesEffectiveReviewerConfiguration(t *testing.T) { } } +// An implicit trigger must install as unset. Freezing the resolved mode into the +// unit makes the service read it as an operator's choice, and the registry +// default for a bot the fleet later REQUIRES can then never be recomputed — so a +// required Codex would be waited for and never commanded. +func TestDrainEnvInstallsAnImplicitTriggerAsUnset(t *testing.T) { + cfg := firingConfig() + cfg.CoBots = []CoBotConfig{{ + Name: "codex", Login: dialect.CodexBotLogin, Command: "@codex review", + Trigger: engine.TriggerNever, SelfHealGrace: 10 * time.Minute, + }} + svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) + + env := svc.drainEnv(DrainInstall{}) + trigger, ok := env["CRQ_COBOT_CODEX_TRIGGER"] + if !ok || trigger != "" { + t.Fatalf("CRQ_COBOT_CODEX_TRIGGER = %q, present=%t; want an explicit empty value", trigger, ok) + } + // The installed service reloads from this environment: the mode has to come + // back implicit, so requiring the bot still promotes it to always. + reloaded := resolveCoBot(env, mustCoReviewer(t, "codex"), true) + if reloaded.TriggerExplicit { + t.Fatal("reloaded trigger is explicit; the installing host had no such setting") + } + if reloaded.Trigger != engine.TriggerAlways { + t.Fatalf("required codex reloaded with trigger %q, want always", reloaded.Trigger) + } +} + +func mustCoReviewer(t *testing.T, name string) dialect.CoReviewer { + t.Helper() + co, ok := dialect.CoReviewerByName(name) + if !ok { + t.Fatalf("unknown co-reviewer %q", name) + } + return co +} + func TestDrainEnvCarriesAnIntentionallyEmptySkipMarker(t *testing.T) { cfg := firingConfig() cfg.SkipMarker = "" diff --git a/internal/crq/fleetcmd.go b/internal/crq/fleetcmd.go index 926af8f5..c0860a46 100644 --- a/internal/crq/fleetcmd.go +++ b/internal/crq/fleetcmd.go @@ -303,8 +303,17 @@ func (s *Service) requireNoAdvancedDrivers(st *State, key string) error { return nil } +// prepareFleetReviewerChange fetches the open PRs reconciliation needs, for the +// keys that can actually move a round. +// +// Only MEMBERSHIP asks that question. reopenForChangedReviewers compares the +// required and co-reviewer login sets and returns before touching this map when +// they match, and a co-reviewer's command, trigger mode or self-heal grace never +// moves either — so asking for the open PRs of every repository ever recorded in +// Rounds bought nothing, spent a REST lookup per historical repository, and made +// a purely local timing update fail whenever one of them was throttled. func (s *Service) prepareFleetReviewerChange(ctx context.Context, key string) (map[string]map[int]bool, error) { - if !isReviewerFleetKey(key) { + if !isReviewerMembershipFleetKey(key) { return nil, nil } st, _, err := s.store.Load(ctx) @@ -316,10 +325,11 @@ func (s *Service) prepareFleetReviewerChange(ctx context.Context, key string) (m // seedsReviewers reports whether seeding would record a reviewer setting the // fleet has no answer for yet, which is the case where the effective reviewers -// can move and existing rounds have to be reconciled against them. +// can move and existing rounds have to be reconciled against them. Same +// membership-only question as prepareFleetReviewerChange, for the same reason. func seedsReviewers(st State) bool { for _, key := range FleetKeys() { - if !isReviewerFleetKey(key) { + if !isReviewerMembershipFleetKey(key) { continue } if _, ok := st.FleetValue(key); !ok { diff --git a/internal/crq/fleetconfig_test.go b/internal/crq/fleetconfig_test.go index 68c0a062..2b0aef6f 100644 --- a/internal/crq/fleetconfig_test.go +++ b/internal/crq/fleetconfig_test.go @@ -1011,6 +1011,42 @@ func TestFleetReviewerChangeDoesNotFailOnAnInaccessibleHistoricalRepository(t *t } } +// A co-reviewer's command, trigger mode or self-heal grace moves no membership, +// so reconciliation compares the same reviewer sets before and after and never +// reads the open-PR map. Building one anyway spent a REST lookup on every +// repository ever recorded in Rounds — and made a purely local timing update +// fail whenever one of those historical lookups was throttled. +func TestFleetCoBotTimingChangeScansNoPullRequests(t *testing.T) { + ctx := context.Background() + cfg := isolatedConfig(t, map[string]string{ + "CRQ_COBOTS": "codex,bugbot", + "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", + }) + gh := newFakeGitHub() + gh.listPullErrs["owner/repo"] = &ghapi.RateLimitError{Kind: "secondary"} + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.PutRound(Round{Repo: "owner/repo", PR: 7, Head: "bbbbbbb22", Phase: PhaseCompleted}) + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, gh, store, nil) + for _, key := range []string{"cobot-bugbot-grace", "cobot-bugbot-cmd", "cobot-bugbot-trigger"} { + value := map[string]string{ + "cobot-bugbot-grace": "12m", + "cobot-bugbot-cmd": "bugbot run", + "cobot-bugbot-trigger": "selfheal", + }[key] + if err := svc.SetFleetConfig(ctx, key, value); err != nil { + t.Fatalf("set %s: %v", key, err) + } + if _, err := svc.UnsetFleetConfig(ctx, key); err != nil { + t.Fatalf("unset %s: %v", key, err) + } + } +} + func TestFleetReviewerChangePropagatesGitHubThrottling(t *testing.T) { ctx := context.Background() cfg := isolatedConfig(t, map[string]string{ diff --git a/internal/crq/service.go b/internal/crq/service.go index f71fbb6f..5feddf2a 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -1939,7 +1939,17 @@ func (s *Service) RefreshQuota(ctx context.Context) (State, error) { } updated, err := s.store.Update(ctx, func(st *State) error { current := s.fleetCfg(*st) - if current.FleetRevision != cfg.FleetRevision { + // Only the scope invalidates the reading: it says WHICH account the probe + // answered for, and a scope that moved under the read already reset the + // quota to be recalibrated, so writing the old account's answer over that + // reset would state a window for an account crq no longer queues. + // + // Every other fleet setting leaves the reading true, and refusing it over + // one — as a whole-revision comparison did — discarded a still-standing + // account block, since Update reports a refused write as success: this + // same pump then went on to post a metered review inside the blocked + // window it had just been told about. + if !sameFoldedSet(current.Scope, cfg.Scope) { return ErrNoChange } currentTTL := current.CalibrationTTL diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index 08a173cc..b146ded8 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -2532,6 +2532,48 @@ func TestRefreshQuotaRejectsAReadingFromAnObsoleteFleetScope(t *testing.T) { } } +// Only the scope says which account a probe answered for. Refusing the write +// because some unrelated fleet setting moved under the read dropped the block +// the probe had just found — and Update reports a refused write as success, so +// the same pump went on to post a metered review inside that window. +func TestRefreshQuotaRecordsABlockAcrossAnUnrelatedFleetChange(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.CalibrationPR = 77 + cfg.GateRepo = "o/state" + cfg.CalibrationTTL = time.Minute + gh := newFakeGitHub() + inner := NewMemoryStore(cfg) + store := &hookedStore{StateStore: inner} + svc := NewService(cfg, gh, store, nil) + now := time.Now().UTC() + svc.now = func() time.Time { return now } + + reply := ghapi.IssueComment{ + ID: 5, + Body: "auto-generated reply by CodeRabbit\n> **Next review available in:** **45 minutes**", + CreatedAt: now.Add(-10 * time.Second), UpdatedAt: now.Add(-10 * time.Second), + } + reply.User.Login = cfg.Bot + gh.comments[fakeKey(cfg.GateRepo, 77)] = []ghapi.IssueComment{reply} + store.hook = func() { + if _, err := inner.Update(ctx, func(st *State) error { + st.SetFleetValue("min-interval", "45m") + return nil + }); err != nil { + t.Error(err) + } + } + + updated, err := svc.RefreshQuota(ctx) + if err != nil { + t.Fatal(err) + } + if updated.Account.BlockedUntil == nil || !updated.Account.BlockedUntil.After(now.Add(40*time.Minute)) { + t.Fatalf("account = %+v, want the probe's block recorded despite the unrelated policy change", updated.Account) + } +} + func TestRefreshQuotaDoesNotProbeOrWriteInDryRun(t *testing.T) { ctx := context.Background() cfg := Config{ diff --git a/internal/crq/watch.go b/internal/crq/watch.go index e77d7c15..5b1d5374 100644 --- a/internal/crq/watch.go +++ b/internal/crq/watch.go @@ -184,14 +184,17 @@ func (s *Service) Watch(ctx context.Context, opts WatchOptions, emit func(WatchE // the command line are the operator's own request and outrank the list — but not // the exclusion, exactly as at the top of the pass. // -// An empty list is the fleet having no list, not a list that allows nothing: -// without one crq scans by scope, which is how autoReviewPass reads it too. +// An emptied list is not "watch everything" here. Unlike autoReviewPass, which +// falls back to scanning by scope, a pass with no repositories of its own has +// nothing to watch at all and refuses to run — so a candidate gathered under the +// old list is one the live policy no longer covers, and treating the empty list +// as permissive would dispatch a fix for a repository the fleet just dropped. func watchesRepo(cfg Config, explicit []string, repo string) bool { key := NormalizeRepo(repo) if cfg.ExcludeRepos[key] { return false } - if len(explicit) > 0 || len(cfg.AllowRepos) == 0 { + if len(explicit) > 0 { return true } return cfg.AllowRepos[key] diff --git a/internal/crq/watch_test.go b/internal/crq/watch_test.go index b5690b5f..b8b6e247 100644 --- a/internal/crq/watch_test.go +++ b/internal/crq/watch_test.go @@ -1221,3 +1221,55 @@ func TestWatchRereadsTheRepositoryListBeforeEachCandidate(t *testing.T) { t.Fatalf("round = %+v, want an unwatched repository never queued", round) } } + +// Emptying the list is the same removal. `crq watch` has no scope fallback — a +// pass with no repositories of its own refuses to run at all — so an emptied +// list left the candidates of the pass already running as the only repositories +// crq would ever act on, each one no longer covered by the live policy. +func TestWatchDropsCandidatesWhenTheRepositoryListIsEmptied(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"owner/repo": true} + gh := newFakeGitHub() + var pull ghapi.Pull + pull.State, pull.Number, pull.Head.SHA = "open", 1, "aaaaaaaa1" + gh.pulls[fakeKey("owner/repo", 1)] = pull + + store := NewMemoryStore(cfg) + hooked := &loadHookedStore{StateStore: store} + svc := NewService(cfg, gh, hooked, nil) + hooked.hook = func() { + if err := svc.SetFleetConfig(ctx, "repos", ""); err != nil { + t.Error(err) + } + } + + var seen []WatchEvent + if err := svc.watchPass(ctx, WatchOptions{}, newDispatchPool(0), + func(e WatchEvent) error { seen = append(seen, e); return nil }); err != nil { + t.Fatal(err) + } + if len(seen) != 0 { + t.Fatalf("events = %+v, want no work once the fleet's list is emptied mid-pass", seen) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if round := st.Round("owner/repo", 1); round != nil { + t.Fatalf("round = %+v, want an unwatched repository never queued", round) + } +} + +// A repository named on the command line is the operator's own request and +// outranks the fleet's list, emptied or not — only the exclusion overrides it. +func TestWatchesRepoHonoursExplicitTargetsAndExclusion(t *testing.T) { + empty := Config{} + if !watchesRepo(empty, []string{"owner/repo"}, "owner/repo") { + t.Error("an explicitly requested repository must stay watched with no fleet list") + } + excluded := Config{ExcludeRepos: map[string]bool{"owner/repo": true}} + if watchesRepo(excluded, []string{"owner/repo"}, "owner/repo") { + t.Error("exclusion outranks an explicit target") + } +} From 342671178e9adbf3ad47472354bf117e938083aa Mon Sep 17 00:00:00 2001 From: kristofferR <481270+kristofferR@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:26:45 +0200 Subject: [PATCH 33/37] Spend no repository scan on a fleet setting that is not moving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An idempotent `crq config set required-bots ` fetched the open PRs of every repository ever recorded in Rounds before discovering it had nothing to change, and `crq config unset` scanned before discovering the key was absent. On an old fleet that is a REST lookup per historical repository for a no-op, and it fails outright when one of them is throttled. Settle the recorded value first; the CAS re-reads it, so a concurrent write between the two leaves the open-PR map nil rather than wrong — the degraded case an inaccessible repository already produces. Validate a scope entry as an owner login, as repos and exclude already validate slugs. autoreview hands each scope target to EachOpenPR as a user or organisation name, so `owner/repo` typed here fails every pass — and being fleet-wide, it stops scanning on every host at once. Say why the primary reviewer is not fleet policy. Who it is and the wording crq reads it by are one unit, compiled into the dialect classifiers before any state ref is read; recording the login and command while the markers stayed per-host is the half-applied policy applyFleet refuses everywhere else. --- README.md | 5 ++++ internal/crq/drainswitch.go | 36 ++++++++++++++------------- internal/crq/fleetcmd.go | 25 ++++++++++++++++--- internal/crq/fleetconfig.go | 35 ++++++++++++++++++++++++-- internal/crq/fleetconfig_test.go | 42 ++++++++++++++++++++++++++++++++ 5 files changed, 121 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 5fcefbc6..e75655c2 100644 --- a/README.md +++ b/README.md @@ -513,6 +513,11 @@ What stays per host: where the state lives (`CRQ_REPO`, `CRQ_ISSUE`, `CRQ_STATE_ and what the machine can physically do (`CRQ_DISPATCH_CMD`, `CRQ_WORKSPACE`, `CRQ_DISPATCH_CONCURRENCY`). +The primary reviewer stays per host too, for a different reason: who it is (`CRQ_BOT`) and the +wording crq reads it by (`CRQ_REVIEW_CMD` and the completion/rate-limit/review-done/calibration +markers) are one unit, and they are compiled into the dialect classifiers before any state ref is +read. Keep them the same on every host — `crq doctor` cannot report a disagreement about them. + | Variable | Default | What it does | |----------|---------|--------------| diff --git a/internal/crq/drainswitch.go b/internal/crq/drainswitch.go index 0d0f2902..c951400c 100644 --- a/internal/crq/drainswitch.go +++ b/internal/crq/drainswitch.go @@ -103,27 +103,29 @@ func (s *Service) DrainSettings(ctx context.Context) ([]DrainSetting, error) { // the hazard is a setting recorded under a name nothing will ever match. func validRepoSlug(repo string) bool { owner, name, ok := strings.Cut(repo, "/") - if !ok || owner == "" || name == "" { + return ok && validNameSegment(owner) && validNameSegment(name) +} + +// validNameSegment reports whether part is one segment a GitHub owner or +// repository name could be. +// +// Same segment rules the workspace package applies to a path: "." and ".." are +// not names. The character class is GitHub's: an owner or repository name is +// letters, digits, hyphen, underscore and dot, so a segment holding anything +// else — a space, a slash, a control character — is one no scan result can ever +// normalize to. Recorded, it reads as a rule covering something while covering +// nothing at all. +func validNameSegment(part string) bool { + if part == "" || part == "." || part == ".." { return false } - // Same segment rules the workspace package applies to a path: "." and ".." - // are not repository names. The character class is GitHub's: an owner or - // repository name is letters, digits, hyphen, underscore and dot, so a slug - // holding anything else — a space, a second slash, a control character — is - // one no scan result can ever normalize to. Recorded, it reads as a rule - // covering a repository while covering nothing at all. - for _, part := range []string{owner, name} { - if part == "." || part == ".." { + for _, r := range part { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + case r == '-', r == '_', r == '.': + default: return false } - for _, r := range part { - switch { - case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': - case r == '-', r == '_', r == '.': - default: - return false - } - } } return true } diff --git a/internal/crq/fleetcmd.go b/internal/crq/fleetcmd.go index c0860a46..f2401f02 100644 --- a/internal/crq/fleetcmd.go +++ b/internal/crq/fleetcmd.go @@ -88,7 +88,10 @@ func (s *Service) SetFleetConfig(ctx context.Context, key, value string) error { if err := ValidateFleetSetting(key, value); err != nil { return err } - open, err := s.prepareFleetReviewerChange(ctx, key) + open, err := s.prepareFleetReviewerChange(ctx, key, func(st State) bool { + current, recorded := st.FleetValue(key) + return !recorded || current != value + }) if err != nil { return err } @@ -124,7 +127,10 @@ func (s *Service) UnsetFleetConfig(ctx context.Context, key string) (bool, error if _, ok := fleetSettings()[key]; ok { known = true } - open, err := s.prepareFleetReviewerChange(ctx, key) + open, err := s.prepareFleetReviewerChange(ctx, key, func(st State) bool { + _, recorded := st.FleetValue(key) + return recorded + }) if err != nil { return false, err } @@ -312,7 +318,17 @@ func (s *Service) requireNoAdvancedDrivers(st *State, key string) error { // moves either — so asking for the open PRs of every repository ever recorded in // Rounds bought nothing, spent a REST lookup per historical repository, and made // a purely local timing update fail whenever one of them was throttled. -func (s *Service) prepareFleetReviewerChange(ctx context.Context, key string) (map[string]map[int]bool, error) { +// +// A membership key that is not actually moving asks it just as pointlessly, and +// `changes` is what settles that against the recorded value before anything is +// spent: re-recording the value the fleet already holds, or unsetting a key it +// never held, reconciles nothing, and without this an idempotent command still +// cost a lookup per historical repository and still failed under throttling. The +// recorded value is read again inside the CAS, so a concurrent write landing +// between the two leaves `open` nil rather than wrong — the same degraded case an +// inaccessible repository already produces, where a completed round is marked +// rather than reopened and the next enqueue that finds the PR alive reopens it. +func (s *Service) prepareFleetReviewerChange(ctx context.Context, key string, changes func(State) bool) (map[string]map[int]bool, error) { if !isReviewerMembershipFleetKey(key) { return nil, nil } @@ -320,6 +336,9 @@ func (s *Service) prepareFleetReviewerChange(ctx context.Context, key string) (m if err != nil { return nil, err } + if !changes(st) { + return nil, nil + } return s.openFleetPRs(ctx, st) } diff --git a/internal/crq/fleetconfig.go b/internal/crq/fleetconfig.go index e42c6a31..46e35af3 100644 --- a/internal/crq/fleetconfig.go +++ b/internal/crq/fleetconfig.go @@ -47,14 +47,27 @@ type fleetSetting struct { // sessions it can take). Everything else is policy, and policy the hosts // disagree about is how a repository ends up excluded on one and reviewed by // another with nothing to say so. +// +// The primary reviewer is the one policy deliberately left out, and it is a +// FOURTH kind rather than an oversight: who the primary is (CRQ_BOT) is +// inseparable from the wording crq reads it by (CRQ_REVIEW_CMD and the +// completion, rate-limit, review-done and calibration markers), and those are +// compiled into the dialect classifiers when the Service is constructed — +// before any state ref has been read. Recording the login and its command while +// the markers stayed per-host would make a host post the fleet's command for a +// bot it classifies with its own wording, which is the half-applied policy +// applyFleet refuses everywhere else. Centralizing it means moving the whole +// family and building the classifiers from the fleet-applied configuration, not +// adding two entries here. func fleetSettings() map[string]fleetSetting { settings := map[string]fleetSetting{ "scope": { Doc: "owners crq scans when no repository list is set", Env: "CRQ_SCOPE", Apply: func(cfg *Config, v string) error { - cfg.Scope = splitList(v) - return nil + owners, err := fleetOwners(v) + cfg.Scope = owners + return err }, Show: func(cfg Config) string { return strings.Join(cfg.Scope, ",") }, }, @@ -506,6 +519,24 @@ func ValidateFleetSetting(key, value string) error { return setting.Apply(&probe, value) } +// fleetOwners refuses a scope entry that is not an owner login — a repository +// slug typed here being the mistake to catch. +// +// autoReviewPass hands every scope target to EachOpenPR as a user or +// organisation name, which resolves it against /users/; one that is +// neither fails the whole pass. Recorded for the fleet, a single typo therefore +// stops scanning on every host at once, which is exactly the reach that makes +// validating it here worth more than validating a per-host variable. +func fleetOwners(value string) ([]string, error) { + owners := splitList(value) + for _, owner := range owners { + if !validNameSegment(owner) { + return nil, fmt.Errorf("scope must be owner or organisation logins, got %q", owner) + } + } + return owners, nil +} + func fleetRepoSet(value string) (map[string]bool, error) { repos := repoSet(value) for repo := range repos { diff --git a/internal/crq/fleetconfig_test.go b/internal/crq/fleetconfig_test.go index 2b0aef6f..757382ed 100644 --- a/internal/crq/fleetconfig_test.go +++ b/internal/crq/fleetconfig_test.go @@ -87,6 +87,15 @@ func TestFleetRefusesWhatItCannotRead(t *testing.T) { } } } + // A scope entry is an owner login, not a slug: autoreview hands each one to + // EachOpenPR as a user or organisation name, so a repository typed here — or + // anything else GitHub cannot resolve — fails every pass, on every host at + // once, because the value is fleet-wide. + for _, owner := range []string{"owner/repo", "own er", "owner?", ".."} { + if err := svc.SetFleetConfig(ctx, "scope", owner); err == nil { + t.Errorf("scope accepted the malformed owner %q", owner) + } + } if err := svc.SetFleetConfig(ctx, "required-bots", ""); err == nil { t.Error("an empty required reviewer set was accepted") } @@ -1047,6 +1056,39 @@ func TestFleetCoBotTimingChangeScansNoPullRequests(t *testing.T) { } } +// An idempotent membership command scans nothing either. Re-recording the value +// the fleet already holds, or unsetting a key it never held, reconciles nothing +// — and a fleet whose round history names many repositories would otherwise +// spend an open-PR lookup on each of them and fail outright when one was +// throttled, for a command with nothing to change. +func TestIdempotentFleetReviewerCommandScansNoPullRequests(t *testing.T) { + ctx := context.Background() + cfg := isolatedConfig(t, map[string]string{ + "CRQ_COBOTS": "codex", + "CRQ_REQUIRED_BOTS": "coderabbitai[bot]", + }) + gh := newFakeGitHub() + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.PutRound(Round{Repo: "owner/repo", PR: 7, Head: "bbbbbbb22", Phase: PhaseCompleted}) + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, gh, store, nil) + if err := svc.SetFleetConfig(ctx, "required-bots", "coderabbitai[bot]"); err != nil { + t.Fatalf("recording required-bots: %v", err) + } + // Only now does the lookup fail, so reaching it at all is the failure. + gh.listPullErrs["owner/repo"] = &ghapi.RateLimitError{Kind: "secondary"} + if err := svc.SetFleetConfig(ctx, "required-bots", "coderabbitai[bot]"); err != nil { + t.Fatalf("repeating the recorded value: %v", err) + } + if dropped, err := svc.UnsetFleetConfig(ctx, "cobots"); err != nil || dropped { + t.Fatalf("unsetting a key the fleet never recorded = (%v, %v), want (false, nil)", dropped, err) + } +} + func TestFleetReviewerChangePropagatesGitHubThrottling(t *testing.T) { ctx := context.Background() cfg := isolatedConfig(t, map[string]string{ From ca3f36ad17300305b54ffc0f4d411f05ebf39451 Mon Sep 17 00:00:00 2001 From: kristofferR <481270+kristofferR@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:37:40 +0200 Subject: [PATCH 34/37] Read fleet state once for a drain preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An authenticated `crq drain install --dry-run` probed the state ref only to decide it should fall through, then paid for the same read again inside InstallDrain. Move the host-only fallback to where the state is actually read, so the preview costs one read: main now answers only the host that cannot get that far — no credentials, or no CRQ_REPO to look in. Also drop the duplicate `crq reviewers` block from `crq help`, and name feedback-bots among the fleet settings `crq config` enumerates. --- cmd/crq/main.go | 89 ++++++++++++++++++++------------------ internal/crq/drain.go | 21 ++++++++- internal/crq/drain_test.go | 33 ++++++++++++++ 3 files changed, 101 insertions(+), 42 deletions(-) diff --git a/cmd/crq/main.go b/cmd/crq/main.go index 038cde0c..0fe68b61 100644 --- a/cmd/crq/main.go +++ b/cmd/crq/main.go @@ -72,37 +72,6 @@ func run(ctx context.Context, args []string) int { return 1 case "preflight": return preflight(ctx, args[1:]) - case "drain": - // An authenticated dry run follows the normal path below so its plan is - // built from the same fleet state as the real install. Keep a host-only - // fallback for somebody who has not configured or authenticated GitHub - // yet, but label it so it cannot be mistaken for the fleet-backed plan. - if opts, perr := parseDrainArgs(args[1:]); perr == nil && opts.dryRun { - cfg, cerr := crq.LoadConfig() - if cerr != nil { - fatal(cerr) - return 1 - } - gh, fleetErr := ghapi.NewGitHub(ctx) - if fleetErr == nil { - fleetErr = cfg.RequireState() - } - if fleetErr == nil { - store := crq.NewGitStateStore(cfg, gh, stderrLogger{}) - _, _, fleetErr = store.Load(ctx) - } - if fleetErr != nil { - plan, ierr := crq.DrainPlan(cfg, opts.agent, crq.SplitArgv(opts.agentArgs), opts.repos, true) - if ierr != nil { - fatal(ierr) - return 1 - } - plan.PolicySource = "host" - plan.Warning = "GitHub fleet state is unavailable; this host-only preview may differ from an authenticated install" - printJSON(plan) - return 0 - } - } } cfg, err := crq.LoadConfig() @@ -112,6 +81,24 @@ func run(ctx context.Context, args []string) int { } gh, err := ghapi.NewGitHub(ctx) if err != nil { + // A dry run is documented as a PREVIEW, so it has to work for somebody + // who has not authenticated yet — otherwise the one command for looking + // at the plan is itself another thing to set up first. An authenticated + // one takes the normal path below, where InstallDrain builds it from the + // same fleet state as the real install (and falls back to this host's + // own plan if that state cannot be read). Every other command needs the + // token this just failed to find. + if args[0] == "drain" { + if opts, perr := parseDrainArgs(args[1:]); perr == nil && opts.dryRun { + plan, ierr := hostOnlyDrainPlan(cfg, opts) + if ierr != nil { + fatal(ierr) + return 1 + } + printJSON(plan) + return 0 + } + } fatal(err) return 1 } @@ -344,8 +331,19 @@ func run(ctx context.Context, args []string) int { return 1 } if err := cfg.RequireState(); err != nil { - fatal(err) - return 1 + // Without CRQ_REPO there is no state ref to read, so a preview here + // is the host-only one InstallDrain would otherwise produce. + if !opts.dryRun { + fatal(err) + return 1 + } + plan, ierr := hostOnlyDrainPlan(cfg, opts) + if ierr != nil { + fatal(ierr) + return 1 + } + printJSON(plan) + return 0 } plan, ierr := service.InstallDrain(ctx, opts.agent, crq.SplitArgv(opts.agentArgs), opts.repos, opts.dryRun) if ierr != nil { @@ -706,10 +704,6 @@ USAGE crq decline [...] --reason "" [--keep-open] reply on a thread to record why a finding is declined (resolves it; --keep-open leaves it open) - crq reviewers which bots review this project (and what each costs) - crq reviewers set [--bots ] [--required ] - choose this project's reviewers (either flag alone) - crq reviewers clear go back to the fleet default crq drain install [--agent ] [--dry-run] [...] crq drain [on|off|default ] which repositories crq may fix (on by default) install and start the unattended review drain @@ -1136,10 +1130,10 @@ window one host respects and another does not — and nothing says so, because each host is behaving correctly according to what it can see. These settings have one answer for the fleet: - scope, repos, exclude, required-bots, cobots, rate-limit-co-degrade, - min-interval, inflight-timeout, rate-limit-fallback, calibrate-ttl, settle, - skip-marker, skip-authors, and per co-reviewer: cobot--trigger, - cobot--cmd, cobot--grace + scope, repos, exclude, required-bots, cobots, feedback-bots, + rate-limit-co-degrade, min-interval, inflight-timeout, rate-limit-fallback, + calibrate-ttl, settle, skip-marker, skip-authors, and per co-reviewer: + cobot--trigger, cobot--cmd, cobot--grace Three kinds of setting deliberately stay local: where the state lives (CRQ_REPO, CRQ_ISSUE, CRQ_STATE_REF — a host cannot read fleet policy until it @@ -1929,6 +1923,19 @@ type drainArgs struct { repos []string } +// hostOnlyDrainPlan is the preview for a host that cannot reach fleet state at +// all: this machine's own answers, labelled so the plan cannot be mistaken for +// the fleet-backed one an authenticated install would write. +func hostOnlyDrainPlan(cfg crq.Config, opts drainArgs) (crq.DrainInstall, error) { + plan, err := crq.DrainPlan(cfg, opts.agent, crq.SplitArgv(opts.agentArgs), opts.repos, true) + if err != nil { + return crq.DrainInstall{}, err + } + plan.PolicySource = "host" + plan.Warning = crq.HostOnlyDrainWarning + return plan, nil +} + // parseDrainArgs is shared by the pre-authentication dry-run path and the // install itself, so the two cannot disagree about what was asked for. func parseDrainArgs(args []string) (drainArgs, error) { diff --git a/internal/crq/drain.go b/internal/crq/drain.go index 39cb07e6..9d46dcd7 100644 --- a/internal/crq/drain.go +++ b/internal/crq/drain.go @@ -22,6 +22,11 @@ import ( //go:embed dispatch/fix-prompt.txt var fixPrompt string +// HostOnlyDrainWarning labels a preview built from this host's own +// configuration because fleet state could not be read. Shared so every path +// that can only offer that answer says the same thing about it. +const HostOnlyDrainWarning = "GitHub fleet state is unavailable; this host-only preview may differ from an authenticated install" + // DrainInstall describes what an install would do, so --dry-run can print it and // the result can be reported. type DrainInstall struct { @@ -56,7 +61,21 @@ func (s *Service) InstallDrain(ctx context.Context, agent string, agentArgs []st effectiveDryRun := dryRun || s.cfg.DryRun st, _, err := s.store.Load(ctx) if err != nil { - return DrainInstall{}, err + // A dry run is documented as a preview, so a state ref this host cannot + // read downgrades it to the host's own plan rather than failing — the + // warning is what keeps it from being mistaken for the fleet's. A real + // install has no such fallback: writing a unit from policy the fleet may + // disagree with is the divergence this whole mechanism exists to end. + if !effectiveDryRun { + return DrainInstall{}, err + } + plan, perr := DrainPlan(s.cfg, agent, agentArgs, repos, true) + if perr != nil { + return DrainInstall{}, perr + } + plan.PolicySource = "host" + plan.Warning = HostOnlyDrainWarning + return plan, nil } effective := s.fleetCfg(st) plan, err := DrainPlan(effective, agent, agentArgs, repos, effectiveDryRun) diff --git a/internal/crq/drain_test.go b/internal/crq/drain_test.go index 716d11bf..95f3b2d1 100644 --- a/internal/crq/drain_test.go +++ b/internal/crq/drain_test.go @@ -3,6 +3,7 @@ package crq import ( "context" "encoding/xml" + "errors" "html" "os" "os/exec" @@ -146,6 +147,38 @@ func TestDrainWrapperKeepsExplicitRepositoriesAuthoritative(t *testing.T) { } } +// A state ref this host cannot read must not turn the documented preview into an +// error — --dry-run is what somebody runs to inspect the setup before finishing +// it. The plan says which policy it could see, so it cannot be mistaken for the +// fleet's; a real install has no such fallback. +func TestInstallDrainPreviewsFromTheHostWhenFleetStateIsUnreadable(t *testing.T) { + cfg := firingConfig() + cfg.AllowRepos = map[string]bool{"owner/host-repo": true} + svc := NewService(cfg, newFakeGitHub(), unreadableStore{NewMemoryStore(cfg)}, nil) + + plan, err := svc.InstallDrain(context.Background(), fakeAgent(t, "claude"), nil, nil, true) + if err != nil { + t.Fatal(err) + } + if plan.PolicySource != "host" || plan.Warning == "" { + t.Fatalf("plan = %+v, want a labelled host-only preview", plan) + } + if len(plan.Repos) != 1 || plan.Repos[0] != "owner/host-repo" { + t.Fatalf("preview repos = %v, want this host's own", plan.Repos) + } + if _, err := svc.InstallDrain(context.Background(), fakeAgent(t, "claude"), nil, nil, false); err == nil { + t.Fatal("an install wrote a unit from policy it could not check against the fleet's") + } +} + +// unreadableStore is a host that cannot reach the state ref: no access to the +// gate repository, or no network to reach it over. +type unreadableStore struct{ StateStore } + +func (unreadableStore) Load(context.Context) (State, Revision, error) { + return State{}, Revision{}, errors.New("state ref unreadable") +} + // A missing agent must fail loudly at install time. Discovering it at the first // dispatch means a drain that looks installed and fixes nothing. func TestInstallDrainRefusesWithoutAnAgent(t *testing.T) { From b9a2f23932844f87dcc74de3e5f513fb42a17e4f Mon Sep 17 00:00:00 2001 From: kristofferR <481270+kristofferR@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:28:09 +0200 Subject: [PATCH 35/37] Read the fleet's policy where a host's own still answered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six places still decided from this host's startup configuration while the fleet's was in hand: - an observed account block was discarded whenever ANY fleet setting had moved since the observation. Update reports ErrNoChange as success, so the caller was told the window had been recorded while the account stayed open, and the next fire went out inside it. Only the scope says whose account a notice is about; the window is now recomputed under the current policy, inside the write. - `crq threads` classified authors with this host's reviewer set, so a login only the fleet names came back as a human — disagreeing with `crq feedback` over the same thread. - `Tidy` compared trigger comments against commands this host knows, so a co-reviewer command the fleet configured read as edited by hand and its spent comments stayed on the PR. - `crq init` applied the fleet's scope to its own copy and then printed the host's, installing a fallback that scans another account. - the installed service froze a DERIVED feedback-bots list as an explicit one, and dropped the per-bot settings of every co-reviewer this host has switched off — the fallbacks a repository override or a fleet `cobots` change picks that bot up with. And `scope` now validates GitHub owner logins rather than repository-name characters: `-team` could be recorded fleet-wide, where it fails every autoreview pass on every host. --- cmd/crq/main.go | 2 +- internal/crq/drain.go | 38 ++++++++++++++++++++-- internal/crq/drain_test.go | 49 +++++++++++++++++++++++++++++ internal/crq/drainswitch.go | 22 +++++++++++++ internal/crq/fleetconfig.go | 2 +- internal/crq/fleetconfig_test.go | 8 ++++- internal/crq/init.go | 7 +++++ internal/crq/init_test.go | 34 ++++++++++++++++++++ internal/crq/service.go | 41 ++++++++++++++++++------ internal/crq/service_test.go | 52 ++++++++++++++++++++++++++++++ internal/crq/threads.go | 13 +++++++- internal/crq/threads_test.go | 39 +++++++++++++++++++++++ internal/crq/tidy.go | 10 +++--- internal/crq/tidy_test.go | 54 ++++++++++++++++++++++++++++++++ 14 files changed, 351 insertions(+), 20 deletions(-) diff --git a/cmd/crq/main.go b/cmd/crq/main.go index 0fe68b61..513863c2 100644 --- a/cmd/crq/main.go +++ b/cmd/crq/main.go @@ -123,7 +123,7 @@ func run(ctx context.Context, args []string) int { if result.CalibrationPR > 0 { fmt.Printf("export CRQ_CAL_PR=%q\n", strconv.Itoa(result.CalibrationPR)) } - fmt.Printf("export CRQ_SCOPE=%q\n", strings.Join(cfg.Scope, ",")) + fmt.Printf("export CRQ_SCOPE=%q\n", strings.Join(result.Scope, ",")) fmt.Printf("export CRQ_STATE_REF=%q\n", result.StateRef) return 0 case "status": diff --git a/internal/crq/drain.go b/internal/crq/drain.go index 9d46dcd7..8a1bee34 100644 --- a/internal/crq/drain.go +++ b/internal/crq/drain.go @@ -398,6 +398,17 @@ func (s *Service) drainEnv(plan DrainInstall) map[string]string { } func drainEnvFor(cfg Config, plan DrainInstall) map[string]string { + // Only an operator's OWN list travels. When it was derived from who reviews, + // writing the currently derived logins out makes the service read them back + // as an explicit choice, frozen: a later fleet `cobots` change that enables + // an optional reviewer would then never reach the surfaced set, and a round + // could converge without ever showing that bot's findings. Empty is the + // environment's "unset" — the same reasoning, and the same encoding, as the + // implicit co-reviewer trigger below. + feedbackBots := "" + if cfg.FeedbackBotsExplicit { + feedbackBots = strings.Join(cfg.FeedbackBots, ",") + } env := map[string]string{ "CRQ_REPOS": strings.Join(sortedRepoList(cfg.AllowRepos), ","), // The denylist travels with the allowlist. Carrying one and not the other @@ -411,7 +422,7 @@ func drainEnvFor(cfg Config, plan DrainInstall) map[string]string { "CRQ_AUTOREVIEW_SKIP_MARKER": cfg.SkipMarker, "CRQ_BOT": cfg.Bot, "CRQ_REQUIRED_BOTS": strings.Join(cfg.RequiredBots, ","), - "CRQ_FEEDBACK_BOTS": strings.Join(cfg.FeedbackBots, ","), + "CRQ_FEEDBACK_BOTS": feedbackBots, "CRQ_REVIEW_CMD": cfg.ReviewCommand, "CRQ_RATELIMIT_CMD": cfg.RateLimitCommand, "CRQ_RL_MARKER": cfg.RateLimitMarker, @@ -441,7 +452,15 @@ func drainEnvFor(cfg Config, plan DrainInstall) map[string]string { coNames := make([]string, 0, len(cfg.CoBots)) for _, co := range cfg.CoBots { coNames = append(coNames, co.Name) - prefix := "CRQ_COBOT_" + strings.ToUpper(co.Name) + } + written := map[string]bool{} + writeCoBot := func(co CoBotConfig) { + key := strings.ToUpper(co.Name) + if written[key] { + return + } + written[key] = true + prefix := "CRQ_COBOT_" + key env[prefix+"_CMD"] = co.Command // The explicitness bit has to survive the install, not just the mode. An // implicit trigger is the registry default for how the bot is REQUIRED, @@ -460,6 +479,21 @@ func drainEnvFor(cfg Config, plan DrainInstall) map[string]string { env[prefix+"_REQUIRED"] = strconv.FormatBool(co.Required) env[prefix+"_GRACE"] = co.SelfHealGrace.String() } + // The enabled entries first: they carry the requiredness this host resolved, + // and with it the trigger mode that requiredness implies. + for _, co := range cfg.CoBots { + writeCoBot(co) + } + // Then the registry-wide fallbacks for the bots this host has switched off. + // A command, trigger or grace set for one of those is still the value this + // host would drive it with the moment something enables it — a per-repo + // reviewer override, or a fleet `cobots` change that names the bot without + // naming its per-bot keys. Carrying only the enabled set left the service + // running that bot on registry defaults while the installing shell's preview + // showed the host's own. + for _, co := range cfg.KnownCoBots { + writeCoBot(co) + } // Explicitly carry an empty set: omitting this key would re-enable every // default co-reviewer when the service starts. env["CRQ_COBOTS"] = strings.Join(coNames, ",") diff --git a/internal/crq/drain_test.go b/internal/crq/drain_test.go index 95f3b2d1..9970f2d1 100644 --- a/internal/crq/drain_test.go +++ b/internal/crq/drain_test.go @@ -290,7 +290,10 @@ func TestDrainUnitCarriesEffectiveReviewerConfiguration(t *testing.T) { cfg.SkipMarker = "" cfg.Bot = "custom-reviewer[bot]" cfg.RequiredBots = []string{"custom-reviewer[bot]", "cursor[bot]"} + // Named by the operator: it reaches past who reviews, which only an explicit + // CRQ_FEEDBACK_BOTS can do — and only an explicit one travels. cfg.FeedbackBots = []string{"custom-reviewer[bot]", "cursor[bot]", "observer[bot]"} + cfg.FeedbackBotsExplicit = true cfg.ReviewCommand = "@custom review this" cfg.RateLimitCoDegrade = false cfg.MinInterval = time.Hour @@ -360,6 +363,52 @@ func TestDrainEnvInstallsAnImplicitTriggerAsUnset(t *testing.T) { } } +// A derived surfaced set must install as unset, for the same reason as the +// implicit trigger: written out as a value, the service reads it back as the +// operator's own list and stops recomputing it, so a co-reviewer the fleet +// enables later never has its findings surfaced. +func TestDrainEnvInstallsDerivedFeedbackBotsAsUnset(t *testing.T) { + cfg := firingConfig() + cfg.FeedbackBots = []string{cfg.Bot, dialect.CodexBotLogin} + cfg.FeedbackBotsExplicit = false + svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) + + env := svc.drainEnv(DrainInstall{}) + bots, ok := env["CRQ_FEEDBACK_BOTS"] + if !ok || bots != "" { + t.Fatalf("CRQ_FEEDBACK_BOTS = %q, present=%t; want an explicit empty value", bots, ok) + } +} + +// The per-bot settings of a co-reviewer this host has switched OFF still travel. +// They are what a repository override — or a fleet `cobots` change that names +// the bot without naming its keys — picks the bot up with, and the installing +// shell's own preview uses them. +func TestDrainEnvCarriesDisabledCoBotFallbacks(t *testing.T) { + cfg := firingConfig() + cfg.CoBots = nil + cfg.KnownCoBots = []CoBotConfig{{ + Name: "bugbot", Login: "cursor[bot]", Command: "bugbot run please", + Trigger: engine.TriggerAlways, TriggerExplicit: true, + SelfHealGrace: 42 * time.Minute, + }} + svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) + + env := svc.drainEnv(DrainInstall{}) + if got := env["CRQ_COBOTS"]; got != "" { + t.Fatalf("CRQ_COBOTS = %q, want the enabled set to stay empty", got) + } + for key, want := range map[string]string{ + "CRQ_COBOT_BUGBOT_CMD": "bugbot run please", + "CRQ_COBOT_BUGBOT_TRIGGER": "always", + "CRQ_COBOT_BUGBOT_GRACE": "42m0s", + } { + if got := env[key]; got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } +} + func mustCoReviewer(t *testing.T, name string) dialect.CoReviewer { t.Helper() co, ok := dialect.CoReviewerByName(name) diff --git a/internal/crq/drainswitch.go b/internal/crq/drainswitch.go index c951400c..a412db4e 100644 --- a/internal/crq/drainswitch.go +++ b/internal/crq/drainswitch.go @@ -106,6 +106,28 @@ func validRepoSlug(repo string) bool { return ok && validNameSegment(owner) && validNameSegment(name) } +// validOwnerLogin reports whether login is a GitHub user or organisation login. +// +// Stricter than validNameSegment on purpose: a repository NAME may hold +// underscores and dots, a login may not, and a login may neither begin nor end +// with a hyphen. Everything this validates is looked up as /users/, so a +// value outside those rules is one no account can ever have — and recorded for +// the fleet, a single such entry fails the whole scan on every host at once, +// which is precisely the mistake worth catching at `crq config set` instead. +func validOwnerLogin(login string) bool { + if login == "" || strings.HasPrefix(login, "-") || strings.HasSuffix(login, "-") { + return false + } + for _, r := range login { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-': + default: + return false + } + } + return true +} + // validNameSegment reports whether part is one segment a GitHub owner or // repository name could be. // diff --git a/internal/crq/fleetconfig.go b/internal/crq/fleetconfig.go index 46e35af3..0f8db9d2 100644 --- a/internal/crq/fleetconfig.go +++ b/internal/crq/fleetconfig.go @@ -530,7 +530,7 @@ func ValidateFleetSetting(key, value string) error { func fleetOwners(value string) ([]string, error) { owners := splitList(value) for _, owner := range owners { - if !validNameSegment(owner) { + if !validOwnerLogin(owner) { return nil, fmt.Errorf("scope must be owner or organisation logins, got %q", owner) } } diff --git a/internal/crq/fleetconfig_test.go b/internal/crq/fleetconfig_test.go index 757382ed..d760c8df 100644 --- a/internal/crq/fleetconfig_test.go +++ b/internal/crq/fleetconfig_test.go @@ -91,11 +91,17 @@ func TestFleetRefusesWhatItCannotRead(t *testing.T) { // EachOpenPR as a user or organisation name, so a repository typed here — or // anything else GitHub cannot resolve — fails every pass, on every host at // once, because the value is fleet-wide. - for _, owner := range []string{"owner/repo", "own er", "owner?", ".."} { + // A login may not begin or end with a hyphen, and holds neither underscores + // nor dots — those are repository-name characters, and accepting them here + // records a scope no /users/ lookup can ever resolve. + for _, owner := range []string{"owner/repo", "own er", "owner?", "..", "-team", "team-", "a_team", "a.team"} { if err := svc.SetFleetConfig(ctx, "scope", owner); err == nil { t.Errorf("scope accepted the malformed owner %q", owner) } } + if err := svc.SetFleetConfig(ctx, "scope", "acme-2,Some-Org"); err != nil { + t.Errorf("a pair of ordinary owner logins was rejected: %v", err) + } if err := svc.SetFleetConfig(ctx, "required-bots", ""); err == nil { t.Error("an empty required reviewer set was accepted") } diff --git a/internal/crq/init.go b/internal/crq/init.go index 5c58ecb7..a0a1dab6 100644 --- a/internal/crq/init.go +++ b/internal/crq/init.go @@ -14,6 +14,12 @@ type InitResult struct { DashboardIssue int `json:"dashboard_issue"` CalibrationPR int `json:"calibration_pr,omitempty"` StateRef string `json:"state_ref"` + // Scope is the owner list in force after the fleet's policy is applied, not + // the one this host started with. Joining an existing queue is exactly when + // the two differ, and the setup lines this result prints are copied into a + // config file verbatim — printing the host's own would install a fallback + // that scans the wrong account the moment the fleet key is unset. + Scope []string `json:"scope,omitempty"` } func Init(ctx context.Context, cfg Config, gh *ghapi.GitHub, store StateStore) (InitResult, error) { @@ -57,6 +63,7 @@ func Init(ctx context.Context, cfg Config, gh *ghapi.GitHub, store StateStore) ( DashboardIssue: cfg.DashboardIssue, CalibrationPR: cfg.CalibrationPR, StateRef: cfg.StateRef, + Scope: cfg.Scope, }, nil } diff --git a/internal/crq/init_test.go b/internal/crq/init_test.go index ac280b29..ade05ece 100644 --- a/internal/crq/init_test.go +++ b/internal/crq/init_test.go @@ -41,3 +41,37 @@ func TestInitSyncsDashboardWithFleetReviewers(t *testing.T) { t.Fatalf("dashboard co-reviewers = %q, want fleet-required codex", store.render.CoReviewers) } } + +// The setup lines init prints are copied into a config file verbatim. Joining a +// queue whose fleet scans another account and printing this host's own scope +// installs a fallback that targets the wrong one the moment the fleet key is +// unset — and reports a divergence immediately. +func TestInitReportsTheFleetScope(t *testing.T) { + ctx := context.Background() + cfg := isolatedConfig(t, map[string]string{ + "CRQ_REPO": "owner/gate", + "CRQ_ISSUE": "7", + "CRQ_CAL_PR": "8", + "CRQ_SCOPE": "this-host", + }) + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.SetFleetValue("scope", "the-fleet") + return nil + }); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + result, err := Init(ctx, cfg, ghapi.NewTestClient(server.URL, server.Client()), store) + if err != nil { + t.Fatal(err) + } + if strings.Join(result.Scope, ",") != "the-fleet" { + t.Fatalf("scope = %v, want the fleet's", result.Scope) + } +} diff --git a/internal/crq/service.go b/internal/crq/service.go index 5feddf2a..9436cb50 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -596,14 +596,30 @@ func (s *Service) recordObservedBlock(ctx context.Context, obs observation, st S if blk == nil || s.cfg.DryRun || !observedAccountBlockChanges(st.Account, blk) { return nil, nil } + // Update swallows ErrNoChange, so whether the block was recorded has to be + // carried out of the mutation rather than read from its error. + var recorded *engine.AccountBlock updated, err := s.store.Update(ctx, func(w *State) error { - if fleetRevision(*w) != cfg.FleetRevision { + recorded = nil + current := s.fleetCfg(*w) + // Only the SCOPE decides whose account this notice is evidence about. + // Any other fleet setting moving between the observation and this write + // leaves the notice just as valid, and comparing the whole revision + // discarded it — silently, since a returned ErrNoChange reads as a + // successful update. The account then stayed open for the rest of a + // window the bot had already stated, and the next fire went out inside + // it. So the window is recomputed here instead, under the policy that is + // current at the moment of the write, against the quota this write lands + // on. + if !sameFoldedSet(current.Scope, cfg.Scope) { return ErrNoChange } - if !observedAccountBlockChanges(w.Account, blk) { + blk := engine.ObservedAccountBlock(obs.eng, current.policy(), w.Account, now) + if blk == nil || !observedAccountBlockChanges(w.Account, blk) { return ErrNoChange } applyAccountBlock(w, blk, now) + recorded = blk return nil }) if err != nil { @@ -612,14 +628,19 @@ func (s *Service) recordObservedBlock(ctx context.Context, obs observation, st S } return nil, err } - // Like every other writer of this window: the dashboard is where an operator - // reads whether the account is available, and the decision that follows this - // is usually FireNo — which writes nothing further, leaving the issue claiming - // a free account for the whole block while state and `crq status` say - // otherwise. - s.sync(ctx, updated) - if s.log != nil { - s.log.Printf("account blocked until %s (observed, not tied to a round)", blk.Until.UTC().Format(time.RFC3339)) + // The state is handed back either way — it is newer than the caller's, and a + // window another host recorded in the meantime is one this decision has to + // respect. Only the report of a WRITE is conditional. + if recorded != nil { + // Like every other writer of this window: the dashboard is where an operator + // reads whether the account is available, and the decision that follows this + // is usually FireNo — which writes nothing further, leaving the issue claiming + // a free account for the whole block while state and `crq status` say + // otherwise. + s.sync(ctx, updated) + if s.log != nil { + s.log.Printf("account blocked until %s (observed, not tied to a round)", recorded.Until.UTC().Format(time.RFC3339)) + } } return &updated, nil } diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index b146ded8..7533fb29 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -3346,6 +3346,58 @@ func TestFeedbackRecordsARateLimitNoticeItObserves(t *testing.T) { } } +// A fleet setting that has nothing to do with the account must not throw away +// an observed block. Update reports ErrNoChange as success, so refusing the +// write on a whole-revision mismatch left the caller believing the window had +// been recorded while the account stayed open — and the next fire went out +// inside it. +func TestObservedAccountBlockSurvivesAnUnrelatedFleetChange(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + gh := newFakeGitHub() + pull := ghapi.Pull{State: "open"} + pull.Head.SHA = "a0646f010abcdef0" + gh.pulls[fakeKey("o/carrier", 82)] = pull + + notice := time.Now().UTC().Add(-time.Minute) + rl := ghapi.IssueComment{ID: 501, + Body: "\n> ## Review limit reached\n> **Next review available in:** **40 minutes**", + CreatedAt: notice, UpdatedAt: notice} + rl.User.Login = "coderabbitai[bot]" + gh.comments[fakeKey("o/carrier", 82)] = []ghapi.IssueComment{rl} + + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + now := time.Now().UTC() + + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + obs, err := svc.observe(ctx, svc.fleetCfg(st), "o/carrier", 82, nil, nil, now) + if err != nil { + t.Fatal(err) + } + // Somebody changes an unrelated policy while this notice is being read. + if _, err := store.Update(ctx, func(w *State) error { + w.SetFleetValue("settle", "30s") + return nil + }); err != nil { + t.Fatal(err) + } + + if _, err := svc.recordObservedBlock(ctx, obs, st, now); err != nil { + t.Fatal(err) + } + after, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if after.Account.BlockedUntil == nil { + t.Fatal("the observed block was discarded because an unrelated fleet setting moved") + } +} + func TestObservedAccountBlockPersistsANewerNoticeAtTheStandingWindow(t *testing.T) { now := time.Date(2026, 7, 27, 8, 0, 0, 0, time.UTC) standing := now.Add(time.Hour) diff --git a/internal/crq/threads.go b/internal/crq/threads.go index 86520c71..e2d8581c 100644 --- a/internal/crq/threads.go +++ b/internal/crq/threads.go @@ -36,6 +36,17 @@ type OpenThread struct { // // This is the read that closes that loop: list, decide, `crq resolve`. func (s *Service) OpenThreads(ctx context.Context, repo string, pr int) ([]OpenThread, error) { + repo = NormalizeRepo(repo) + // The same reviewer set `crq feedback` reads findings with. A login the + // fleet or this repository added is a bot there, and reading threads from + // this host's own configuration instead reported its unresolved threads as + // human comments — the one output an agent uses to decide whether a thread + // needs a person. + st, _, err := s.store.Load(ctx) + if err != nil { + return nil, err + } + cfg := s.cfgFor(st, repo) threads, err := s.reviewThreads(ctx, repo, pr) if err != nil { return nil, err @@ -51,7 +62,7 @@ func (s *Service) OpenThreads(ctx context.Context, repo string, pr int) ([]OpenT // Only when the author is actually a reviewer bot. The command // promises every unresolved thread, humans included, and labelling // "alice" as a bot makes the output plainly wrong. - isBot := isReviewerBot(s.cfg, first.Author.Login) + isBot := isReviewerBot(cfg, first.Author.Login) if isBot { open.Bot = dialect.NormalizeBotName(first.Author.Login) } else { diff --git a/internal/crq/threads_test.go b/internal/crq/threads_test.go index 93bb90a6..caacf87b 100644 --- a/internal/crq/threads_test.go +++ b/internal/crq/threads_test.go @@ -89,6 +89,45 @@ func TestOpenThreadsNamesAConfiguredFeedbackBot(t *testing.T) { } } +// The same, for a reviewer only the FLEET names. `crq feedback` already reads +// its findings through the effective configuration, so listing its thread as a +// human's would have the two commands disagree about the same thread. +func TestOpenThreadsNamesAFleetFeedbackBot(t *testing.T) { + gh := newFakeGitHub() + gh.graphQL = func(query string, _ map[string]any, out any) error { + if !strings.Contains(query, "reviewThreads") { + return nil + } + return json.Unmarshal([]byte(`{"repository":{"pullRequest":{"reviewThreads":{ + "pageInfo":{"hasNextPage":false,"endCursor":""}, + "nodes":[{"id":"T_fleet","isResolved":false,"isOutdated":false,"path":"a.go","line":1, + "comments":{"totalCount":1,"nodes":[{"body":"### Nil deref\n\n**High Severity**\n\nDetail.", + "author":{"login":"reviewdog[bot]"}}]}}] + }}}}`), out) + } + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + if _, err := store.Update(ctx, func(st *State) error { + st.SetFleetValue("feedback-bots", "coderabbitai[bot],reviewdog[bot]") + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, gh, store, nil) + + threads, err := svc.OpenThreads(ctx, "o/r", 1) + if err != nil { + t.Fatal(err) + } + if len(threads) != 1 { + t.Fatalf("got %d threads, want the fleet reviewer's", len(threads)) + } + if threads[0].Bot != "reviewdog" || threads[0].Author != "" { + t.Errorf("thread = %+v, want the fleet's feedback bot named as a bot", threads[0]) + } +} + // The command promises every unresolved thread, so it also gets humans'. Copying // the author into `bot` made the output say `"bot":"alice"`. func TestOpenThreadsDoesNotCallAPersonABot(t *testing.T) { diff --git a/internal/crq/tidy.go b/internal/crq/tidy.go index bb04f1f6..96cecd02 100644 --- a/internal/crq/tidy.go +++ b/internal/crq/tidy.go @@ -67,10 +67,12 @@ func (s *Service) Tidy(ctx context.Context, repo string, pr int, dryRun bool) (T return result, err } // Which comments count as a trigger depends on who reviews, so the whole - // path takes a configuration value rather than reading the Service's. That - // is what lets per-repo reviewers substitute one here later without - // threading anything new through. - cfg := s.cfg + // path takes a configuration value rather than reading the Service's — the + // effective one, fleet policy and this repository's override included. + // Reading this host's own would compare the comments against a command crq + // never posted, read every spent trigger as edited by hand, and keep them on + // the PR for ever. + cfg := s.cfgFor(st, repo) observedPosted := collectPosted(st, repo, pr) if len(observedPosted.commands) == 0 { result.Kept = append(result.Kept, "no round on this pr posted a trigger comment") diff --git a/internal/crq/tidy_test.go b/internal/crq/tidy_test.go index 47c379d3..9c958b3d 100644 --- a/internal/crq/tidy_test.go +++ b/internal/crq/tidy_test.go @@ -3,6 +3,7 @@ package crq import ( "context" "encoding/json" + "strings" "testing" "time" @@ -365,6 +366,59 @@ func TestTidyKeepsATriggerEditedDuringThePass(t *testing.T) { } } +// The command crq POSTED is the fleet's, so that is the one a trigger comment +// has to be compared against. Reading this host's own startup configuration +// instead found no such command, read every spent co-reviewer trigger as edited +// by hand, and kept them on the PR for ever. +func TestTidyRecognisesATriggerTheFleetConfigured(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + gh := newFakeGitHub() + gh.graphQL = noForcePush + now := time.Now().UTC() + repo, pr := "o/r", 27 + const command = "bugbot run please" + + var pull ghapi.Pull + pull.State = "open" + pull.Head.SHA = "bbbbbbbb2" + gh.pulls[fakeKey(repo, pr)] = pull + gh.commits["bbbbbbbb2"] = commitAt(now.Add(-10 * time.Minute)) + + c := ghapi.IssueComment{ID: 100, Body: command, + CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: now.Add(-2 * time.Hour)} + c.User.Login = "kristofferR" + gh.comments[fakeKey(repo, pr)] = []ghapi.IssueComment{c} + + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + if _, err := store.Update(ctx, func(st *State) error { + // The fleet enables a co-reviewer this host does not run, with a command + // only the fleet knows. + st.SetFleetValue("cobots", "bugbot") + st.SetFleetValue("cobot-bugbot-cmd", command) + return completedRound(st, repo, pr, now, func(r *Round) error { + if err := r.Fire(0, now.Add(-2*time.Hour)); err != nil { + return err + } + r.RecordPosted("cursor[bot]", c.ID, c.CreatedAt) + return nil + }) + }); err != nil { + t.Fatal(err) + } + + result, err := svc.Tidy(ctx, repo, pr, true) + if err != nil { + t.Fatal(err) + } + for _, kept := range result.Kept { + if strings.Contains(kept, "someone edited it") { + t.Fatalf("the fleet's own trigger read as an edited comment: %v", result.Kept) + } + } +} + // A delete GitHub refuses must reach the caller. An empty Deleted otherwise // reads as "nothing was spent" when it means "the token may not delete". func TestTidyReportsDeletionFailures(t *testing.T) { From 74860c60a54026870912be247f721a75bfe7ef5b Mon Sep 17 00:00:00 2001 From: kristofferR <481270+kristofferR@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:01:59 +0200 Subject: [PATCH 36/37] Let a policy read stand only where it still decides the same thing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four places drew a conclusion from a policy or a slot and then wrote it somewhere it no longer belonged: - releasing the fire slot went by key alone, so an orphaned hold — the slot kept for a metered command whose round was superseded by the push its success invited — was dropped by whatever retired the QUEUED replacement at that key: a fleet exclusion, a cancel, a closed PR. The hold belongs to the command, so the slot is now released only by the round that took it, matched on its token. - a CLI-reported account block was refused because ANY fleet setting had moved between preflight's read and the write. Only the scope says whose account the block is about, and cliOrgMatches already asks that against the current policy; an edit to settle or repos left the shared quota open and the caller told to run preflight again. - a calibration reply adopted under the TTL the read started with was committed with CheckedAt=now even when the fleet had shortened calibrate-ttl under it, presenting a reading the new window calls stale as fresh for a whole TTL. readQuota now returns when the reply it adopted was written, and the write revalidates that age. - applyFleet applied the recorded settings alphabetically, so `cobots` resolved through ForRepo against THIS HOST's required list: a fleet recording `required-bots=coderabbitai[bot]` with `cobots=""` had the host's locally required co-reviewer put straight back, and the rebuild preserved it. required-bots is now applied first, giving both keys one baseline. --- internal/crq/cliquota_test.go | 31 +++++++++++++ internal/crq/feedback.go | 2 +- internal/crq/fleetcmd.go | 7 ++- internal/crq/fleetconfig.go | 40 ++++++++++++++-- internal/crq/fleetconfig_test.go | 70 ++++++++++++++++++++++++++++ internal/crq/service.go | 80 ++++++++++++++++++++++++-------- internal/crq/service_test.go | 43 +++++++++++++++++ 7 files changed, 247 insertions(+), 26 deletions(-) diff --git a/internal/crq/cliquota_test.go b/internal/crq/cliquota_test.go index e8b69ab4..4a00e033 100644 --- a/internal/crq/cliquota_test.go +++ b/internal/crq/cliquota_test.go @@ -146,6 +146,37 @@ func TestRecordCLIQuotaRefusesAChangedFleetAccountAtCommit(t *testing.T) { } } +// Only the account the evidence belongs to can invalidate it. Comparing the +// whole fleet revision meant any unrelated setting moving between the read and +// the write refused an explicit, organisation-attributed block: the operator was +// told to run preflight again while the shared quota stayed open, and the daemon +// could post a metered review inside the window the CLI had just reported. +func TestRecordCLIQuotaSurvivesAnUnrelatedFleetChange(t *testing.T) { + now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC) + svc, store := cliQuotaService(t, now) + st, _, err := store.Load(context.Background()) + if err != nil { + t.Fatal(err) + } + snapshot := svc.fleetCfg(st) + if _, err := store.Update(context.Background(), func(st *State) error { + st.SetFleetValue("settle", "9m") + return nil + }); err != nil { + t.Fatal(err) + } + + applied, standing, err := svc.applyAccountBlock( + context.Background(), now.Add(time.Hour), "coderabbit-cli", snapshot, "kristofferR", + ) + if err != nil { + t.Fatal(err) + } + if !applied || standing == nil || !standing.Equal(now.Add(time.Hour)) { + t.Fatalf("applied=%v standing=%v, want the block recorded across the unrelated change", applied, standing) + } +} + // A window read from a PR comment is authoritative about the whole account; a // local reading may be a narrower limit. Extending is safe, shortening is not. func TestRecordCLIQuotaNeverShortensAStandingBlock(t *testing.T) { diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index c1b64fed..0607bc01 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -924,7 +924,7 @@ func (s *Service) completeWaitRound(ctx context.Context, repo string, pr int, he if err := r.Complete(); err != nil { return err } - releaseSlot(st, QueueKey(repo, pr)) + releaseSlot(st, QueueKey(repo, pr), r.Token) st.PutRound(*r) changed = true return nil diff --git a/internal/crq/fleetcmd.go b/internal/crq/fleetcmd.go index f2401f02..cad6120a 100644 --- a/internal/crq/fleetcmd.go +++ b/internal/crq/fleetcmd.go @@ -474,8 +474,13 @@ func (s *Service) reconcileFleetChange(st *State, change fleetChange, open map[s if !after.ExcludeRepos[NormalizeRepo(round.Repo)] { continue } + // A queued round holds no slot, so this releases one only when the round + // it retires is the one that took it — which it never is here. An orphaned + // hold at the same key stands for a metered command a previous round + // posted and is left alone: excluding a repository stops the next review, + // it does not answer the one already in flight. st.EndRound(round.Repo, round.PR, "repository excluded by fleet policy") - releaseSlot(st, QueueKey(round.Repo, round.PR)) + releaseSlot(st, QueueKey(round.Repo, round.PR), round.Token) } s.reopenForFleetReviewerChange(st, before, after, open) return nil diff --git a/internal/crq/fleetconfig.go b/internal/crq/fleetconfig.go index 0f8db9d2..04a9153c 100644 --- a/internal/crq/fleetconfig.go +++ b/internal/crq/fleetconfig.go @@ -91,7 +91,7 @@ func fleetSettings() map[string]fleetSetting { }, Show: func(cfg Config) string { return strings.Join(sortedSetKeys(cfg.ExcludeRepos), ",") }, }, - "required-bots": { + requiredBotsKey: { Doc: "logins that must review a head before a round converges", Env: "CRQ_REQUIRED_BOTS", AltEnv: coBotRequiredEnvs(), @@ -193,7 +193,7 @@ func fleetSettings() map[string]fleetSetting { }, Show: func(cfg Config) string { return strings.Join(sortedSetKeys(cfg.SkipAuthors), ",") }, }, - "cobots": { + coBotsKey: { Doc: "co-reviewers crq surfaces and triggers (empty disables all)", Env: "CRQ_COBOTS", // A required bot is enabled whatever CRQ_COBOTS lists, so the per-bot @@ -439,6 +439,14 @@ func fleetCoBotNames(value string) ([]string, error) { // but it never requeues a round. const feedbackBotsKey = "feedback-bots" +// requiredBotsKey and coBotsKey are the two membership settings, named because +// their order relative to each other decides what they resolve to. See +// fleetApplyOrder. +const ( + requiredBotsKey = "required-bots" + coBotsKey = "cobots" +) + // isReviewerFleetKey reports whether a setting reshapes who reviews, or how a // co-reviewer is driven. Those are the ones whose derived views have to be // rebuilt, and whose changes existing rounds may have to be reconciled against. @@ -454,7 +462,7 @@ func isReviewerFleetKey(key string) bool { // // The distinction matters when the fleet adopts a key, see fleetChange.baseline. func isReviewerMembershipFleetKey(key string) bool { - return key == "required-bots" || key == "cobots" + return key == requiredBotsKey || key == coBotsKey } // FleetKeys lists every setting the fleet owns, in a stable order. @@ -559,7 +567,7 @@ func fleetRepoSet(value string) (map[string]bool, error) { // disagreement surfaces in `crq doctor`, not silently here. func applyFleet(cfg Config, fleet map[string]string, warn func(string)) Config { settings := fleetSettings() - for _, key := range sortedKeys(fleet) { + for _, key := range fleetApplyOrder(fleet) { setting, ok := settings[key] if !ok { if warn != nil { @@ -599,6 +607,30 @@ func applyFleet(cfg Config, fleet map[string]string, warn func(string)) Config { return cfg } +// fleetApplyOrder is the order recorded settings are applied in: alphabetical, +// except that required-bots comes first. +// +// The two membership settings are interdependent — `cobots` resolves through +// ForRepo, where a required bot is enabled whatever the list says. Alphabetical +// order applies it BEFORE required-bots, so it reads THIS HOST's required set: +// a fleet that records `required-bots=coderabbitai[bot]` with `cobots=""` has +// the host's locally required co-reviewer put straight back, and the rebuild +// below preserves the contaminated set — leaving hosts surfacing and triggering +// a bot the fleet disabled, from local environment the fleet is meant to +// override. Resolving the required list first gives both keys one baseline. +func fleetApplyOrder(fleet map[string]string) []string { + keys := make([]string, 0, len(fleet)) + if _, ok := fleet[requiredBotsKey]; ok { + keys = append(keys, requiredBotsKey) + } + for _, key := range sortedKeys(fleet) { + if key != requiredBotsKey { + keys = append(keys, key) + } + } + return keys +} + // touchesReviewers reports whether the recorded policy holds anything the // derived reviewer views are built from. feedback-bots is one of those inputs // without reshaping who reviews: dropping it has to return the surfaced set to diff --git a/internal/crq/fleetconfig_test.go b/internal/crq/fleetconfig_test.go index d760c8df..4efe1259 100644 --- a/internal/crq/fleetconfig_test.go +++ b/internal/crq/fleetconfig_test.go @@ -248,6 +248,41 @@ func TestFleetOwnsTheCoReviewerPolicy(t *testing.T) { } } +// The two membership settings are read together or not at all. Applied in +// alphabetical order, `cobots` resolved against the host's required list — which +// re-enabled a co-reviewer the fleet had just disabled, and the rebuild kept it, +// leaving the policy depending on local environment again. +func TestFleetCoBotsResolveAgainstTheFleetsRequiredBots(t *testing.T) { + ctx := context.Background() + cfg := isolatedConfig(t, map[string]string{ + "CRQ_COBOTS": "codex", + "CRQ_COBOT_CODEX_REQUIRED": "1", + }) + if len(cfg.CoBots) != 1 || cfg.CoBots[0].Name != "codex" { + t.Fatalf("this host must start with codex required: %+v", cfg.CoBots) + } + store := NewMemoryStore(cfg) + svc := NewService(cfg, newFakeGitHub(), store, nil) + + if err := svc.SetFleetConfig(ctx, "cobots", ""); err != nil { + t.Fatal(err) + } + if err := svc.SetFleetConfig(ctx, "required-bots", "coderabbitai[bot]"); err != nil { + t.Fatal(err) + } + st, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + got := svc.fleetCfg(st) + if len(got.CoBots) != 0 { + t.Errorf("CoBots = %+v, want none — the fleet disabled every co-reviewer", got.CoBots) + } + if containsBot(got.RequiredBots, dialect.CodexBotLogin) { + t.Errorf("RequiredBots = %v, want only the fleet's", got.RequiredBots) + } +} + // A value only some hosts can read breaks the fleet from the inside, so the // co-reviewer settings are refused where they are set like every other one. func TestFleetRefusesCoReviewerPolicyItCannotRead(t *testing.T) { @@ -448,6 +483,41 @@ func TestFleetExcludeRetiresQueuedRounds(t *testing.T) { } } +// The slot outlives the round that took it: a hold left behind for a metered +// command whose round was superseded by the push it invited belongs to that +// command. Excluding the repository retires the QUEUED replacement at the same +// key, and dropping the hold with it would let a second metered review fire +// while the first command is still unanswered. +func TestFleetExcludeLeavesAnOrphanedFireSlotHold(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + store := NewMemoryStore(cfg) + now := time.Now().UTC() + until := now.Add(20 * time.Minute) + if _, err := store.Update(ctx, func(st *State) error { + st.PutRound(Round{ + Repo: "owner/repo", PR: 7, Head: "beef456", + Phase: PhaseQueued, EnqueuedAt: now, + }) + st.FireSlot = &FireSlot{Key: QueueKey("owner/repo", 7), Token: "gone", Since: now} + st.HoldSlotUntil(until) + return nil + }); err != nil { + t.Fatal(err) + } + + if err := NewService(cfg, newFakeGitHub(), store, nil).SetFleetConfig(ctx, "exclude", "owner/repo"); err != nil { + t.Fatal(err) + } + st, _, _ := store.Load(ctx) + if round := st.Round("owner/repo", 7); round != nil { + t.Fatalf("excluded round remains fire-eligible: %+v", round) + } + if !st.SlotHeld(now) { + t.Fatalf("exclusion released the unanswered command's hold: %+v", st.FireSlot) + } +} + // A reservation is a fire's commit point, but the review command reaches GitHub // after it. Excluding the repository in that window cannot take the post back: // retiring the round would only destroy the record of the quota the account is diff --git a/internal/crq/service.go b/internal/crq/service.go index 9436cb50..2b23c992 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -775,7 +775,15 @@ func (s *Service) applyAccountBlock( applied := false state, err := s.store.Update(ctx, func(st *State) error { current := s.fleetCfg(*st) - if current.FleetRevision != expected.FleetRevision || !cliOrgMatches(current, cliOrg) { + // Only the SCOPE decides which account this block is evidence about, and + // cliOrgMatches asks the sharper form of that question against the policy + // the write lands on. Comparing the whole revision instead refused an + // explicit, organisation-attributed block because an unrelated setting — + // settle, repos, a co-reviewer's command — moved between the read and this + // write: the caller was told to run preflight again while the shared quota + // stayed open, and the daemon could post a metered review inside the window + // the CLI had just reported. + if !sameFoldedSet(current.Scope, expected.Scope) || !cliOrgMatches(current, cliOrg) { return errFleetQuotaChanged } if !engine.AcceptAccountBlock(st.Account.BlockedUntil, until) { @@ -877,6 +885,9 @@ func (s *Service) progressSlotRound(ctx context.Context, slot Round) (PumpResult // where the state it reads is the state the write lands on. func (s *Service) applyTransition(st *State, r *Round, tr engine.Transition, now time.Time, cfg Config) error { key := QueueKey(r.Repo, r.PR) + // Captured before the transitions below clear it: this round's claim on the + // fire slot is what lets it release the slot at the end. + token := r.Token // Completion and retry are the policy-dependent outcomes, so both are dropped // when the policy moved under them. The completed round is the "this head was // reviewed" dedup marker, and reopenForChangedReviewers deliberately leaves an @@ -917,19 +928,29 @@ func (s *Service) applyTransition(st *State, r *Round, tr engine.Transition, now } case engine.OutAbandon: st.EndRound(r.Repo, r.PR, tr.Reason) - releaseSlot(st, key) + releaseSlot(st, key, token) return nil default: return nil } st.PutRound(*r) - releaseSlot(st, key) + releaseSlot(st, key, token) return nil } -// releaseSlot clears the fire slot when it points at key. -func releaseSlot(st *State, key string) { - if st.FireSlot != nil && st.FireSlot.Key == key { +// releaseSlot clears the fire slot when it points at key AND token — the round +// that took it is the only one that may give it back. +// +// The key alone is not enough because the slot outlives the round: a hold left +// behind for a metered command whose round was superseded belongs to that +// command, not to the pull request it was posted on. Its replacement round at +// the same key ending — excluded by fleet policy, cancelled, or abandoned with +// the PR — would otherwise drop the hold and let a second metered review fire +// while the first command is still unanswered. Its token is the archived one's, +// so it releases nothing here. Nobody needs to: the hold is bounded by the +// in-flight window, and Normalize reaps the slot once it expires. +func releaseSlot(st *State, key, token string) { + if st.FireSlot != nil && st.FireSlot.Key == key && st.FireSlot.Token == token { st.FireSlot = nil st.ClearSlotHold() } @@ -1122,7 +1143,7 @@ func (s *Service) abandonRound(ctx context.Context, round Round, reason, action return ErrNoChange } st.EndRound(round.Repo, round.PR, reason) - releaseSlot(st, QueueKey(round.Repo, round.PR)) + releaseSlot(st, QueueKey(round.Repo, round.PR), round.Token) ended = true return nil }) @@ -1314,7 +1335,7 @@ func (s *Service) fireRound(ctx context.Context, cfg Config, round Round, obs en if rerr := r.AwaitRetry(now.Add(postFailureBackoff), "failed to post review command: "+err.Error(), now); rerr != nil { return rerr } - releaseSlot(st, key) + releaseSlot(st, key, token) st.Warn = "failed to post review command: " + err.Error() st.PutRound(*r) return nil @@ -1913,11 +1934,13 @@ const triggerClaimTTL = 2 * time.Minute func (s *Service) Cancel(ctx context.Context, repo string, pr int) error { repo = NormalizeRepo(repo) state, err := s.store.Update(ctx, func(st *State) error { - if st.Round(repo, pr) == nil { + r := st.Round(repo, pr) + if r == nil { return ErrNoChange } + token := r.Token st.EndRound(repo, pr, "cancelled") - releaseSlot(st, QueueKey(repo, pr)) + releaseSlot(st, QueueKey(repo, pr), token) return nil }) if err != nil { @@ -1954,7 +1977,7 @@ func (s *Service) RefreshQuota(ctx context.Context) (State, error) { if state.Account.CalibAskedAt == nil && state.Account.CheckedAt != nil && now.Sub(*state.Account.CheckedAt) < cfg.CalibrationTTL { return state, nil } - quota, err := s.readQuota(ctx, s.calibrationIssue(state), now, state.Account.CalibAskedAt, cfg) + quota, readAt, err := s.readQuota(ctx, s.calibrationIssue(state), now, state.Account.CalibAskedAt, cfg) if err != nil { return state, err } @@ -1977,6 +2000,16 @@ func (s *Service) RefreshQuota(ctx context.Context) (State, error) { if st.Account.CalibAskedAt == nil && st.Account.CheckedAt != nil && now.Sub(*st.Account.CheckedAt) < currentTTL { return ErrNoChange } + // The reading is an existing reply adopted under the TTL the read STARTED + // with. A fleet that shortened calibrate-ttl in the meantime has already + // declared that reply too old to decide from, and committing it stamps + // CheckedAt=now — presenting it as fresh for a whole new window, and + // letting a metered review fire on an availability result the shorter + // window asks crq to re-establish first. Drop it; the next pass reads + // under the TTL that is now current, and probes when nothing qualifies. + if !readAt.IsZero() && now.Sub(readAt) >= currentTTL { + return ErrNoChange + } // A fresh reading replaces the whole quota; carry the account-quota comment // identity over so the engine can still recognise an edited comment it // already accounted for. @@ -2070,25 +2103,32 @@ func (s *Service) rotateCalibration(ctx context.Context, oldIssue int) (int, err return issue.Number, nil } -func (s *Service) readQuota(ctx context.Context, issue int, now time.Time, pendingAsked *time.Time, cfg Config) (AccountQuota, error) { +// readQuota reads the account quota off the calibration thread, adopting a reply +// that is still inside the TTL before spending a probe on a fresh one. +// +// It also returns when that adopted reply was written, and the zero time when +// the reading is the answer to a probe this call posted. Only the caller's write +// knows the TTL in force when the reading is committed, and an adopted reply +// accepted under a longer one is exactly what a shortened TTL invalidates. +func (s *Service) readQuota(ctx context.Context, issue int, now time.Time, pendingAsked *time.Time, cfg Config) (AccountQuota, time.Time, error) { quota := AccountQuota{Scope: strings.Join(cfg.Scope, ","), Source: "calibrate", CheckedAt: &now} cutoff := now.Add(-cfg.CalibrationTTL) keepAfter := now.Add(-2 * cfg.CalibrationTTL) if reply, ok, err := s.latestCalibrationReply(ctx, issue, cutoff); err != nil { - return quota, err + return quota, time.Time{}, err } else if ok { remaining, reset := dialect.ParseQuota(reply.Body, reply.UpdatedAt) quota.Remaining = remaining quota.BlockedUntil = reset s.pruneCalibration(ctx, issue, keepAfter, 80) - return quota, nil + return quota, reply.UpdatedAt, nil } // A probe from a previous call is still pending and not yet stale, and no // reply to it was found: keep waiting for its (possibly late) reply instead of // posting another probe every cycle. if pendingAsked != nil && pendingAsked.After(cutoff) { quota.CalibAskedAt = pendingAsked - return quota, nil + return quota, time.Time{}, nil } asked, err := s.gh.PostIssueComment(ctx, s.cfg.GateRepo, issue, s.cfg.RateLimitCommand) if err != nil { @@ -2112,19 +2152,19 @@ func (s *Service) readQuota(ctx context.Context, issue int, now time.Time, pendi if s.log != nil { s.log.Printf("calibration probe on #%d failed: %v", issue, err) } - return quota, err + return quota, time.Time{}, err } } quota.CalibAskedAt = &asked.CreatedAt for i := 0; i < 6; i++ { select { case <-ctx.Done(): - return quota, ctx.Err() + return quota, time.Time{}, ctx.Err() case <-time.After(2 * time.Second): } reply, ok, err := s.latestCalibrationReply(ctx, issue, asked.CreatedAt.Add(-time.Second)) if err != nil { - return quota, err + return quota, time.Time{}, err } if ok { remaining, reset := dialect.ParseQuota(reply.Body, reply.UpdatedAt) @@ -2132,10 +2172,10 @@ func (s *Service) readQuota(ctx context.Context, issue int, now time.Time, pendi quota.BlockedUntil = reset quota.CalibAskedAt = nil s.pruneCalibration(ctx, issue, keepAfter, 80) - return quota, nil + return quota, time.Time{}, nil } } - return quota, nil + return quota, time.Time{}, nil } // pruneCalibration deletes crq's old calibration probe comments and CodeRabbit's diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index 7533fb29..e52578f1 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -2574,6 +2574,49 @@ func TestRefreshQuotaRecordsABlockAcrossAnUnrelatedFleetChange(t *testing.T) { } } +// The other half of that rule: a reading adopted under the TTL the read started +// with is exactly what a SHORTENED calibrate-ttl invalidates. Committing it +// would stamp CheckedAt=now over a reply the new window already calls too old, +// making it fresh for another full TTL and letting a metered review fire on a +// calibration the fleet has just asked to be redone. +func TestRefreshQuotaRejectsAReadingAShortenedTTLMakesStale(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.CalibrationPR = 77 + cfg.GateRepo = "o/state" + cfg.CalibrationTTL = 10 * time.Minute + gh := newFakeGitHub() + inner := NewMemoryStore(cfg) + store := &hookedStore{StateStore: inner} + svc := NewService(cfg, gh, store, nil) + now := time.Now().UTC() + svc.now = func() time.Time { return now } + + reply := ghapi.IssueComment{ + ID: 5, + Body: "auto-generated reply by CodeRabbit\n> **6 reviews** remaining", + CreatedAt: now.Add(-5 * time.Minute), UpdatedAt: now.Add(-5 * time.Minute), + } + reply.User.Login = cfg.Bot + gh.comments[fakeKey(cfg.GateRepo, 77)] = []ghapi.IssueComment{reply} + store.hook = func() { + if _, err := inner.Update(ctx, func(st *State) error { + st.SetFleetValue("calibrate-ttl", "1m") + return nil + }); err != nil { + t.Error(err) + } + } + + updated, err := svc.RefreshQuota(ctx) + if err != nil { + t.Fatal(err) + } + if updated.Account.CheckedAt != nil || updated.Account.Remaining != nil { + t.Fatalf("account = %+v, want the stale reading refused rather than stamped fresh", updated.Account) + } +} + func TestRefreshQuotaDoesNotProbeOrWriteInDryRun(t *testing.T) { ctx := context.Background() cfg := Config{ From dc2ba51bdd67eb92044788968da09d447e134b56 Mon Sep 17 00:00:00 2001 From: kristofferR <481270+kristofferR@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:09:39 +0200 Subject: [PATCH 37/37] Refuse a scope login GitHub could never issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validOwnerLogin already rejects the characters and the leading or trailing hyphen no GitHub login may hold, but let through two logins GitHub also cannot issue: one longer than 39 characters, and one with consecutive hyphens. Recorded fleet-wide, either fails every scan on every host at once — the reach that makes catching it at `crq config set` worth more than the per-host check. --- internal/crq/drainswitch.go | 18 ++++++++++++------ internal/crq/fleetconfig_test.go | 12 ++++++++---- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/internal/crq/drainswitch.go b/internal/crq/drainswitch.go index a412db4e..b16d70bc 100644 --- a/internal/crq/drainswitch.go +++ b/internal/crq/drainswitch.go @@ -106,16 +106,22 @@ func validRepoSlug(repo string) bool { return ok && validNameSegment(owner) && validNameSegment(name) } +// maxOwnerLogin is GitHub's hard length limit for a user or organisation login. +const maxOwnerLogin = 39 + // validOwnerLogin reports whether login is a GitHub user or organisation login. // // Stricter than validNameSegment on purpose: a repository NAME may hold -// underscores and dots, a login may not, and a login may neither begin nor end -// with a hyphen. Everything this validates is looked up as /users/, so a -// value outside those rules is one no account can ever have — and recorded for -// the fleet, a single such entry fails the whole scan on every host at once, -// which is precisely the mistake worth catching at `crq config set` instead. +// underscores and dots and may be long, a login may not, and a login may +// neither begin nor end with a hyphen nor hold two in a row. Everything this +// validates is looked up as /users/, so a value outside those rules is +// one no account can ever have — and recorded for the fleet, a single such +// entry fails the whole scan on every host at once, which is precisely the +// mistake worth catching at `crq config set` instead. func validOwnerLogin(login string) bool { - if login == "" || strings.HasPrefix(login, "-") || strings.HasSuffix(login, "-") { + if login == "" || len(login) > maxOwnerLogin || + strings.HasPrefix(login, "-") || strings.HasSuffix(login, "-") || + strings.Contains(login, "--") { return false } for _, r := range login { diff --git a/internal/crq/fleetconfig_test.go b/internal/crq/fleetconfig_test.go index 4efe1259..0cb186ec 100644 --- a/internal/crq/fleetconfig_test.go +++ b/internal/crq/fleetconfig_test.go @@ -91,10 +91,14 @@ func TestFleetRefusesWhatItCannotRead(t *testing.T) { // EachOpenPR as a user or organisation name, so a repository typed here — or // anything else GitHub cannot resolve — fails every pass, on every host at // once, because the value is fleet-wide. - // A login may not begin or end with a hyphen, and holds neither underscores - // nor dots — those are repository-name characters, and accepting them here - // records a scope no /users/ lookup can ever resolve. - for _, owner := range []string{"owner/repo", "own er", "owner?", "..", "-team", "team-", "a_team", "a.team"} { + // A login may not begin or end with a hyphen, hold two in a row, run past + // GitHub's 39-character limit, or hold underscores or dots — those are + // repository-name characters, and accepting any of them here records a scope + // no /users/ lookup can ever resolve. + for _, owner := range []string{ + "owner/repo", "own er", "owner?", "..", "-team", "team-", "a_team", "a.team", + "a--team", strings.Repeat("a", maxOwnerLogin+1), + } { if err := svc.SetFleetConfig(ctx, "scope", owner); err == nil { t.Errorf("scope accepted the malformed owner %q", owner) }