package main // A session's memory follows the operator (novox/hq ADR 0343, issue 367). Every test runs against temporary homes // and a state in memory with the bus's watch semantics: never the live memory folders. import ( "encoding/json" "errors" "os" "path/filepath" "sort" "strings" "sync" "testing" "time" ) // memState is the `memory` state in memory: revisions, create and compare-and-set, and every change handed to every // watcher, the writer included, as the bus does. type memState struct { mu sync.Mutex values map[string]json.RawMessage revs map[string]uint64 rev uint64 pending []change watchers []*MemoryCarrier } type change struct { key, op string value json.RawMessage rev uint64 } func newMemState() *memState { return &memState{values: map[string]json.RawMessage{}, revs: map[string]uint64{}} } var errChanged = errors.New("wrong last revision") func (s *memState) Get(key string) (json.RawMessage, uint64, bool, error) { s.mu.Lock() defer s.mu.Unlock() v, ok := s.values[key] return v, s.revs[key], ok, nil } func (s *memState) write(key string, value any) { raw, _ := json.Marshal(value) s.rev++ s.values[key], s.revs[key] = raw, s.rev s.pending = append(s.pending, change{key, "put", raw, s.rev}) } func (s *memState) Create(key string, value any) error { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.values[key]; ok { return errChanged } s.write(key, value) return nil } func (s *memState) Update(key string, value any, revision uint64) error { s.mu.Lock() defer s.mu.Unlock() if s.revs[key] != revision { return errChanged } s.write(key, value) return nil } func (s *memState) Keys() ([]string, error) { s.mu.Lock() defer s.mu.Unlock() var keys []string for k := range s.values { keys = append(keys, k) } sort.Strings(keys) return keys, nil } func (s *memState) Delete(key string) error { s.mu.Lock() defer s.mu.Unlock() delete(s.values, key) delete(s.revs, key) s.rev++ s.pending = append(s.pending, change{key, "delete", nil, s.rev}) return nil } // pump hands every change so far to every watcher, until none is left. func (s *memState) pump(t *testing.T) { t.Helper() for i := 0; i < 50; i++ { s.mu.Lock() todo := s.pending s.pending = nil s.mu.Unlock() if len(todo) == 0 { return } for _, c := range todo { for _, w := range s.watchers { if err := w.Take(c.key, c.op, c.value, c.rev); err != nil { t.Logf("%s taking %s: %v", w.Node, c.key, err) } } } for _, w := range s.watchers { if err := w.Drain(); err != nil && !errors.Is(err, errChanged) { t.Logf("%s applying: %v", w.Node, err) } } } t.Fatal("the state never settled") } // clock is a clock a test moves. type clock struct{ now time.Time } func (c *clock) Now() time.Time { return c.now } // memNode is one node's carrier over its own temporary home and state directory. type memNode struct { *MemoryCarrier notices []map[string]any } func newMemNode(t *testing.T, root, name, kind string, state *memState, c *clock) *memNode { t.Helper() n := &memNode{} home := filepath.Join(root, name, "home") stateDir := filepath.Join(root, name, "state") for _, d := range []string{filepath.Join(home, ".claude", "projects"), stateDir} { if err := os.MkdirAll(d, 0o700); err != nil { t.Fatal(err) } } n.MemoryCarrier = &MemoryCarrier{Home: home, Node: name, State: stateDir, Kind: kind, Store: state, Now: c.Now, wake: make(chan struct{}, 1), Notify: func(a map[string]any) error { n.notices = append(n.notices, a); return nil }} state.watchers = append(state.watchers, n.MemoryCarrier) return n } func (n *memNode) path(project, file string) string { return filepath.Join(n.Home, ".claude", "projects", project, "memory", filepath.FromSlash(file)) } func (n *memNode) write(t *testing.T, project, file, text string, at time.Time) { t.Helper() p := n.path(project, file) if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(text), 0o644); err != nil { t.Fatal(err) } _ = os.Chtimes(p, at, at) } func (n *memNode) read(t *testing.T, project, file string) (string, bool) { t.Helper() raw, err := os.ReadFile(n.path(project, file)) if os.IsNotExist(err) { return "", false } if err != nil { t.Fatal(err) } return string(raw), true } func (n *memNode) look(t *testing.T) { t.Helper() if err := n.Look(); err != nil && !errors.Is(err, errChanged) { t.Fatalf("%s's look: %v", n.Node, err) } } // copies is every text kept in the node's copies. func (n *memNode) copies(t *testing.T) []string { t.Helper() var out []string _ = filepath.WalkDir(n.MemoryCarrier.copies(), func(p string, d os.DirEntry, err error) error { if err == nil && !d.IsDir() { raw, _ := os.ReadFile(p) out = append(out, string(raw)) } return nil }) return out } func holdsText(list []string, s string) bool { for _, x := range list { if x == s { return true } } return false } const project = "-home-op-projects-novox-hq" func twoNodes(t *testing.T) (*memState, *clock, *memNode, *memNode) { state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)} root := t.TempDir() return state, c, newMemNode(t, root, "g14", HomeOperator, state, c), newMemNode(t, root, "shanks", HomeOperator, state, c) } // carried writes a file on g14 and carries it to shanks. func carriedOnBoth(t *testing.T, state *memState, c *clock, a, b *memNode, file, text string) { t.Helper() a.write(t, project, file, text, c.now) a.look(t) state.pump(t) b.look(t) state.pump(t) if got, _ := b.read(t, project, file); got != text { t.Fatalf("not carried to %s: %q", b.Node, got) } } // §5, §6: a memory file written on one node appears unchanged on another, at any depth, and is carried once. func TestAMemoryFileWrittenOnOneNodeAppearsUnchangedOnAnother(t *testing.T) { state, c, g14, shanks := twoNodes(t) g14.write(t, project, "MEMORY.md", "- [state](state.md) — where things stand\n", c.now) g14.write(t, project, "notes/state.md", "ADR 0343 proposed.\n", c.now) g14.look(t) state.pump(t) for file, want := range map[string]string{"MEMORY.md": "- [state](state.md) — where things stand\n", "notes/state.md": "ADR 0343 proposed.\n"} { if got, ok := shanks.read(t, project, file); !ok || got != want { t.Fatalf("%s on shanks: %q, %v", file, got, ok) } } before := state.rev shanks.look(t) g14.look(t) state.pump(t) if state.rev != before { t.Fatalf("a carried file was carried again: %d changes", state.rev-before) } // A change on shanks comes back to g14, over the version it replaced, which g14 keeps. c.now = c.now.Add(time.Minute) shanks.write(t, project, "notes/state.md", "ADR 0343 accepted.\n", c.now) shanks.look(t) state.pump(t) if got, _ := g14.read(t, project, "notes/state.md"); got != "ADR 0343 accepted.\n" { t.Fatalf("the change did not reach g14: %q", got) } if !holdsText(g14.copies(t), "ADR 0343 proposed.\n") { t.Fatal("the version replaced on g14 was not kept") } if len(g14.notices)+len(shanks.notices) != 0 { t.Fatalf("a plain change warned: %v %v", g14.notices, shanks.notices) } // No value in the state is anything but a version of a memory file, and no tool answer carries the text. raw, _ := json.Marshal(g14.Status()) if strings.Contains(string(raw), "accepted") { t.Fatalf("the memory tool answered the text: %s", raw) } } // §1, §2: only the memory folders are carried; a symbolic link, an oversized file and a secret's shape are not, and are // listed with why. func TestOnlyTheMemoryFoldersAreCarried(t *testing.T) { state, c, g14, shanks := twoNodes(t) projectDir := filepath.Join(g14.Home, ".claude", "projects", project) g14.write(t, project, "MEMORY.md", "index\n", c.now) for path, text := range map[string]string{ filepath.Join(projectDir, "session.jsonl"): "{}\n", filepath.Join(g14.Home, ".claude", "settings.json"): "{}\n", filepath.Join(g14.Home, ".claude", "history.jsonl"): "{}\n", } { if err := os.WriteFile(path, []byte(text), 0o600); err != nil { t.Fatal(err) } } g14.write(t, project, "key.md", "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n", c.now) g14.write(t, project, "url.md", "https://op:hunter2@git.example.org/x\n", c.now) g14.write(t, project, "big.md", strings.Repeat("x", MemoryMostBytes+1), c.now) g14.write(t, project, "hash.md", "head 1fe6ea4ee47379007598e163784dbd7ac50b7f79\n", c.now) outside := filepath.Join(t.TempDir(), "elsewhere.md") _ = os.WriteFile(outside, []byte("not memory\n"), 0o600) if err := os.Symlink(outside, g14.path(project, "link.md")); err != nil { t.Fatal(err) } g14.look(t) state.pump(t) for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} { if _, ok := shanks.read(t, project, file); ok { t.Errorf("%s was carried", file) } } for _, file := range []string{"MEMORY.md", "hash.md"} { if _, ok := shanks.read(t, project, file); !ok { t.Errorf("%s was not carried", file) } } if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", "projects", project, "session.jsonl")); err == nil { t.Error("a session was carried") } for _, f := range []string{"settings.json", "history.jsonl"} { if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", f)); err == nil { t.Errorf("%s was carried", f) } } keys, _ := state.Keys() if len(keys) != 2 { t.Fatalf("the state holds %d keys, not the two memory files: %v", len(keys), keys) } not, _ := g14.Status()["not carried"].(map[string]string) for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} { if not[project+"/"+file] == "" { t.Errorf("%s is not listed as not carried: %v", file, not) } } } // §4: the operator's homes and an agent account's homes never take each other's memory. func TestHomesOfTheTwoKindsNeverTakeEachOthersMemory(t *testing.T) { state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)} root := t.TempDir() laptop := newMemNode(t, root, "g14", HomeOperator, state, c) control := newMemNode(t, root, "novox", HomeAgent, state, c) control.write(t, project, "MEMORY.md", "from a confined agent\n", c.now) control.look(t) state.pump(t) if _, ok := laptop.read(t, project, "MEMORY.md"); ok { t.Fatal("a confined agent's memory reached the operator's home") } laptop.write(t, project, "MEMORY.md", "the operator's\n", c.now) laptop.look(t) state.pump(t) if got, _ := control.read(t, project, "MEMORY.md"); got != "from a confined agent\n" { t.Fatalf("the operator's memory reached the agent account's home: %q", got) } // A value of the other kind put under this kind's key is refused too. key := MemoryKey(HomeOperator, project, "x.md") raw, _ := json.Marshal(MemoryVersion{Kind: HomeAgent, Project: project, Path: "x.md", Text: "x", Digest: digestOf([]byte("x")), Node: "novox"}) if err := laptop.Take(key, "put", raw, 99); err == nil { t.Fatal("a value of another kind was taken") } // Which kind a node carries for. if k, _ := HomeKind(Paths{Agent: "jochen", Operator: "jochen", Home: "/home/jochen"}); k != HomeOperator { t.Fatalf("the operator's own home is %q", k) } if k, _ := HomeKind(Paths{Agent: "agent", Operator: "jochen", Home: "/home/agent", AgentHome: "/home/agent", OperatorHome: "/home/jochen"}); k != HomeAgent { t.Fatalf("an agent account's home is %q", k) } if k, why := HomeKind(Paths{Agent: "agent", Operator: "jochen", Home: "/home/jochen", AgentHome: "/home/agent", OperatorHome: "/home/jochen"}); k != "" || why == "" { t.Fatalf("a confined agent in the operator's home carries %q", k) } } // §7, §8: a file changed on two nodes since it was carried is kept twice in one file on both, the newer first, with // one warning; it clears once the file is tidied. func TestAFileChangedOnTwoNodesIsKeptTwiceTheNewerFirstWithAWarning(t *testing.T) { state, c, g14, shanks := twoNodes(t) carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "- base\n") g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n", c.now.Add(time.Minute)) shanks.write(t, project, "MEMORY.md", "- base\n- learned on shanks\n", c.now.Add(2*time.Minute)) c.now = c.now.Add(3 * time.Minute) g14.look(t) shanks.look(t) state.pump(t) g14.look(t) shanks.look(t) state.pump(t) a, _ := g14.read(t, project, "MEMORY.md") b, _ := shanks.read(t, project, "MEMORY.md") if a != b { t.Fatalf("the nodes did not end on one text:\n%s\n----\n%s", a, b) } newer, older := strings.Index(a, "learned on shanks"), strings.Index(a, "learned on g14") marker := strings.Index(a, "