diff --git a/internal/ai/patrol_run.go b/internal/ai/patrol_run.go index 4280a5e02..38477446c 100644 --- a/internal/ai/patrol_run.go +++ b/internal/ai/patrol_run.go @@ -263,6 +263,26 @@ func shouldSkipInitialFullPatrol(runHistory []PatrolRunRecord, now time.Time) bo return false } +// LastSuccessfulFullPatrolAt returns the completion time of the most recent +// full patrol run that finished without errors, or the zero time when none +// exists. The manual-run cadence gate uses this rather than lastFullPatrol so +// a failed run does not consume the Community hourly slot. +func (p *PatrolService) LastSuccessfulFullPatrolAt() time.Time { + if p == nil || p.runHistoryStore == nil { + return time.Time{} + } + var last time.Time + for _, run := range p.runHistoryStore.GetAll() { + if run.CompletedAt.IsZero() || !isSuccessfulFullPatrolRun(run) { + continue + } + if run.CompletedAt.After(last) { + last = run.CompletedAt + } + } + return last +} + func patrolRecencyFromHistory(runHistory []PatrolRunRecord) (time.Time, time.Time) { var lastActivity time.Time var lastFullPatrol time.Time diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 2f7e2777b..3d01a12d8 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -5925,10 +5925,13 @@ func (h *AISettingsHandler) HandleForcePatrol(w http.ResponseWriter, r *http.Req } // Cadence cap: Community tier is limited to 1 patrol run per hour. - // Patrol itself is free (ai_patrol), but higher cadence is gated behind Pro/Cloud. + // Patrol itself is free (ai_patrol), but higher cadence is gated behind + // Pro/Cloud. Only successful runs consume the hourly slot: a failed run + // delivered no coverage, and counting it turns every configuration or + // provider problem into an hour-long retest loop. if !aiService.HasLicenseFeature(featureAIAutoFixValue) { - if last := patrol.GetStatus().LastPatrolAt; last != nil { - if since := time.Since(*last); since < 1*time.Hour { + if last := patrol.LastSuccessfulFullPatrolAt(); !last.IsZero() { + if since := time.Since(last); since < 1*time.Hour { remaining := (1*time.Hour - since).Round(time.Minute) writeErrorResponse(w, http.StatusTooManyRequests, "patrol_rate_limited", fmt.Sprintf("Community tier is limited to 1 patrol run per hour. Try again in %s.", remaining), nil) diff --git a/internal/api/ai_handlers_patrol_actions_additional_test.go b/internal/api/ai_handlers_patrol_actions_additional_test.go index 01917ca1e..66b34277a 100644 --- a/internal/api/ai_handlers_patrol_actions_additional_test.go +++ b/internal/api/ai_handlers_patrol_actions_additional_test.go @@ -1062,6 +1062,37 @@ func TestHandleForcePatrol_CommunityTierIgnoresRecentScopedActivityForFullPatrol } } +func TestHandleForcePatrol_CommunityTierFailedRunDoesNotConsumeHourlySlot(t *testing.T) { + handler, patrol, _, _ := setupAIHandlerWithPatrol(t) + seedReadyAnthropicPatrolRuntime(t, handler) + handler.defaultAIService.SetLicenseChecker(communityLicenseChecker{}) + + // A recent full run that FAILED must not consume the Community hourly + // slot: retesting a broken provider or configuration would otherwise cost + // an hour per attempt (discussion #1571). + failedHistory := ai.NewPatrolRunHistoryStore(ai.MaxPatrolRunHistory) + failedHistory.Add(ai.PatrolRunRecord{ + ID: "run-recent-failure", + Type: "patrol", + CompletedAt: time.Now().Add(-10 * time.Minute), + Status: "error", + ErrorCount: 1, + }) + setUnexportedField(t, patrol, "runHistoryStore", failedHistory) + setUnexportedField(t, patrol, "lastFullPatrol", time.Now().Add(-10*time.Minute)) + + req := newLoopbackRequest(http.MethodPost, "/api/ai/patrol/run", nil) + rec := httptest.NewRecorder() + handler.HandleForcePatrol(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "patrol_rate_limited") { + t.Fatalf("expected failed run to leave the hourly slot free, got %s", rec.Body.String()) + } +} + func TestBuildManualScopedPatrolScope(t *testing.T) { cases := []struct { name string @@ -1136,8 +1167,16 @@ func TestHandleForcePatrol_ScopedRequestBypassesFullRunCadenceGate(t *testing.T) }}) handler.defaultAIService.SetLicenseChecker(communityLicenseChecker{}) - // A recent full patrol puts Community tier inside the 1/hour full-run gate, - // so a fleet-wide request would be rate-limited. + // A recent successful full patrol puts Community tier inside the 1/hour + // full-run gate, so a fleet-wide request would be rate-limited. + recentHistory := ai.NewPatrolRunHistoryStore(ai.MaxPatrolRunHistory) + recentHistory.Add(ai.PatrolRunRecord{ + ID: "run-recent-success", + Type: "patrol", + CompletedAt: time.Now().Add(-10 * time.Minute), + Status: "healthy", + }) + setUnexportedField(t, patrol, "runHistoryStore", recentHistory) setUnexportedField(t, patrol, "lastFullPatrol", time.Now().Add(-10*time.Minute)) // Scoped request: must succeed despite the full-run cadence window.