Let a failed Patrol run leave the Community hourly slot free

The 1-run-per-hour Community cadence gate on manual Patrol runs keyed off
lastFullPatrol, which is stamped on every completed run including errored
ones. Debugging a broken provider therefore cost an hour per attempt,
raised in discussion #1571. The gate now keys off the most recent
successful full run from history, matching the success-aware skip logic
the startup path already uses.

Contract-Neutral: Behavioral fix: Community manual-Patrol cadence gate now ignores failed runs; no API payload or endpoint change (#1571)
This commit is contained in:
rcourtman
2026-07-22 12:08:37 +01:00
parent 4a516ae79b
commit 0296e4ed3e
3 changed files with 67 additions and 5 deletions
+20
View File
@@ -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
+6 -3
View File
@@ -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)
@@ -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.