diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index a3bb4f1f..d3683375 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -80,13 +80,15 @@ module's long-running process carries each project's memory folder, `~/.claude/p nothing else of the home: - **Picked up** every ten seconds: a file whose text differs from the version last carried goes into the `memory` - state. Not carried, and listed with why: a symbolic link, a file over 128 KiB, and a file holding a key block, a + state. Not carried, and listed with why: a symbolic link, a file over 128 KiB or too large for one value once + encoded, and a file holding a key block, a signed token, a known token prefix or a password in an address. - **Written** on every other node of the same home kind, only where its file is still the version the change - replaced, or there is none; staged in the folder and renamed in, never through a symbolic link. The version + replaced, or the version it last carried (a node that was away), or there is none; staged in the folder and renamed in, never through a symbolic link. The version replaced is kept first in `memory-copies/` in the module's state directory, 30 days. - **A file changed on two nodes** becomes one file holding both, the newer first, then a line naming each version's - node and time, then the older; that file is carried, so every node ends on it, and the operator is warned through + node and time, then the older (two versions too large for one file: the newer in it, the older beside it as + `.older-from-`); what was written is carried, so every node ends on it, and the operator is warned through the operator channel's `notify` (a warning, cleared when the file next changes without a conflict). - **A removal** of a carried file, from a folder that still exists, is a marker in the state for 30 days: a node still holding the removed version moves it into the copies; a node that changed it keeps it and carries it again. A diff --git a/modules/claude-code/cmd/claude-code/memory.go b/modules/claude-code/cmd/claude-code/memory.go index 3e570c25..f71cdc98 100644 --- a/modules/claude-code/cmd/claude-code/memory.go +++ b/modules/claude-code/cmd/claude-code/memory.go @@ -45,6 +45,9 @@ const ( // MemoryMostBytes is the largest file carried: the state's value holds the text, and the largest memory file // measured on the laptop on 2026-10-13 was 70 KiB. MemoryMostBytes = 128 * 1024 + // MemoryValueBytes is the most a file's encoded text may take of one value of the state (256 KiB, ADR 0201), + // leaving room for the rest of the value. + MemoryValueBytes = 250 * 1024 // MemoryKeep is how long a replaced or removed version is kept in the copies, and a removal marker in the state. MemoryKeep = 30 * 24 * time.Hour // LookEvery is how often the memory folders are looked at. @@ -172,6 +175,10 @@ func memoryProblem(text []byte) string { if len(text) > MemoryMostBytes { return fmt.Sprintf("it is %d KiB, over the %d KiB a memory file may be to be carried", len(text)/1024, MemoryMostBytes/1024) } + // One value of the state holds at most 256 KiB (ADR 0201): the text as it is encoded, with room for the rest. + if encoded, _ := json.Marshal(string(text)); len(encoded) > MemoryValueBytes { + return "its text, as the state encodes it, would not fit in one value of the state" + } for _, re := range memorySecret { if re.Match(text) { return "it holds something shaped like a secret, which kept state never carries" @@ -429,7 +436,7 @@ func noticeKey(key string) string { } // warn tells the operator of a conflict, once per file while it is open. -func (m *MemoryCarrier) warn(key, project, path string, nodes []string) { +func (m *MemoryCarrier) warn(key, project, path string, nodes []string, beside bool) { nodes = append([]string(nil), nodes...) sort.Strings(nodes) m.rec.Conflicts[key] = conflict{Project: project, Path: path, Nodes: nodes, At: m.Now().UTC()} @@ -441,17 +448,23 @@ func (m *MemoryCarrier) warn(key, project, path string, nodes []string) { if len([]rune(headline)) > 60 { headline = "Claude memory kept twice" } + explanation := fmt.Sprintf("Needs you: keep what is right in the memory file of project %s. It was changed on %s "+ + "before either change reached the other, so both versions are now in it on every machine, the newer first.", + label, strings.Join(nodes, " and ")) + if beside { + explanation = fmt.Sprintf("Needs you: keep what is right in the memory file of project %s. It was changed on %s "+ + "before either change reached the other, and both were too large for one file, so the newer is in it and the "+ + "older in a second file beside it, on every machine.", label, strings.Join(nodes, " and ")) + } // The plain rule (ADR 0253 §3): no path, file name, time, "agent" or "session"; the verdict opens the explanation. err := m.Notify(map[string]any{ - "key": noticeKey(key), - "severity": "warning", - "subject": "claude-code", - "summary": fmt.Sprintf("memory of project %s changed on %s and kept twice", label, strings.Join(nodes, " and ")), - "headline": headline, - "explanation": fmt.Sprintf("Needs you: keep what is right in the memory file of project %s. It was changed on %s "+ - "before either change reached the other, so both versions are now in it on every machine, the newer first.", - label, strings.Join(nodes, " and ")), - "resolved": fmt.Sprintf("the memory file of project %s holds one version again", label), + "key": noticeKey(key), + "severity": "warning", + "subject": "claude-code", + "summary": fmt.Sprintf("memory of project %s changed on %s and kept twice", label, strings.Join(nodes, " and ")), + "headline": headline, + "explanation": explanation, + "resolved": fmt.Sprintf("the memory file of project %s holds one version again", label), }) if err != nil && m.Say != nil { m.Say("memory: the operator could not be warned of the conflict in %s/%s: %v", project, path, err) @@ -607,7 +620,7 @@ func (m *MemoryCarrier) lookAt(key string, f localFile) error { func (m *MemoryCarrier) conflict(key, project, path string, local []byte, localAt time.Time, cur MemoryVersion, revision uint64) error { merged := MergeVersions(local, m.Node, localAt, []byte(cur.Text), cur.Node, cur.Changed) if memoryProblem(merged) != "" { - return fmt.Errorf("%s/%s changed on two nodes, and both together are more than is carried; both stay as they are", project, path) + return m.conflictBeside(key, project, path, local, localAt, cur, revision) } v := MemoryVersion{Kind: m.Kind, Project: project, Path: path, Text: string(merged), Digest: digestOf(merged), Base: cur.Digest, Node: m.Node, Changed: m.Now().UTC()} @@ -623,7 +636,48 @@ func (m *MemoryCarrier) conflict(key, project, path string, local []byte, localA if m.Say != nil { m.Say("memory: %s/%s was changed on %s and on %s; both versions are kept in it, the newer first", project, path, m.Node, cur.Node) } - m.warn(key, project, path, []string{m.Node, cur.Node}) + m.warn(key, project, path, []string{m.Node, cur.Node}, false) + return nil +} + +// OlderBeside is where the older of two versions too large to share one file is kept: beside the file, named for +// the node it was changed on ("MEMORY.md" from shanks: "MEMORY.older-from-shanks.md"). +func OlderBeside(path, node string) string { + ext := filepath.Ext(path) + return strings.TrimSuffix(path, ext) + ".older-from-" + node + ext +} + +// conflictBeside keeps two versions too large to share one file in two (ADR 0343 §7): the newer in the file, the +// older beside it, each carried. +func (m *MemoryCarrier) conflictBeside(key, project, path string, local []byte, localAt time.Time, cur MemoryVersion, revision uint64) error { + newer, newerNode, newerAt := local, m.Node, localAt + older, olderNode := []byte(cur.Text), cur.Node + if cur.Changed.After(localAt) || (cur.Changed.Equal(localAt) && cur.Node < m.Node) { + newer, newerNode, newerAt = []byte(cur.Text), cur.Node, cur.Changed + older, olderNode = local, m.Node + } + beside := OlderBeside(path, olderNode) + v := MemoryVersion{Kind: m.Kind, Project: project, Path: path, Text: string(newer), Digest: digestOf(newer), + Base: cur.Digest, Node: newerNode, Changed: newerAt} + if newerNode == m.Node { + v.Node, v.Changed = m.Node, m.Now().UTC() + } + if err := m.Store.Update(key, v, revision); err != nil { + return fmt.Errorf("%s/%s changed on two nodes, and the state changed again meanwhile; it is judged again on "+ + "the next change: %w", project, path, err) + } + if err := m.writeLocal(project, beside, older, "before-merge"); err != nil { + return err + } + if err := m.writeLocal(project, path, newer, "before-merge"); err != nil { + return err + } + m.record(key, v) + if m.Say != nil { + m.Say("memory: %s/%s was changed on %s and on %s, too large to hold both: the newer is in it, the older beside it as %s", + project, path, m.Node, cur.Node, beside) + } + m.warn(key, project, path, []string{m.Node, cur.Node}, true) return nil } diff --git a/modules/claude-code/cmd/claude-code/memory_test.go b/modules/claude-code/cmd/claude-code/memory_test.go index 56321b35..e78b81fe 100644 --- a/modules/claude-code/cmd/claude-code/memory_test.go +++ b/modules/claude-code/cmd/claude-code/memory_test.go @@ -594,3 +594,85 @@ func TestTheManifestDeclaresTheMemoryState(t *testing.T) { t.Fatalf("a carrier not started says it carries: %v", st) } } + +// replay hands one node the whole current set, as a watch begins when the node comes back. +func (s *memState) replay(t *testing.T, n *memNode) { + t.Helper() + keys, _ := s.Keys() + for _, k := range keys { + raw, rev, _, _ := s.Get(k) + if err := n.Take(k, "put", raw, rev); err != nil && !errors.Is(err, errChanged) { + t.Logf("%s taking %s: %v", n.Node, k, err) + } + } +} + +// §6: a node that was away while a file changed twice takes the newest version, whose base it never saw, because +// its own file holds no change it did not carry; the version replaced is kept. +func TestANodeThatWasAwayTakesTheNewestVersion(t *testing.T) { + state, c, g14, shanks := twoNodes(t) + carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "v1\n") + state.watchers = []*MemoryCarrier{g14.MemoryCarrier} + for _, v := range []string{"v2\n", "v3\n"} { + c.now = c.now.Add(time.Minute) + g14.write(t, project, "MEMORY.md", v, c.now) + g14.look(t) + state.pump(t) + } + state.watchers = append(state.watchers, shanks.MemoryCarrier) + state.replay(t, shanks) + if got, _ := shanks.read(t, project, "MEMORY.md"); got != "v3\n" { + t.Fatalf("the node that was away holds %q", got) + } + if !holdsText(shanks.copies(t), "v1\n") { + t.Fatal("the version replaced was not kept") + } + if len(g14.notices)+len(shanks.notices) != 0 { + t.Fatal("taking a newer version warned") + } +} + +// §2, §7: two versions too large to share one file are kept in two: the newer in the file, the older beside it, +// both carried; and a text that would not fit in one value of the state is not carried. +func TestTwoVersionsTooLargeForOneFileAreKeptInTwo(t *testing.T) { + state, c, g14, shanks := twoNodes(t) + carriedOnBoth(t, state, c, g14, shanks, "big.md", "base\n") + older := strings.Repeat("from g14 ", MemoryMostBytes/9-100) + "\n" + newer := strings.Repeat("from shanks ", MemoryMostBytes/12-100) + "\n" + g14.write(t, project, "big.md", older, c.now.Add(time.Minute)) + shanks.write(t, project, "big.md", newer, 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) + beside := OlderBeside("big.md", "g14") + if beside != "big.older-from-g14.md" { + t.Fatalf("the older is named %q", beside) + } + for _, n := range []*memNode{g14, shanks} { + if got, _ := n.read(t, project, "big.md"); got != newer { + t.Fatalf("%s's file does not hold the newer version", n.Node) + } + if got, _ := n.read(t, project, beside); got != older { + t.Fatalf("%s does not hold the older version beside it", n.Node) + } + } + notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...) + if len(notices) != 1 || !strings.Contains(notices[0]["explanation"].(string), "second file beside it") { + t.Fatalf("not one warning saying the older is beside it: %v", notices) + } + // Under the size limit, but too large once encoded: not carried, and said. + g14.write(t, project, "angles.md", strings.Repeat("<", MemoryMostBytes-10), c.now) + g14.look(t) + state.pump(t) + if _, ok := shanks.read(t, project, "angles.md"); ok { + t.Fatal("a text too large for one value was carried") + } + not, _ := g14.Status()["not carried"].(map[string]string) + if not[project+"/angles.md"] == "" { + t.Fatalf("it is not listed as not carried: %v", not) + } +}