claude-code: say a memory conflict in the operator's plain words, and skip a look the state does not answer (issue 367)
mesh/merge-gate head 0096beeff999 merged into trunk a84d0094a0a3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 0096beeff999 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 0096beeff999 merged into trunk a84d0094a0a3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 0096beeff999 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
The warning now opens with what the operator is needed for and names no path, agent or session (ADR 0253's plain rule); clause references follow ADR 0343 as reviewed.
This commit is contained in:
@@ -49,7 +49,7 @@ const (
|
||||
MemoryKeep = 30 * 24 * time.Hour
|
||||
// LookEvery is how often the memory folders are looked at.
|
||||
LookEvery = 10 * time.Second
|
||||
// The two home kinds (ADR 0343 §2).
|
||||
// The two home kinds (ADR 0343 §4).
|
||||
HomeOperator = "operator"
|
||||
HomeAgent = "agent"
|
||||
// stagePrefix names a file staged beside its target; a look never carries one.
|
||||
@@ -131,7 +131,7 @@ func HomeKind(p Paths) (string, string) {
|
||||
return HomeAgent, ""
|
||||
}
|
||||
return "", "the agent runs as an account of its own here, and the home it runs in is not that account's own: " +
|
||||
"whose memory it is cannot be judged, so none is carried (novox/hq ADR 0343 §2)"
|
||||
"whose memory it is cannot be judged, so none is carried (novox/hq ADR 0343 §4)"
|
||||
}
|
||||
|
||||
// NewMemoryCarrier is the carrier for these paths.
|
||||
@@ -158,7 +158,7 @@ func digestOf(text []byte) string {
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// memorySecret are the shapes that are always a secret (ADR 0343 §1): a key block, a signed token, a known token
|
||||
// memorySecret are the shapes that are always a secret (ADR 0343 §2): a key block, a signed token, a known token
|
||||
// prefix, a password in an address. Narrower than the mesh's general check on purpose: memory names commit hashes.
|
||||
var memorySecret = []*regexp.Regexp{
|
||||
regexp.MustCompile(`-----BEGIN [A-Z ]+-----`),
|
||||
@@ -437,16 +437,21 @@ func (m *MemoryCarrier) warn(key, project, path string, nodes []string) {
|
||||
return
|
||||
}
|
||||
label := projectLabel(project)
|
||||
headline := "Claude memory of " + label + " kept twice"
|
||||
if len([]rune(headline)) > 60 {
|
||||
headline = "Claude memory kept twice"
|
||||
}
|
||||
// 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": "Agent memory kept twice",
|
||||
"explanation": fmt.Sprintf("A memory file of project %s was changed on %s before either change reached the other. "+
|
||||
"Both versions are now in the file on every node, the newer first; read it in a session and keep what is right.",
|
||||
"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 was tidied", label),
|
||||
"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)
|
||||
@@ -504,7 +509,7 @@ func (m *MemoryCarrier) record(key string, v MemoryVersion) {
|
||||
m.rec.Files[key] = carried{Project: v.Project, Path: v.Path, Digest: v.Digest, Node: v.Node, Changed: v.Changed}
|
||||
}
|
||||
|
||||
// Look looks at the memory folders once and carries what changed (ADR 0343 §3, §5, §7, §8).
|
||||
// Look looks at the memory folders once and carries what changed (ADR 0343 §5, §7, §9, §10).
|
||||
func (m *MemoryCarrier) Look() error {
|
||||
if m.Kind == "" {
|
||||
return nil
|
||||
@@ -515,14 +520,15 @@ func (m *MemoryCarrier) Look() error {
|
||||
files, folders, not := m.scan()
|
||||
m.notCarried = not
|
||||
var problems []error
|
||||
// A key the state lost is carried again, never read as a removal (ADR 0343 §7): one listing per look.
|
||||
// A key the state lost is carried again, never read as a removal (ADR 0343 §9): one listing per look.
|
||||
// When the state does not answer, nothing is carried on this look: the next one tries again.
|
||||
listed, err := m.Store.Keys()
|
||||
if err != nil {
|
||||
return fmt.Errorf("the memory state did not answer; nothing was carried on this look: %w", err)
|
||||
}
|
||||
inState := map[string]bool{}
|
||||
if listed, err := m.Store.Keys(); err == nil {
|
||||
for _, k := range listed {
|
||||
inState[k] = true
|
||||
}
|
||||
} else {
|
||||
inState = nil
|
||||
for _, k := range listed {
|
||||
inState[k] = true
|
||||
}
|
||||
keys := make([]string, 0, len(files))
|
||||
for k := range files {
|
||||
@@ -530,7 +536,7 @@ func (m *MemoryCarrier) Look() error {
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if inState != nil && !inState[key] {
|
||||
if !inState[key] {
|
||||
delete(m.rec.Files, key)
|
||||
}
|
||||
if err := m.lookAt(key, files[key]); err != nil {
|
||||
@@ -644,7 +650,7 @@ func (m *MemoryCarrier) removedHere(key string) error {
|
||||
// Take is one change of the `memory` state, as the watch hands it over.
|
||||
func (m *MemoryCarrier) Take(key, op string, value json.RawMessage, revision uint64) error {
|
||||
if m.Kind == "" || !strings.HasPrefix(key, m.Kind+".") || op != "put" {
|
||||
// A deleted key removes nothing (ADR 0343 §7); another kind's key is never taken (§2).
|
||||
// A deleted key removes nothing (ADR 0343 §9); another kind's key is never taken (§4).
|
||||
return nil
|
||||
}
|
||||
var v MemoryVersion
|
||||
@@ -672,7 +678,7 @@ func (m *MemoryCarrier) Take(key, op string, value json.RawMessage, revision uin
|
||||
return errors.Join(err, m.save())
|
||||
}
|
||||
|
||||
// apply writes one incoming version, read at revision, into this node's folder (ADR 0343 §4, §5, §7).
|
||||
// apply writes one incoming version, read at revision, into this node's folder (ADR 0343 §6, §7, §9).
|
||||
func (m *MemoryCarrier) apply(key string, v MemoryVersion, revision uint64) error {
|
||||
if v.Node == m.Node && !v.Removed {
|
||||
// This node's own version: what is here is either it, or newer, which the next look carries.
|
||||
|
||||
@@ -221,7 +221,7 @@ func carriedOnBoth(t *testing.T, state *memState, c *clock, a, b *memNode, file,
|
||||
}
|
||||
}
|
||||
|
||||
// §3, §4: a memory file written on one node appears unchanged on another, at any depth, and is carried once.
|
||||
// §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)
|
||||
@@ -261,7 +261,7 @@ func TestAMemoryFileWrittenOnOneNodeAppearsUnchangedOnAnother(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// §1: only the memory folders are carried; a symbolic link, an oversized file and a secret's shape are not, and are
|
||||
// §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)
|
||||
@@ -317,7 +317,7 @@ func TestOnlyTheMemoryFoldersAreCarried(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// §2: the operator's homes and an agent account's homes never take each other's memory.
|
||||
// §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()
|
||||
@@ -353,7 +353,7 @@ func TestHomesOfTheTwoKindsNeverTakeEachOthersMemory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// §5, §6: a file changed on two nodes since it was carried is kept twice in one file on both, the newer first, with
|
||||
// §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)
|
||||
@@ -384,10 +384,21 @@ func TestAFileChangedOnTwoNodesIsKeptTwiceTheNewerFirstWithAWarning(t *testing.T
|
||||
if len(notices) != 1 || notices[0]["severity"] != "warning" || notices[0]["clear"] != nil {
|
||||
t.Fatalf("not one warning: %v", notices)
|
||||
}
|
||||
for _, field := range []string{"headline", "explanation", "summary"} {
|
||||
if s, _ := notices[0][field].(string); strings.ContainsAny(s, "/~") || strings.Contains(s, ".md") {
|
||||
t.Errorf("the warning's %s names a path: %q", field, s)
|
||||
for _, field := range []string{"headline", "explanation", "summary", "resolved"} {
|
||||
s, _ := notices[0][field].(string)
|
||||
if s == "" || strings.ContainsAny(s, "/~:") && field != "explanation" || strings.Contains(s, ".md") {
|
||||
t.Errorf("the warning's %s is empty or names a path: %q", field, s)
|
||||
}
|
||||
// The plain rule (ADR 0253 §3): it never sends the operator elsewhere.
|
||||
if field != "summary" && (strings.Contains(strings.ToLower(s), "agent") || strings.Contains(strings.ToLower(s), "session")) {
|
||||
t.Errorf("the warning's %s sends the operator elsewhere: %q", field, s)
|
||||
}
|
||||
}
|
||||
if e, _ := notices[0]["explanation"].(string); !strings.HasPrefix(e, "Needs you: ") {
|
||||
t.Errorf("the explanation does not open with its verdict: %q", e)
|
||||
}
|
||||
if h, _ := notices[0]["headline"].(string); len([]rune(h)) > 60 {
|
||||
t.Errorf("the headline is longer than sixty characters: %q", h)
|
||||
}
|
||||
all := append(g14.copies(t), shanks.copies(t)...)
|
||||
for _, v := range []string{"- base\n- learned on g14\n", "- base\n- learned on shanks\n"} {
|
||||
@@ -430,7 +441,7 @@ func TestMergeVersionsPutsTheNewerFirst(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// §7: a removal moves the other node's unchanged file into the copies.
|
||||
// §9: a removal moves the other node's unchanged file into the copies.
|
||||
func TestARemovalMovesTheUnchangedFileIntoTheCopies(t *testing.T) {
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, g14, shanks, "old.md", "an old note\n")
|
||||
@@ -457,7 +468,7 @@ func TestARemovalMovesTheUnchangedFileIntoTheCopies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// §7: a change outlives a removal, whichever reaches the state first.
|
||||
// §9: a change outlives a removal, whichever reaches the state first.
|
||||
func TestAChangeOutlivesARemoval(t *testing.T) {
|
||||
for _, removalFirst := range []bool{true, false} {
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
@@ -484,7 +495,7 @@ func TestAChangeOutlivesARemoval(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// §7: a missing folder, a state that lost its keys and a deleted key remove nothing.
|
||||
// §9: a missing folder, a state that lost its keys and a deleted key remove nothing.
|
||||
func TestAMissingFolderOrAnAbsentKeyRemovesNothing(t *testing.T) {
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
|
||||
@@ -515,7 +526,7 @@ func TestAMissingFolderOrAnAbsentKeyRemovesNothing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// §8: on a first carrying, two nodes' different versions are both kept, and each node's own files reach the other.
|
||||
// §10: on a first carrying, two nodes' different versions are both kept, and each node's own files reach the other.
|
||||
func TestTheFirstCarryingKeepsEveryVersion(t *testing.T) {
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
g14.write(t, project, "MEMORY.md", "- laptop's index\n", c.now)
|
||||
|
||||
Reference in New Issue
Block a user