From 315a9df68b37630a967ef774ec9ceaa9658d24e4 Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Mon, 2 Nov 2026 15:35:09 -0800 Subject: [PATCH 1/2] BUG: A memory file that cannot be read keeps its unflushed flag and is not overwritten --- internal/memory/unreadable_file_test.go | 79 +++++++++++++++++++++++++ internal/memory/vaultstore.go | 19 +++++- 2 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 internal/memory/unreadable_file_test.go diff --git a/internal/memory/unreadable_file_test.go b/internal/memory/unreadable_file_test.go new file mode 100644 index 0000000..cf7374e --- /dev/null +++ b/internal/memory/unreadable_file_test.go @@ -0,0 +1,79 @@ +package memory + +import ( + "bytes" + "database/sql" + "os" + "runtime" + "testing" +) + +// storeUnreadable stores one fact and makes its own file unreadable, the +// state a chmod, a sync client mid-download or a disk error leaves behind. +func storeUnreadable(t *testing.T) (db *sql.DB, dir, path string, id int64) { + t.Helper() + if runtime.GOOS == "windows" || os.Getuid() == 0 { + t.Skip("needs a file its owner cannot read") + } + db, dir = vaultDB(t) + r, err := Store(db, nil, "", &Memory{Text: "Deploys go out on Tuesdays", Kind: Fact, Source: "manual"}) + if err != nil { + t.Fatal(err) + } + var uid string + if err := db.QueryRow(`SELECT uid FROM memories WHERE id = ?`, r.ID).Scan(&uid); err != nil { + t.Fatal(err) + } + path = memoryFilePath(dir, Fact, uid) + if err := os.Chmod(path, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0o600) }) + return db, dir, path, r.ID +} + +// An unreadable file confirms nothing about its memory. Counted as found, it +// cleared the unflushed flag of a memory no read had seen in the vault, and +// doctor stopped reporting a row that one more wipe would lose. +func TestAnUnreadableMemoryFileKeepsItsUnflushedFlag(t *testing.T) { + db, dir, _, id := storeUnreadable(t) + if _, err := db.Exec(`UPDATE memories SET unflushed = 1 WHERE id = ?`, id); err != nil { + t.Fatal(err) + } + if _, _, _, err := ImportEdits(db, nil, "", dir); err != nil { + t.Fatal(err) + } + var unflushed int + if err := db.QueryRow(`SELECT unflushed FROM memories WHERE id = ?`, id).Scan(&unflushed); err != nil { + t.Fatalf("the memory behind an unreadable file is gone: %v", err) + } + if unflushed != 1 { + t.Error("an index cleared the unflushed flag of a memory whose file it could not read") + } +} + +// The hand-edit guard compares the file with its stamp. A file it could not +// read was taken as nothing to compare, and the rewrite replaced whatever a +// person had put there. +func TestAMemoryFileThatCannotBeReadIsNotOverwritten(t *testing.T) { + db, _, path, _ := storeUnreadable(t) + os.Chmod(path, 0o600) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + os.Chmod(path, 0) + + // The same text again reinforces the memory, which rewrites its file. + if _, err := Store(db, nil, "", &Memory{Text: "Deploys go out on Tuesdays", Kind: Fact, Source: "manual"}); err == nil { + t.Error("a store rewrote a memory file it could not read, with no error") + } + os.Chmod(path, 0o600) + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Error("the unreadable memory file was replaced") + } +} diff --git a/internal/memory/vaultstore.go b/internal/memory/vaultstore.go index ea5f20f..6b4a22f 100644 --- a/internal/memory/vaultstore.go +++ b/internal/memory/vaultstore.go @@ -667,7 +667,14 @@ func writeMemoryFile(db *sql.DB, dir string, kind Kind, m Memory) error { return fmt.Errorf("memory #%d has no uid to file it under", m.ID) } path := memoryFilePath(dir, kind, m.Uid) - if onDisk, err := os.ReadFile(path); err == nil { + onDisk, err := os.ReadFile(path) + // A file there that cannot be read may hold a hand edit nobody can check, + // and the index keeps its row now; replacing it would lose that edit with + // no refusal. + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("memories/%s/%s.md could not be read to check it for hand edits, so it was not rewritten: %w", kind, m.Uid, err) + } + if err == nil { last, stamped := fileStampSeen(db, m.Uid) pending, pendingStamped := fileStampSeen(db, pendingUID(m.Uid)) if mine, claimed := ours(sha256.Sum256(onDisk), last, stamped, pending, pendingStamped); claimed && !mine { @@ -852,6 +859,11 @@ func importInto(db *sql.DB, p *provider.Provider, embedModel, dir string, edits } keep := map[int64]bool{} + // unread rows are named by a file nobody could read, or one holding no + // memory line: kept from the reap like keep's, but not counted as found + // in the vault, since nothing confirmed them there and an unflushed flag + // has to stay set for doctor and the next index. + unread := map[int64]bool{} // Only kinds whose file was read and found intact are authoritative. A kind // whose file is missing or truncated says nothing about what the user wants @@ -895,7 +907,7 @@ func importInto(db *sql.DB, p *provider.Provider, embedModel, dir string, edits var id int64 uid := strings.TrimSuffix(filepath.Base(rel), ".md") if db.QueryRow("SELECT id FROM memories WHERE uid = ? AND kind = ?", uid, string(kind)).Scan(&id) == nil { - keep[id] = true + unread[id] = true } } @@ -986,6 +998,9 @@ func importInto(db *sql.DB, p *provider.Provider, embedModel, dir string, edits rows.Close() return imported, rescued, err } + if unread[id] { + continue + } if keep[id] { // Already recovered — not by the rescue below, but by its own // per-memory file, found and read during the directory scan From 7f81f29067b854a4bf1d7054698e1ae443aee232 Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Mon, 2 Nov 2026 15:35:09 -0800 Subject: [PATCH 2/2] BUG: An index that refuses still names the memory files it could not read --- cmd/logos/index.go | 4 +++ cmd/logos/index_refuses_unread_test.go | 46 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 cmd/logos/index_refuses_unread_test.go diff --git a/cmd/logos/index.go b/cmd/logos/index.go index 702d2d0..c8277bc 100644 --- a/cmd/logos/index.go +++ b/cmd/logos/index.go @@ -193,6 +193,10 @@ func indexPass(ix *index.Index, w io.Writer, p *provider.Provider, embed string) // nothing" cannot depend on a model being reachable. mems, rescuedMems, edits, err := ix.SyncMemoryEdits(p, embed) if err != nil { + // What the scan found before it refused is still true, and an + // unreadable file left out of this run would otherwise go unnamed + // behind the refusal. + printUnreadMemoryFiles(w, "", edits) return err } printMemoryEdits(w, edits) diff --git a/cmd/logos/index_refuses_unread_test.go b/cmd/logos/index_refuses_unread_test.go new file mode 100644 index 0000000..bdf6872 --- /dev/null +++ b/cmd/logos/index_refuses_unread_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// A damaged digest elsewhere makes the index refuse, and the refusal returned +// before the notice: the unreadable file the same scan found went unnamed, so +// the one run that had found it said nothing about it. +func TestAnUnreadableMemoryFileIsNamedWhenTheIndexRefuses(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("needs a file permission that stops its owner reading it") + } + vaultDir := t.TempDir() + t.Setenv("LOGOS_VAULT", vaultDir) + t.Setenv("LOGOS_EMBED", "off") + if err := memoryCmd([]string{"add", "--project", "shop", "Deploys go out on Tuesdays"}); err != nil { + t.Fatalf("memory add: %v", err) + } + matches, err := filepath.Glob(filepath.Join(vaultDir, "memories", "fact", "*.md")) + if err != nil || len(matches) != 1 { + t.Fatalf("got %v per-memory fact files (%v), want 1", matches, err) + } + locked := matches[0] + if err := os.Chmod(locked, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(locked, 0o644) }) + torn := filepath.Join(vaultDir, "memories", "context.md") + if err := os.WriteFile(torn, []byte("- Shipping in November