fix: harden checkpoint provenance, scope and atomic binding

This commit is contained in:
Sycun committed 2026-10-12 10:49:23 +08:00
1 parent 015e16457e
commit 2c311a6ab1
10 files changed
+256 -13

No files matched your search

+33
View File
@@ -83,6 +83,39 @@ func ContextCheckpointVisible(f *ProjectFact, conversationID string, includeHist
(strings.TrimSpace(conversationID) != "" && f.SourceConversationID == strings.TrimSpace(conversationID)))
}
// SaveContextCheckpoint tests project binding in the same SQLite write statement,
// so a concurrent conversation rebind cannot slip between a read and an upsert.
func (db *DB) SaveContextCheckpoint(f *ProjectFact) error {
if f == nil || !IsContextCheckpointKey(f.FactKey) || strings.TrimSpace(f.SourceConversationID) == "" {
return fmt.Errorf("invalid context checkpoint")
}
if err := ValidateFactKey(f.FactKey); err != nil {
return err
}
now := time.Now()
result, err := db.Exec(`INSERT INTO project_facts
(id, project_id, fact_key, category, summary, body, confidence, source_conversation_id, pinned, created_at, updated_at)
SELECT ?, ?, ?, 'note', ?, ?, 'tentative', ?, 0, ?, ?
WHERE EXISTS (SELECT 1 FROM conversations WHERE id = ? AND project_id = ?)
ON CONFLICT(project_id, fact_key) DO UPDATE SET
category = 'note', summary = excluded.summary, body = excluded.body,
confidence = 'tentative', source_conversation_id = excluded.source_conversation_id,
pinned = 0, updated_at = excluded.updated_at`,
uuid.New().String(), f.ProjectID, f.FactKey, f.Summary, f.Body, f.SourceConversationID,
now, now, f.SourceConversationID, f.ProjectID)
if err != nil {
return err
}
changed, err := result.RowsAffected()
if err != nil {
return err
}
if changed == 0 {
return fmt.Errorf("checkpoint project does not match current conversation binding")
}
return nil
}
func contextCheckpointScopeSQL(conversationID string) (string, []interface{}) {
return " AND (substr(fact_key, 1, ?) != ? OR (source_conversation_id = ? AND ? != ''))",
[]interface{}{len(ContextCheckpointKeyPrefix), ContextCheckpointKeyPrefix, strings.TrimSpace(conversationID), strings.TrimSpace(conversationID)}
@@ -0,0 +1,65 @@
package database
import (
"path/filepath"
"testing"
"go.uber.org/zap"
)
func TestCheckpointWriteRechecksBindingAtomically(t *testing.T) {
db, err := NewDB(filepath.Join(t.TempDir(), "binding.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
a, err := db.CreateProject(&Project{Name: "a"})
if err != nil {
t.Fatal(err)
}
b, err := db.CreateProject(&Project{Name: "b"})
if err != nil {
t.Fatal(err)
}
conv, err := db.CreateConversation("run", ConversationCreateMeta{ProjectID: a.ID})
if err != nil {
t.Fatal(err)
}
f := &ProjectFact{ProjectID: a.ID, FactKey: "note/context-test", SourceConversationID: conv.ID, Summary: "first", Body: "first body"}
if err := db.SaveContextCheckpoint(f); err != nil {
t.Fatal(err)
}
first, err := db.GetProjectFactByKey(a.ID, f.FactKey)
if err != nil {
t.Fatal(err)
}
// A running agent has already read a.ID; binding changes before its write.
if err := db.SetConversationProjectID(conv.ID, b.ID); err != nil {
t.Fatal(err)
}
f.Body = "wrong-project update"
if err := db.SaveContextCheckpoint(f); err == nil {
t.Fatal("stale run updated old-project memory")
}
f.FactKey = "note/context-new"
if err := db.SaveContextCheckpoint(f); err == nil {
t.Fatal("stale run inserted old-project memory")
}
existing, err := db.GetProjectFactByKey(a.ID, first.FactKey)
if err != nil || existing.Body != "first body" {
t.Fatalf("failed write changed evidence: %#v %v", existing, err)
}
if _, err := db.GetProjectFactByKey(a.ID, f.FactKey); err == nil {
t.Fatal("failed write left a row")
}
f.ProjectID = b.ID
if err := db.SaveContextCheckpoint(f); err != nil {
t.Fatal(err)
}
if err := db.SetConversationProjectID(conv.ID, ""); err != nil {
t.Fatal(err)
}
if err := db.SaveContextCheckpoint(f); err == nil {
t.Fatal("unbound conversation still persisted project memory")
}
}