claude-code: keep two memory versions too large for one file in two, and say a text that cannot fit one value (issue 367)
mesh/merge-gate head 910bc58fe01d merged into trunk a84d0094a0a3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 910bc58fe01d merged into trunk a84d0094a0a3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request

A merged file could exceed the state's 256 KiB value; the newer now stays in the
file and the older is written beside it, each carried. A node that was away takes
the newest version when its own file holds no uncarried change (tested).
This commit is contained in:
2026-10-13 16:03:37 +02:00
parent 0096beeff9
commit 910bc58fe0
3 changed files with 153 additions and 15 deletions
+5 -3
View File
@@ -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
`<name>.older-from-<node>`); 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
+66 -12
View File
@@ -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
}
@@ -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)
}
}