mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
fix(telemetry): make patrol run volume and refusal causes readable
Two counters could not answer the question they exist for. pulse_intelligence_patrol_runs_30d was counted from the operator-facing patrol run history, which is capped at MaxPatrolRunHistory (100). On any install patrolling on a normal schedule that cap is reached within hours of a thirty-day window opening, so the field reported the cap rather than the run count, and the calls-per-run ratio derived from it was inflated by a censored denominator. Patrol run history now carries an uncapped daily tally beside the capped list, advanced from a persisted high-water mark so repeated full-list saves cannot double count, pruned to 31 days, and read in preference to the list. An install with no tally yet falls back to the list, so the counter never regresses on upgrade. Schema v8 split agent-side pre-mutation refusals into target-change, prerequisite and contract categories so they would stop hiding in "other". In production all three are zero fleet-wide and "other" still absorbs every refusal, because the dominant reason code is the legacy preflight_refused aggregate recorded when an agent sends no machine reason code at all. Agents older than the typed refusal contract report every refusal that way, so the split is starved rather than broken, and folding the two together made those two states indistinguishable. Schema v9 counts uncoded refusals separately.
This commit is contained in:
@@ -187,6 +187,7 @@ Every field is listed below with the reason it exists. Nothing else is included
|
||||
| Pulse Intelligence approved action refusals: target changed 30d | `1` | Count pre-dispatch refusals caused by target or inventory drift after planning without sending target, inventory, or command details |
|
||||
| Pulse Intelligence approved action refusals: prerequisite 30d | `1` | Count pre-dispatch refusals caused by an unavailable inspection, busy or unhealthy package manager, or failed bounded preflight without sending raw errors, targets, or command details |
|
||||
| Pulse Intelligence approved action refusals: contract 30d | `1` | Count pre-dispatch refusals caused by an invalid typed action contract without sending payloads, targets, or command details |
|
||||
| Pulse Intelligence approved action refusals: uncoded 30d | `1` | Count pre-dispatch refusals that arrived with no machine reason code, which is what an agent older than the typed refusal contract reports, without sending raw error or action content |
|
||||
| Pulse Intelligence approved action refusals: other 30d | `1` | Count pre-dispatch refusals not covered by the fixed categories without sending raw error or action content |
|
||||
| Pulse Intelligence verified finding resolutions 30d | `1` | Count approved Patrol-origin actions whose execution succeeded and whose linked finding postcondition was independently confirmed, without sending finding IDs, action IDs, evidence, resources, or fix details |
|
||||
| Pulse Intelligence approved action last failure reason 30d | `plan_drift` | See one fixed machine reason code for the most recent approved-action failure in the current 30-day telemetry window without sending error text, action output, command text, resource IDs, or actors |
|
||||
|
||||
@@ -923,7 +923,12 @@ agent timestamp. It cannot approve, admit, reserve, execute, or verify a
|
||||
mutation. Current agents advertise protocol version 1 at registration; a
|
||||
server with the transport but an older connected agent fails concrete
|
||||
feasibility closed, while internal executors that predate the optional
|
||||
transport retain their compatibility path. Dispatch still repeats all local
|
||||
transport retain their compatibility path. An agent older than the bounded
|
||||
refusal-code vocabulary reports pre-mutation refusals with no reason code at
|
||||
all. The API layer records those under the `preflight_refused` legacy aggregate
|
||||
and aggregate telemetry counts them as `uncoded` rather than `other`, so the
|
||||
share of the fleet still awaiting an agent capable of typed refusals stays
|
||||
measurable and is never mistaken for a categorization failure. Dispatch still repeats all local
|
||||
preconditions after durable admission because preflight evidence is not a
|
||||
lease and target state may race after approval.
|
||||
Proxmox VM and LXC lifecycle affordances follow the same adjacent boundary:
|
||||
|
||||
@@ -9312,14 +9312,23 @@ together: `type Ping struct` in `internal/telemetry/telemetry.go`, the
|
||||
Retired columns stay in the live receiver database rather than being dropped:
|
||||
migrations only add, the columns hold real rows from the schema-v6 window, and
|
||||
nothing writes them once the receiver struct loses the fields.
|
||||
### Telemetry payload parity spans three surfaces at schema v8
|
||||
### Telemetry payload parity spans three surfaces at schema v9
|
||||
|
||||
Schema v8 preserves the same three-surface parity requirement and adds
|
||||
Schema v8 preserved the same three-surface parity requirement and added
|
||||
content-free approved-action refusal counters for target change, prerequisite
|
||||
failure, and invalid typed contracts. These counters partition agent-side
|
||||
pre-mutation refusals that previously accumulated in `other`; no raw error,
|
||||
command, target, resource identifier, or action payload is exported.
|
||||
|
||||
Schema v9 adds the content-free `uncoded` refusal counter and completes that
|
||||
partition. A pre-dispatch refusal that carries no machine reason code is
|
||||
recorded under the legacy `preflight_refused` aggregate, which is what every
|
||||
agent older than the typed refusal contract reports, and it must not be counted
|
||||
as `other`. `other` means a typed code this server does not recognise, so
|
||||
collapsing the two made a partition starved by agent rollout read exactly like
|
||||
a partition that is wrong. The `uncoded` counter is a count only; the refusal's
|
||||
error text, command, target, and action payload stay on the install.
|
||||
|
||||
### Per-tenant resource stores are released on offboarding and shutdown
|
||||
|
||||
`ResourceHandlers.getStore` opens a SQLite handle per org and caches it for the
|
||||
|
||||
@@ -2104,9 +2104,14 @@ actor, and every audit row read stay on the install.
|
||||
|
||||
### Telemetry ingestion matches the released sender while storage stays compatible
|
||||
|
||||
The active outbound contract is schema v8. It adds content-free approved-action
|
||||
refusal counters for target change, prerequisite failure, and invalid typed
|
||||
contract so agent-side pre-mutation failures no longer collapse into `other`.
|
||||
The active outbound contract is schema v9. Schema v8 added content-free
|
||||
approved-action refusal counters for target change, prerequisite failure, and
|
||||
invalid typed contract so agent-side pre-mutation failures no longer collapse
|
||||
into `other`. Schema v9 completes that split with a content-free `uncoded`
|
||||
counter for refusals that carried no machine reason code at all, which is what
|
||||
every agent older than the typed refusal contract reports. Without it a split
|
||||
starved by agent rollout is indistinguishable from a broken one, because both
|
||||
present as `other` absorbing every refusal.
|
||||
The earlier draft schema-v8 `business_estate` field was reverted and must not
|
||||
remain in the license server's accepted ping struct merely because a private receiver build
|
||||
and database migration briefly carried it. Existing deployed databases need no
|
||||
|
||||
@@ -369,6 +369,15 @@ verification stdout, command text, resource IDs, or backup identities. The legac
|
||||
the legacy completed/resolved/value `proActivation*` fields mirror the same
|
||||
Patrol control outcome classifier as compatibility aliases and do not create a
|
||||
second storage/recovery signal.
|
||||
|
||||
Persisted Patrol run history carries an uncapped per-UTC-day run tally beside
|
||||
its capped newest-first run list. The list is bounded for the operator-facing
|
||||
history and saturates within hours on a normally scheduled install, so it
|
||||
cannot serve as a run count over a reporting window; the tally advances from a
|
||||
persisted high-water mark, is pruned to its retention bound, and is a count of
|
||||
runs only. It never records run identity, scope resource IDs, finding IDs,
|
||||
evidence, or narrative, and it is not recovery coverage, backup verification,
|
||||
or storage-health proof.
|
||||
First-party workflow starter activity recorded through shared `internal/api/`
|
||||
handlers, including Pro activation entry-point telemetry for the same
|
||||
operations-loop prompt, is likewise API/privacy/commercial activation evidence
|
||||
|
||||
@@ -187,6 +187,7 @@ Every field is listed below with the reason it exists. Nothing else is included
|
||||
| Pulse Intelligence approved action refusals: target changed 30d | `1` | Count pre-dispatch refusals caused by target or inventory drift after planning without sending target, inventory, or command details |
|
||||
| Pulse Intelligence approved action refusals: prerequisite 30d | `1` | Count pre-dispatch refusals caused by an unavailable inspection, busy or unhealthy package manager, or failed bounded preflight without sending raw errors, targets, or command details |
|
||||
| Pulse Intelligence approved action refusals: contract 30d | `1` | Count pre-dispatch refusals caused by an invalid typed action contract without sending payloads, targets, or command details |
|
||||
| Pulse Intelligence approved action refusals: uncoded 30d | `1` | Count pre-dispatch refusals that arrived with no machine reason code, which is what an agent older than the typed refusal contract reports, without sending raw error or action content |
|
||||
| Pulse Intelligence approved action refusals: other 30d | `1` | Count pre-dispatch refusals not covered by the fixed categories without sending raw error or action content |
|
||||
| Pulse Intelligence verified finding resolutions 30d | `1` | Count approved Patrol-origin actions whose execution succeeded and whose linked finding postcondition was independently confirmed, without sending finding IDs, action IDs, evidence, resources, or fix details |
|
||||
| Pulse Intelligence approved action last failure reason 30d | `plan_drift` | See one fixed machine reason code for the most recent approved-action failure in the current 30-day telemetry window without sending error text, action output, command text, resource IDs, or actors |
|
||||
|
||||
@@ -24396,3 +24396,34 @@ func TestContract_ResourceListReportsPlatformAdmission(t *testing.T) {
|
||||
t.Fatalf("provider-owned agent must not admit the standalone page, got %+v", admission)
|
||||
}
|
||||
}
|
||||
|
||||
// The schema-v9 approved-action refusal partition must keep an uncoded refusal
|
||||
// out of the "other" bucket. `other` means a typed reason code this server does
|
||||
// not recognise; a refusal that carried no reason code at all is what every
|
||||
// agent older than the typed refusal contract reports, and counting the two
|
||||
// together makes a partition starved by agent rollout look like a broken one.
|
||||
func TestApprovedActionRefusalPartitionSeparatesUncodedFromOther(t *testing.T) {
|
||||
// An agent result with no reason code lands on the legacy aggregate.
|
||||
if got := actionPreflightReasonCode(""); got != actionRefusalUncoded {
|
||||
t.Fatalf("actionPreflightReasonCode(\"\") = %q, want %q", got, actionRefusalUncoded)
|
||||
}
|
||||
if got := actionPreflightReasonCode(" "); got != actionRefusalUncoded {
|
||||
t.Fatalf("actionPreflightReasonCode(whitespace) = %q, want %q", got, actionRefusalUncoded)
|
||||
}
|
||||
// A typed code from a current agent is preserved rather than aggregated.
|
||||
if got := actionPreflightReasonCode("target_state_changed"); got != "target_state_changed" {
|
||||
t.Fatalf("typed reason code was not preserved, got %q", got)
|
||||
}
|
||||
|
||||
// The legacy aggregate is counted as uncoded, never as other.
|
||||
if got := pulseIntelligenceApprovedActionRefusalCategory(actionRefusalUncoded); got != "uncoded" {
|
||||
t.Fatalf("category(%q) = %q, want uncoded", actionRefusalUncoded, got)
|
||||
}
|
||||
if got := pulseIntelligenceApprovedActionRefusalCategory("pre_dispatch_refused"); got != "uncoded" {
|
||||
t.Fatalf("category(pre_dispatch_refused) = %q, want uncoded", got)
|
||||
}
|
||||
// An unrecognised typed code stays in other, so the two remain separable.
|
||||
if got := pulseIntelligenceApprovedActionRefusalCategory("some_future_typed_code"); got != "other" {
|
||||
t.Fatalf("category(some_future_typed_code) = %q, want other", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +205,11 @@ func dockerContainerUpdateFactSummary(facts agentexec.DockerContainerUpdateResul
|
||||
return summary
|
||||
}
|
||||
|
||||
// actionRefusalUncoded is the legacy aggregate recorded when a pre-mutation
|
||||
// refusal arrives with no machine reason code. Agents older than the typed
|
||||
// refusal contract report every refusal this way.
|
||||
const actionRefusalUncoded = "preflight_refused"
|
||||
|
||||
// actionPreflightReasonCode preserves typed agent refusals while retaining the
|
||||
// legacy aggregate for older agents that do not yet send a reason_code.
|
||||
func actionPreflightReasonCode(reasonCode string) string {
|
||||
@@ -212,7 +217,7 @@ func actionPreflightReasonCode(reasonCode string) string {
|
||||
if agentexec.IsActionRefusalReasonCode(reasonCode) {
|
||||
return reasonCode
|
||||
}
|
||||
return "preflight_refused"
|
||||
return actionRefusalUncoded
|
||||
}
|
||||
|
||||
func shortDockerDigest(digest string) string {
|
||||
|
||||
@@ -140,6 +140,8 @@ func accumulatePulseIntelligenceApprovedActionOutcomes(snapshot *telemetry.Pulse
|
||||
snapshot.ApprovedActionRefusalsPrerequisite30d++
|
||||
case "contract":
|
||||
snapshot.ApprovedActionRefusalsContract30d++
|
||||
case "uncoded":
|
||||
snapshot.ApprovedActionRefusalsUncoded30d++
|
||||
default:
|
||||
snapshot.ApprovedActionRefusalsOther30d++
|
||||
}
|
||||
@@ -204,6 +206,13 @@ func pulseIntelligenceApprovedActionRefusalCategory(reason string) string {
|
||||
return "prerequisite"
|
||||
case "action_contract_invalid":
|
||||
return "contract"
|
||||
case actionRefusalUncoded, "pre_dispatch_refused":
|
||||
// The refusal carried no machine reason code, so there is nothing to
|
||||
// categorize. This is what an agent older than the typed refusal
|
||||
// contract reports, and it must stay separable from a code this server
|
||||
// simply does not recognise: the first says the split is waiting on
|
||||
// agent rollout, the second says the split is wrong.
|
||||
return "uncoded"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
|
||||
@@ -340,15 +340,17 @@ func TestGetPulseIntelligenceActionTelemetry_AttributesApprovedActionFailureCaus
|
||||
got.ApprovedActionRefusalsTargetChanged30d != 0 ||
|
||||
got.ApprovedActionRefusalsPrerequisite30d != 0 ||
|
||||
got.ApprovedActionRefusalsContract30d != 0 ||
|
||||
got.ApprovedActionRefusalsUncoded30d != 0 ||
|
||||
got.ApprovedActionRefusalsOther30d != 0 {
|
||||
t.Fatalf(
|
||||
"refusal categories = %d/%d/%d/%d/%d/%d/%d, want 1/0/0/0/0/0/0",
|
||||
"refusal categories = %d/%d/%d/%d/%d/%d/%d/%d, want 1/0/0/0/0/0/0/0",
|
||||
got.ApprovedActionRefusalsPlanStale30d,
|
||||
got.ApprovedActionRefusalsPolicy30d,
|
||||
got.ApprovedActionRefusalsCapability30d,
|
||||
got.ApprovedActionRefusalsTargetChanged30d,
|
||||
got.ApprovedActionRefusalsPrerequisite30d,
|
||||
got.ApprovedActionRefusalsContract30d,
|
||||
got.ApprovedActionRefusalsUncoded30d,
|
||||
got.ApprovedActionRefusalsOther30d,
|
||||
)
|
||||
}
|
||||
@@ -461,6 +463,8 @@ func TestPulseIntelligenceApprovedActionRefusalCategory(t *testing.T) {
|
||||
"package_manager_unhealthy": "prerequisite",
|
||||
"cleanup_preflight_failed": "prerequisite",
|
||||
"action_contract_invalid": "contract",
|
||||
"preflight_refused": "uncoded",
|
||||
"pre_dispatch_refused": "uncoded",
|
||||
"future_reason": "other",
|
||||
}
|
||||
for reason, want := range tests {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A patrol run history that has already saturated the operator-facing cap must
|
||||
// still report the true run count for a thirty-day telemetry window.
|
||||
func TestPatrolRunTallySurvivesHistoryCap(t *testing.T) {
|
||||
p := NewConfigPersistence(t.TempDir())
|
||||
now := time.Now().UTC()
|
||||
since := now.AddDate(0, 0, -30)
|
||||
|
||||
// Patrol every five minutes for three days, saving the newest-first
|
||||
// capped window after each run exactly as the run history store does.
|
||||
const cap = 100
|
||||
total := 0
|
||||
runs := make([]PatrolRunRecord, 0, cap)
|
||||
for at := now.AddDate(0, 0, -3); at.Before(now); at = at.Add(5 * time.Minute) {
|
||||
runs = append([]PatrolRunRecord{{ID: at.Format(time.RFC3339Nano), StartedAt: at, CompletedAt: at}}, runs...)
|
||||
if len(runs) > cap {
|
||||
runs = runs[:cap]
|
||||
}
|
||||
total++
|
||||
if err := p.SavePatrolRunHistory(runs); err != nil {
|
||||
t.Fatalf("SavePatrolRunHistory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
loaded, err := p.LoadPatrolRunHistory()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPatrolRunHistory: %v", err)
|
||||
}
|
||||
if len(loaded.Runs) != cap {
|
||||
t.Fatalf("retained runs = %d, want the capped %d", len(loaded.Runs), cap)
|
||||
}
|
||||
if got := loaded.PatrolRunsSince(since); got != total {
|
||||
t.Fatalf("PatrolRunsSince = %d, want %d (the cap would have reported %d)", got, total, cap)
|
||||
}
|
||||
}
|
||||
|
||||
// Repeated saves of an overlapping newest-first window must not double count.
|
||||
func TestPatrolRunTallyDoesNotDoubleCount(t *testing.T) {
|
||||
p := NewConfigPersistence(t.TempDir())
|
||||
now := time.Now().UTC()
|
||||
runs := []PatrolRunRecord{
|
||||
{ID: "c", StartedAt: now.Add(-1 * time.Hour), CompletedAt: now.Add(-1 * time.Hour)},
|
||||
{ID: "b", StartedAt: now.Add(-2 * time.Hour), CompletedAt: now.Add(-2 * time.Hour)},
|
||||
{ID: "a", StartedAt: now.Add(-3 * time.Hour), CompletedAt: now.Add(-3 * time.Hour)},
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
if err := p.SavePatrolRunHistory(runs); err != nil {
|
||||
t.Fatalf("SavePatrolRunHistory: %v", err)
|
||||
}
|
||||
}
|
||||
loaded, err := p.LoadPatrolRunHistory()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPatrolRunHistory: %v", err)
|
||||
}
|
||||
if got := loaded.PatrolRunsSince(now.AddDate(0, 0, -30)); got != 3 {
|
||||
t.Fatalf("PatrolRunsSince = %d, want 3 after four saves of the same window", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An install upgrading into the tally has no tallied days yet, so the counter
|
||||
// must fall back to the run list rather than reporting zero.
|
||||
func TestPatrolRunsSinceFallsBackToHistory(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
data := &PatrolRunHistoryData{Runs: []PatrolRunRecord{
|
||||
{ID: "a", StartedAt: now.Add(-2 * time.Hour), CompletedAt: now.Add(-2 * time.Hour)},
|
||||
{ID: "b", StartedAt: now.Add(-1 * time.Hour), CompletedAt: now.Add(-1 * time.Hour)},
|
||||
{ID: "old", StartedAt: now.AddDate(0, 0, -40), CompletedAt: now.AddDate(0, 0, -40)},
|
||||
}}
|
||||
if got := data.PatrolRunsSince(now.AddDate(0, 0, -30)); got != 2 {
|
||||
t.Fatalf("PatrolRunsSince = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Days that fall out of the retention window must be pruned so the tally stays
|
||||
// bounded no matter how long an install runs.
|
||||
func TestPatrolRunTallyPrunesOldDays(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
data := &PatrolRunHistoryData{DailyRuns: map[string]int{
|
||||
PatrolRunTallyDayKey(now.AddDate(0, 0, -400)): 12,
|
||||
PatrolRunTallyDayKey(now.AddDate(0, 0, -40)): 7,
|
||||
PatrolRunTallyDayKey(now.AddDate(0, 0, -1)): 3,
|
||||
}}
|
||||
advancePatrolRunTally(data, nil, now)
|
||||
if _, ok := data.DailyRuns[PatrolRunTallyDayKey(now.AddDate(0, 0, -400))]; ok {
|
||||
t.Fatal("400-day-old tally day was not pruned")
|
||||
}
|
||||
if _, ok := data.DailyRuns[PatrolRunTallyDayKey(now.AddDate(0, 0, -40))]; ok {
|
||||
t.Fatal("40-day-old tally day was not pruned")
|
||||
}
|
||||
if data.DailyRuns[PatrolRunTallyDayKey(now.AddDate(0, 0, -1))] != 3 {
|
||||
t.Fatal("yesterday's tally day was pruned")
|
||||
}
|
||||
}
|
||||
@@ -2653,6 +2653,98 @@ type PatrolRunHistoryData struct {
|
||||
Version int `json:"version"`
|
||||
LastSaved time.Time `json:"last_saved"`
|
||||
Runs []PatrolRunRecord `json:"runs"`
|
||||
|
||||
// DailyRuns counts completed patrol runs per UTC day. Runs is deliberately
|
||||
// capped for the operator-facing history, so on any install patrolling on a
|
||||
// normal schedule it saturates hours into a thirty-day reporting window and
|
||||
// cannot answer "how often did Patrol actually run". This tally is the
|
||||
// uncapped answer and costs one small integer per retained day.
|
||||
DailyRuns map[string]int `json:"daily_runs,omitempty"`
|
||||
// RunTallyThrough is the newest run observation already folded into
|
||||
// DailyRuns. History is persisted as a full newest-first list on every
|
||||
// debounced save, so the tally advances from this high-water mark rather
|
||||
// than re-counting runs it has already seen.
|
||||
RunTallyThrough time.Time `json:"run_tally_through,omitempty"`
|
||||
}
|
||||
|
||||
// patrolRunTallyRetentionDays bounds DailyRuns. It exceeds the thirty-day
|
||||
// telemetry window by a day so the day the window opens on is still present
|
||||
// when the tally is read.
|
||||
const patrolRunTallyRetentionDays = 31
|
||||
|
||||
// PatrolRunTallyDayKey renders the UTC day key used by PatrolRunHistoryData.DailyRuns.
|
||||
func PatrolRunTallyDayKey(at time.Time) string {
|
||||
return at.UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// patrolRunObservedAt reports when a run should be counted, preferring the
|
||||
// completion time and falling back to the start time for runs that never
|
||||
// recorded one.
|
||||
func patrolRunObservedAt(run PatrolRunRecord) time.Time {
|
||||
if !run.CompletedAt.IsZero() {
|
||||
return run.CompletedAt
|
||||
}
|
||||
return run.StartedAt
|
||||
}
|
||||
|
||||
// advancePatrolRunTally folds runs newer than the existing high-water mark into
|
||||
// the daily tally and prunes days outside the retention window.
|
||||
func advancePatrolRunTally(data *PatrolRunHistoryData, runs []PatrolRunRecord, now time.Time) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
if data.DailyRuns == nil {
|
||||
data.DailyRuns = make(map[string]int, patrolRunTallyRetentionDays)
|
||||
}
|
||||
newest := data.RunTallyThrough
|
||||
for _, run := range runs {
|
||||
observedAt := patrolRunObservedAt(run)
|
||||
if observedAt.IsZero() || !observedAt.After(data.RunTallyThrough) {
|
||||
continue
|
||||
}
|
||||
data.DailyRuns[PatrolRunTallyDayKey(observedAt)]++
|
||||
if observedAt.After(newest) {
|
||||
newest = observedAt
|
||||
}
|
||||
}
|
||||
data.RunTallyThrough = newest
|
||||
|
||||
cutoff := PatrolRunTallyDayKey(now.UTC().AddDate(0, 0, -patrolRunTallyRetentionDays))
|
||||
for day := range data.DailyRuns {
|
||||
if day < cutoff {
|
||||
delete(data.DailyRuns, day)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PatrolRunsSince counts patrol runs observed at or after since. It prefers the
|
||||
// uncapped daily tally and falls back to the capped run list, taking whichever
|
||||
// is larger so an install that has not yet accumulated a tally never reports
|
||||
// fewer runs than it did before the tally existed. The tally is day-granular,
|
||||
// so the day containing since is counted whole.
|
||||
func (data *PatrolRunHistoryData) PatrolRunsSince(since time.Time) int {
|
||||
if data == nil {
|
||||
return 0
|
||||
}
|
||||
fromHistory := 0
|
||||
for _, run := range data.Runs {
|
||||
observedAt := patrolRunObservedAt(run)
|
||||
if observedAt.IsZero() || observedAt.Before(since) {
|
||||
continue
|
||||
}
|
||||
fromHistory++
|
||||
}
|
||||
fromTally := 0
|
||||
sinceDay := PatrolRunTallyDayKey(since)
|
||||
for day, count := range data.DailyRuns {
|
||||
if day >= sinceDay {
|
||||
fromTally += count
|
||||
}
|
||||
}
|
||||
if fromTally > fromHistory {
|
||||
return fromTally
|
||||
}
|
||||
return fromHistory
|
||||
}
|
||||
|
||||
// PatrolRunRecord represents a single patrol check run
|
||||
@@ -3481,13 +3573,20 @@ func (c *ConfigPersistence) LoadWorkflowPromptActivityHistory() (*WorkflowPrompt
|
||||
)
|
||||
}
|
||||
|
||||
// SavePatrolRunHistory persists patrol run history to disk
|
||||
// SavePatrolRunHistory persists patrol run history to disk, carrying the
|
||||
// uncapped daily run tally forward across the capped run list it replaces.
|
||||
func (c *ConfigPersistence) SavePatrolRunHistory(runs []PatrolRunRecord) error {
|
||||
now := time.Now()
|
||||
data := PatrolRunHistoryData{
|
||||
Version: 1,
|
||||
LastSaved: time.Now(),
|
||||
LastSaved: now,
|
||||
Runs: runs,
|
||||
}
|
||||
if existing, err := c.LoadPatrolRunHistory(); err == nil && existing != nil {
|
||||
data.DailyRuns = existing.DailyRuns
|
||||
data.RunTallyThrough = existing.RunTallyThrough
|
||||
}
|
||||
advancePatrolRunTally(&data, runs, now)
|
||||
return saveHistoryData(c, c.aiPatrolRunsFile, data, len(runs), "patrol run history", "Patrol run history")
|
||||
}
|
||||
|
||||
@@ -3510,6 +3609,9 @@ func (c *ConfigPersistence) LoadPatrolRunHistory() (*PatrolRunHistoryData, error
|
||||
if data.Runs == nil {
|
||||
data.Runs = make([]PatrolRunRecord, 0)
|
||||
}
|
||||
if data.DailyRuns == nil {
|
||||
data.DailyRuns = make(map[string]int)
|
||||
}
|
||||
},
|
||||
func(data *PatrolRunHistoryData) error {
|
||||
jsonData, err := json.Marshal(data)
|
||||
|
||||
@@ -150,7 +150,11 @@ const (
|
||||
// Schema v8 splits agent-side pre-mutation refusals into target-change,
|
||||
// prerequisite, and invalid-contract categories so the legacy "other"
|
||||
// bucket no longer hides actionable product failures.
|
||||
TelemetrySchemaVersion = 8
|
||||
// Schema v9 separates refusals that carried no machine reason code at all
|
||||
// from refusals whose code is simply unrecognised. Agents that predate the
|
||||
// typed refusal contract report the former, and folding them into "other"
|
||||
// made a starved split indistinguishable from a broken one.
|
||||
TelemetrySchemaVersion = 9
|
||||
)
|
||||
|
||||
type installIDRecord struct {
|
||||
@@ -343,6 +347,7 @@ type Ping struct {
|
||||
PulseIntelligenceApprovedActionRefusalsTargetChanged30d int `json:"pulse_intelligence_approved_action_refusals_target_changed_30d"`
|
||||
PulseIntelligenceApprovedActionRefusalsPrerequisite30d int `json:"pulse_intelligence_approved_action_refusals_prerequisite_30d"`
|
||||
PulseIntelligenceApprovedActionRefusalsContract30d int `json:"pulse_intelligence_approved_action_refusals_contract_30d"`
|
||||
PulseIntelligenceApprovedActionRefusalsUncoded30d int `json:"pulse_intelligence_approved_action_refusals_uncoded_30d"`
|
||||
PulseIntelligenceApprovedActionRefusalsOther30d int `json:"pulse_intelligence_approved_action_refusals_other_30d"`
|
||||
PulseIntelligenceVerifiedFindingResolutions30d int `json:"pulse_intelligence_verified_finding_resolutions_30d"`
|
||||
PulseIntelligenceApprovedActionLastFailureReason30d string `json:"pulse_intelligence_approved_action_last_failure_reason_30d,omitempty"`
|
||||
@@ -483,6 +488,7 @@ type Snapshot struct {
|
||||
PulseIntelligenceApprovedActionRefusalsTargetChanged30d int
|
||||
PulseIntelligenceApprovedActionRefusalsPrerequisite30d int
|
||||
PulseIntelligenceApprovedActionRefusalsContract30d int
|
||||
PulseIntelligenceApprovedActionRefusalsUncoded30d int
|
||||
PulseIntelligenceApprovedActionRefusalsOther30d int
|
||||
PulseIntelligenceVerifiedFindingResolutions30d int
|
||||
PulseIntelligenceApprovedActionLastFailureReason30d string
|
||||
@@ -528,7 +534,11 @@ type PulseIntelligenceActionSnapshot struct {
|
||||
ApprovedActionRefusalsTargetChanged30d int
|
||||
ApprovedActionRefusalsPrerequisite30d int
|
||||
ApprovedActionRefusalsContract30d int
|
||||
ApprovedActionRefusalsOther30d int
|
||||
// ApprovedActionRefusalsUncoded30d counts pre-dispatch refusals that
|
||||
// arrived without any machine reason code, which is what an agent older
|
||||
// than the typed refusal contract reports.
|
||||
ApprovedActionRefusalsUncoded30d int
|
||||
ApprovedActionRefusalsOther30d int
|
||||
// VerifiedFindingResolutions30d counts completed, approved Patrol-origin
|
||||
// actions whose postcondition was independently confirmed. No finding or
|
||||
// action identity leaves the runtime.
|
||||
@@ -1078,6 +1088,7 @@ func applySnapshot(base Ping, fn SnapshotFunc) Ping {
|
||||
ping.PulseIntelligenceApprovedActionRefusalsTargetChanged30d = s.PulseIntelligenceApprovedActionRefusalsTargetChanged30d
|
||||
ping.PulseIntelligenceApprovedActionRefusalsPrerequisite30d = s.PulseIntelligenceApprovedActionRefusalsPrerequisite30d
|
||||
ping.PulseIntelligenceApprovedActionRefusalsContract30d = s.PulseIntelligenceApprovedActionRefusalsContract30d
|
||||
ping.PulseIntelligenceApprovedActionRefusalsUncoded30d = s.PulseIntelligenceApprovedActionRefusalsUncoded30d
|
||||
ping.PulseIntelligenceApprovedActionRefusalsOther30d = s.PulseIntelligenceApprovedActionRefusalsOther30d
|
||||
ping.PulseIntelligenceVerifiedFindingResolutions30d = s.PulseIntelligenceVerifiedFindingResolutions30d
|
||||
ping.PulseIntelligenceApprovedActionLastFailureReason30d = s.PulseIntelligenceApprovedActionLastFailureReason30d
|
||||
|
||||
@@ -49,6 +49,7 @@ func applyPulseIntelligenceTelemetrySnapshot(
|
||||
snap.PulseIntelligenceApprovedActionRefusalsTargetChanged30d = actionSnapshot.ApprovedActionRefusalsTargetChanged30d
|
||||
snap.PulseIntelligenceApprovedActionRefusalsPrerequisite30d = actionSnapshot.ApprovedActionRefusalsPrerequisite30d
|
||||
snap.PulseIntelligenceApprovedActionRefusalsContract30d = actionSnapshot.ApprovedActionRefusalsContract30d
|
||||
snap.PulseIntelligenceApprovedActionRefusalsUncoded30d = actionSnapshot.ApprovedActionRefusalsUncoded30d
|
||||
snap.PulseIntelligenceApprovedActionRefusalsOther30d = actionSnapshot.ApprovedActionRefusalsOther30d
|
||||
snap.PulseIntelligenceVerifiedFindingResolutions30d = actionSnapshot.VerifiedFindingResolutions30d
|
||||
snap.PulseIntelligenceApprovedActionLastFailureReason30d = actionSnapshot.ApprovedActionLastFailureReason30d
|
||||
@@ -224,6 +225,11 @@ func applyPulseIntelligencePatrolRunSnapshot(snap *telemetry.Snapshot, persisten
|
||||
if err != nil || history == nil {
|
||||
return
|
||||
}
|
||||
// Run volume comes from the uncapped daily tally. Counting history.Runs
|
||||
// directly saturates at the operator-facing history cap, which on an install
|
||||
// patrolling on a normal schedule is reached within hours of a thirty-day
|
||||
// window rather than at its end.
|
||||
snap.PulseIntelligencePatrolRuns30d += history.PatrolRunsSince(since)
|
||||
for _, run := range history.Runs {
|
||||
observedAt := run.CompletedAt
|
||||
if observedAt.IsZero() {
|
||||
@@ -232,7 +238,6 @@ func applyPulseIntelligencePatrolRunSnapshot(snap *telemetry.Snapshot, persisten
|
||||
if observedAt.IsZero() || observedAt.Before(since) {
|
||||
continue
|
||||
}
|
||||
snap.PulseIntelligencePatrolRuns30d++
|
||||
if run.NewFindings > 0 {
|
||||
snap.PulseIntelligencePatrolNewFindings30d += run.NewFindings
|
||||
}
|
||||
|
||||
@@ -1039,3 +1039,34 @@ func TestApplyPulseIntelligenceAdoptionSnapshot_CompleteLoopRequiresIssueEvidenc
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Patrol run volume must reflect every run in the window, not the operator
|
||||
// history cap the runs are stored under.
|
||||
func TestApplyPulseIntelligencePatrolRunSnapshotIgnoresHistoryCap(t *testing.T) {
|
||||
persistence := config.NewConfigPersistence(t.TempDir())
|
||||
now := time.Now().UTC()
|
||||
since := now.AddDate(0, 0, -30)
|
||||
|
||||
const historyCap = 100
|
||||
total := 0
|
||||
runs := make([]config.PatrolRunRecord, 0, historyCap)
|
||||
for at := now.AddDate(0, 0, -2); at.Before(now); at = at.Add(10 * time.Minute) {
|
||||
runs = append([]config.PatrolRunRecord{{ID: at.Format(time.RFC3339Nano), StartedAt: at, CompletedAt: at}}, runs...)
|
||||
if len(runs) > historyCap {
|
||||
runs = runs[:historyCap]
|
||||
}
|
||||
total++
|
||||
if err := persistence.SavePatrolRunHistory(runs); err != nil {
|
||||
t.Fatalf("SavePatrolRunHistory: %v", err)
|
||||
}
|
||||
}
|
||||
if total <= historyCap {
|
||||
t.Fatalf("test needs more runs than the cap, got %d", total)
|
||||
}
|
||||
|
||||
snap := &telemetry.Snapshot{}
|
||||
applyPulseIntelligencePatrolRunSnapshot(snap, persistence, since)
|
||||
if snap.PulseIntelligencePatrolRuns30d != total {
|
||||
t.Fatalf("PulseIntelligencePatrolRuns30d = %d, want %d", snap.PulseIntelligencePatrolRuns30d, total)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user