diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index f7a12aaaa..658d40afa 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -4063,6 +4063,13 @@ unchanged configuration remains cacheable during the process lifetime while credential rotation invalidates the cache without creating an offline credential verifier. +Remediation-history persistence is a serialized snapshot boundary. Async log +and rollback saves must take a deep copy of rollback metadata while holding the +record lock, then serialize and atomically replace the history file under one +persistence lock. Concurrent saves must never marshal shared rollback pointers, +race over the common temporary path, or allow an older async writer to replace +newer in-memory state. + The canonical findings store must present one active Patrol issue per real problem. When the model reports an equivalent active sibling under a different finding ID, key, or severity for the same resource, `internal/ai/findings.go` diff --git a/internal/ai/memory/branchcov0723pm_test.go b/internal/ai/memory/branchcov0723pm_test.go index 8d49b4b8f..d8c75ecd8 100644 --- a/internal/ai/memory/branchcov0723pm_test.go +++ b/internal/ai/memory/branchcov0723pm_test.go @@ -1,6 +1,8 @@ package memory import ( + "encoding/json" + "fmt" "os" "path/filepath" "strings" @@ -267,6 +269,50 @@ func TestBranchcov0723pmMarkRolledBack(t *testing.T) { }) } +func TestRemediationLogConcurrentPersistenceKeepsLatestState(t *testing.T) { + dataDir := t.TempDir() + remediationLog := NewRemediationLog(RemediationLogConfig{DataDir: dataDir}) + + const recordCount = 32 + for i := 0; i < recordCount; i++ { + id := fmt.Sprintf("rec-%d", i) + if err := remediationLog.Log(RemediationRecord{ + ID: id, + Problem: "p", + Action: "a", + Timestamp: time.Now(), + Rollback: &RollbackInfo{Reversible: true}, + }); err != nil { + t.Fatalf("Log(%s): %v", id, err) + } + if err := remediationLog.MarkRolledBack(id, "rollback-"+id, "operator"); err != nil { + t.Fatalf("MarkRolledBack(%s): %v", id, err) + } + } + + if err := remediationLog.saveToDisk(); err != nil { + t.Fatalf("saveToDisk: %v", err) + } + waitForRemediationSaveQuiescence(t, dataDir) + + data, err := os.ReadFile(filepath.Join(dataDir, remediationHistoryFileName)) + if err != nil { + t.Fatalf("read remediation history: %v", err) + } + var records []RemediationRecord + if err := json.Unmarshal(data, &records); err != nil { + t.Fatalf("unmarshal remediation history: %v", err) + } + if len(records) != recordCount { + t.Fatalf("expected %d persisted records, got %d", recordCount, len(records)) + } + for _, record := range records { + if record.Rollback == nil || !record.Rollback.RolledBack { + t.Fatalf("record %q did not persist its latest rollback state", record.ID) + } + } +} + func TestBranchcov0723pmGetRollbackable(t *testing.T) { // eligibleRecord builds a record that satisfies GetRollbackable's // eligibility condition: Rollback != nil, Reversible, not rolled back, diff --git a/internal/ai/memory/remediation.go b/internal/ai/memory/remediation.go index 6e90d5e36..ff5eda958 100644 --- a/internal/ai/memory/remediation.go +++ b/internal/ai/memory/remediation.go @@ -55,7 +55,9 @@ type RemediationRecord struct { // RemediationLog stores remediation history type RemediationLog struct { - mu sync.RWMutex + mu sync.RWMutex + persistMu sync.Mutex + records []RemediationRecord maxRecords int @@ -287,9 +289,14 @@ func (r *RemediationLog) saveToDisk() error { return nil } + // Async callers share one destination and temporary path. Serialize the + // complete persistence operation so an older goroutine cannot race a newer + // snapshot or rename another writer's temporary file. + r.persistMu.Lock() + defer r.persistMu.Unlock() + r.mu.RLock() - records := make([]RemediationRecord, len(r.records)) - copy(records, r.records) + records := cloneRemediationRecords(r.records) r.mu.RUnlock() path, err := memoryPersistencePath(r.dataDir, remediationHistoryFileName) @@ -309,6 +316,24 @@ func (r *RemediationLog) saveToDisk() error { return os.Rename(tmpPath, path) } +func cloneRemediationRecords(records []RemediationRecord) []RemediationRecord { + cloned := make([]RemediationRecord, len(records)) + for i := range records { + cloned[i] = records[i] + if records[i].Rollback == nil { + continue + } + + rollback := *records[i].Rollback + if records[i].Rollback.RolledBackAt != nil { + rolledBackAt := *records[i].Rollback.RolledBackAt + rollback.RolledBackAt = &rolledBackAt + } + cloned[i].Rollback = &rollback + } + return cloned +} + // loadFromDisk loads records from JSON file func (r *RemediationLog) loadFromDisk() error { records, ok, err := loadMemoryHistory(r.dataDir, remediationHistoryFileName, "remediation history", func(a, b RemediationRecord) bool {