mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user