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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user