mesh/merge-gate head e46eeb829853 merged into trunk 4d15a155e634 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head e46eeb829853 merged into trunk 4d15a155e634 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The runtime hands a watch its current values one at a time and gives them ten seconds. The handler read the state, waited for a running look and wrote files for each value, so with about 450 memory values the watch never started on g14 or shanks, and a file written on g14 never reached shanks. The handler now notes each key's newest revision and returns; a worker applies what was noted at the state's current version.
1146 lines
43 KiB
Go
1146 lines
43 KiB
Go
package main
|
|
|
|
// A session's memory follows the operator (novox/hq ADR 0343, issue 367).
|
|
//
|
|
// Claude Code keeps what a session learned about a project in the agent's home, under
|
|
// `~/.claude/projects/<project>/memory/`, on the node where it was learned. This module carries those folders —
|
|
// and nothing else of the home — between the nodes it runs on:
|
|
//
|
|
// - **Picked up by this process.** The long-running half looks at the memory folders every LookEvery; a file whose
|
|
// text differs from the version last carried is put in the module's kept `memory` state, one key per home kind
|
|
// and file. The text travels in that state and nowhere else: no event, no tool answer.
|
|
// - **Written by this process.** Every node watches the state and writes an incoming version only where its own
|
|
// file is still the version the change replaced, or there is none. The version replaced is kept first, in
|
|
// `memory-copies/` in the module's state directory. No tool writes a memory file or the state (issue 340).
|
|
// - **Both kept on a conflict.** A file changed on two nodes since it was last carried becomes one file holding
|
|
// both, the newer first, then a line naming each, then the older; that file is carried, and the operator is
|
|
// warned through the operator channel's `notify`.
|
|
// - **A removal is a marker.** A removed file is a value marked removed, never a deleted key; a node still holding
|
|
// the removed version moves it into the copies, a node that changed it keeps it and carries it again. A missing
|
|
// folder or an absent key removes nothing.
|
|
// - **Homes of one kind.** The operator's own homes carry among themselves, and the homes of an agent account of its
|
|
// own among themselves: a confined agent's notes never reach a session run as the operator.
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const (
|
|
// MemoryState is the module's kept state the memory travels in.
|
|
MemoryState = "memory"
|
|
// 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.
|
|
LookEvery = 10 * time.Second
|
|
// The two home kinds (ADR 0343 §4).
|
|
HomeOperator = "operator"
|
|
HomeAgent = "agent"
|
|
// stagePrefix names a file staged beside its target; a look never carries one.
|
|
stagePrefix = ".mesh-memory-"
|
|
)
|
|
|
|
// MemoryStore is the module's `memory` state as the carrier uses it. Every write is at the revision read — a create
|
|
// where there was nothing — so of two nodes writing one file at once only one wins, and the other takes the winner's
|
|
// version as it comes: two conflicts are never merged into each other back and forth.
|
|
type MemoryStore interface {
|
|
Get(key string) (json.RawMessage, uint64, bool, error)
|
|
Create(key string, value any) error
|
|
Update(key string, value any, revision uint64) error
|
|
Keys() ([]string, error)
|
|
Delete(key string) error
|
|
}
|
|
|
|
// MemoryVersion is one file's version as the state carries it.
|
|
type MemoryVersion struct {
|
|
Kind string `json:"kind"`
|
|
Project string `json:"project"`
|
|
Path string `json:"path"`
|
|
Text string `json:"text,omitempty"`
|
|
Digest string `json:"digest,omitempty"`
|
|
Base string `json:"base,omitempty"`
|
|
Node string `json:"node"`
|
|
Changed time.Time `json:"changed"`
|
|
Removed bool `json:"removed,omitempty"`
|
|
}
|
|
|
|
// carried is what this node last carried of one file: the digest both its folder and the state held.
|
|
type carried struct {
|
|
Project string `json:"project"`
|
|
Path string `json:"path"`
|
|
Digest string `json:"digest"`
|
|
Node string `json:"node"`
|
|
Changed time.Time `json:"changed"`
|
|
}
|
|
|
|
// conflict is one conflict still open on this node.
|
|
type conflict struct {
|
|
Project string `json:"project"`
|
|
Path string `json:"path"`
|
|
Nodes []string `json:"nodes"`
|
|
At time.Time `json:"at"`
|
|
}
|
|
|
|
// memoryRecord is the carrier's own record, in the module's state directory.
|
|
type memoryRecord struct {
|
|
Files map[string]carried `json:"files"`
|
|
Conflicts map[string]conflict `json:"conflicts"`
|
|
}
|
|
|
|
// MemoryCarrier carries one node's memory folders.
|
|
type MemoryCarrier struct {
|
|
Home, Node, State string
|
|
// Kind is the home kind carried for; empty carries nothing, and Why says why.
|
|
Kind, Why string
|
|
Store MemoryStore
|
|
// Notify tells the operator through the operator channel's `notify` verb.
|
|
Notify func(args map[string]any) error
|
|
Now func() time.Time
|
|
Say func(format string, args ...any)
|
|
|
|
mu sync.Mutex
|
|
// queue is what the watch handed over and Drain has not applied yet: each key's newest revision.
|
|
queueMu sync.Mutex
|
|
queue map[string]uint64
|
|
wake chan struct{}
|
|
rec memoryRecord
|
|
loaded bool
|
|
notCarried map[string]string
|
|
failed map[string]bool
|
|
pruned time.Time
|
|
seen map[string]seenFile
|
|
}
|
|
|
|
// HomeKind is the kind of the home this node's agent runs in, or "" with why none is carried.
|
|
func HomeKind(p Paths) (string, string) {
|
|
if !p.Confined() {
|
|
return HomeOperator, ""
|
|
}
|
|
if p.HomeIsTheAgentsOwn() {
|
|
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 §4)"
|
|
}
|
|
|
|
// NewMemoryCarrier is the carrier for these paths.
|
|
func NewMemoryCarrier(p Paths, store MemoryStore, notify func(map[string]any) error) *MemoryCarrier {
|
|
kind, why := HomeKind(p)
|
|
return &MemoryCarrier{Home: p.Home, Node: p.Node, State: p.State, Kind: kind, Why: why, Store: store,
|
|
Notify: notify, Now: time.Now, Say: say, wake: make(chan struct{}, 1)}
|
|
}
|
|
|
|
func (m *MemoryCarrier) projects() string { return filepath.Join(m.Home, ".claude", "projects") }
|
|
func (m *MemoryCarrier) recordPath() string {
|
|
return filepath.Join(m.State, "memory.json")
|
|
}
|
|
func (m *MemoryCarrier) copies() string { return filepath.Join(m.State, "memory-copies") }
|
|
|
|
// MemoryKey is a file's key in the state: its home kind, then the project and path, encoded to the characters a
|
|
// key may hold.
|
|
func MemoryKey(kind, project, path string) string {
|
|
return kind + "." + base64.RawURLEncoding.EncodeToString([]byte(project+"/"+path))
|
|
}
|
|
|
|
func digestOf(text []byte) string {
|
|
sum := sha256.Sum256(text)
|
|
return "sha256:" + hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// 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 ]+-----`),
|
|
regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`),
|
|
regexp.MustCompile(`\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})`),
|
|
regexp.MustCompile(`\b[a-z][a-z0-9+.-]*://[^/\s:@]+:[^/\s@]+@`),
|
|
}
|
|
|
|
// memoryProblem says why a file is not carried, or "".
|
|
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)
|
|
}
|
|
if !utf8.Valid(text) {
|
|
return "it is not text (not valid UTF-8), and the state carries text only"
|
|
}
|
|
// 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"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ---- the record -----------------------------------------------------------------------------------------
|
|
|
|
func (m *MemoryCarrier) load() {
|
|
if m.loaded {
|
|
return
|
|
}
|
|
m.loaded = true
|
|
m.rec = memoryRecord{}
|
|
if raw, err := os.ReadFile(m.recordPath()); err == nil {
|
|
_ = json.Unmarshal(raw, &m.rec)
|
|
}
|
|
if m.rec.Files == nil {
|
|
m.rec.Files = map[string]carried{}
|
|
}
|
|
if m.rec.Conflicts == nil {
|
|
m.rec.Conflicts = map[string]conflict{}
|
|
}
|
|
if m.notCarried == nil {
|
|
m.notCarried = map[string]string{}
|
|
}
|
|
if m.failed == nil {
|
|
m.failed = map[string]bool{}
|
|
}
|
|
}
|
|
|
|
func (m *MemoryCarrier) save() error {
|
|
raw, err := json.MarshalIndent(m.rec, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := m.recordPath() + ".next"
|
|
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, m.recordPath())
|
|
}
|
|
|
|
// ---- the folders ----------------------------------------------------------------------------------------
|
|
|
|
// seenFile is a file as the last look read it.
|
|
type seenFile struct {
|
|
size int64
|
|
mod time.Time
|
|
text []byte
|
|
}
|
|
|
|
// localFile is one memory file found on this node.
|
|
type localFile struct {
|
|
Project, Path string
|
|
Text []byte
|
|
Changed time.Time
|
|
}
|
|
|
|
// memoryFolder is a project's memory folder, when it is a directory and not a symbolic link.
|
|
func (m *MemoryCarrier) memoryFolder(project string) (string, bool) {
|
|
if !entryName(project) {
|
|
return "", false
|
|
}
|
|
dir := filepath.Join(m.projects(), project, "memory")
|
|
info, err := fsys.Lstat(dir)
|
|
return dir, err == nil && info.IsDir() && info.Mode()&fs.ModeSymlink == 0
|
|
}
|
|
|
|
// sight is what one look saw of the memory folders.
|
|
type sight struct {
|
|
files map[string]localFile
|
|
// folders are the memory folders seen whole: only in them is a carried file that is gone a removal.
|
|
folders map[string]bool
|
|
// unseen are the files that are there but could not be read on this look: kept as they are everywhere.
|
|
unseen map[string]bool
|
|
not map[string]string
|
|
}
|
|
|
|
// scan reads every memory folder: the files it may carry, the folders seen whole, and why any file is not carried.
|
|
// What cannot be read is never taken for gone: a file that cannot be read is unseen, and a folder that cannot be
|
|
// listed whole is not seen at all on this look (ADR 0343 §9).
|
|
func (m *MemoryCarrier) scan() (sight, error) {
|
|
in := sight{files: map[string]localFile{}, folders: map[string]bool{}, unseen: map[string]bool{}, not: map[string]string{}}
|
|
seen := map[string]seenFile{}
|
|
defer func() { m.seen = seen }()
|
|
projects, err := fsys.ReadDir(m.projects())
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return in, nil
|
|
}
|
|
if err != nil {
|
|
return in, fmt.Errorf("the projects directory could not be listed (%v); nothing was carried on this look", err)
|
|
}
|
|
for _, pe := range projects {
|
|
if !pe.IsDir() || !entryName(pe.Name()) {
|
|
continue
|
|
}
|
|
project := pe.Name()
|
|
dir, ok := m.memoryFolder(project)
|
|
if !ok {
|
|
if _, err := fsys.Lstat(dir); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
in.not[project+"/"] = fmt.Sprintf("its memory folder could not be looked at (%v); nothing of it was carried or removed on this look", err)
|
|
}
|
|
continue
|
|
}
|
|
files, unseen, not := map[string]localFile{}, map[string]bool{}, map[string]string{}
|
|
whole := true
|
|
_ = fsys.WalkDir(dir, func(full string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
// A folder that cannot be listed: this project is not seen on this look.
|
|
whole = false
|
|
in.not[project+"/"] = fmt.Sprintf("its memory folder could not be listed whole (%v); nothing of it was carried or removed on this look", err)
|
|
return filepath.SkipAll
|
|
}
|
|
if full == dir {
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(dir, full)
|
|
rel = filepath.ToSlash(rel)
|
|
if strings.HasPrefix(d.Name(), stagePrefix) {
|
|
return nil
|
|
}
|
|
if NameProblem(project, rel) != "" {
|
|
return nil
|
|
}
|
|
key := MemoryKey(m.Kind, project, rel)
|
|
if d.Type()&fs.ModeSymlink != 0 {
|
|
not[project+"/"+rel] = "it is a symbolic link, which is never carried"
|
|
unseen[key] = true
|
|
return nil
|
|
}
|
|
if d.IsDir() || !d.Type().IsRegular() {
|
|
return nil
|
|
}
|
|
info, err := fsys.Lstat(full)
|
|
if err != nil {
|
|
not[project+"/"+rel] = fmt.Sprintf("it could not be looked at here (%v); it is kept as it is everywhere", err)
|
|
unseen[key] = true
|
|
return nil
|
|
}
|
|
if info.Size() > MemoryMostBytes {
|
|
not[project+"/"+rel] = fmt.Sprintf("it is %d KiB, over the %d KiB a memory file may be to be carried",
|
|
info.Size()/1024, MemoryMostBytes/1024)
|
|
unseen[key] = true
|
|
return nil
|
|
}
|
|
// A file whose size and time did not change since the last look is not read again.
|
|
var text []byte
|
|
if was, ok := m.seen[full]; ok && was.size == info.Size() && was.mod.Equal(info.ModTime()) {
|
|
text = was.text
|
|
} else if text, err = fsys.ReadFile(full); err != nil {
|
|
not[project+"/"+rel] = fmt.Sprintf("it could not be read here (%v); it is kept as it is everywhere", err)
|
|
unseen[key] = true
|
|
return nil
|
|
}
|
|
seen[full] = seenFile{size: info.Size(), mod: info.ModTime(), text: text}
|
|
if why := memoryProblem(text); why != "" {
|
|
not[project+"/"+rel] = why
|
|
unseen[key] = true
|
|
return nil
|
|
}
|
|
files[key] = localFile{Project: project, Path: rel, Text: text, Changed: info.ModTime()}
|
|
return nil
|
|
})
|
|
if !whole {
|
|
continue
|
|
}
|
|
in.folders[project] = true
|
|
for k, v := range files {
|
|
in.files[k] = v
|
|
}
|
|
for k := range unseen {
|
|
in.unseen[k] = true
|
|
}
|
|
for k, v := range not {
|
|
in.not[k] = v
|
|
}
|
|
}
|
|
return in, nil
|
|
}
|
|
|
|
// readLocal reads one file of a memory folder: absent, or its text; a symbolic link or anything but a file is an
|
|
// error, never followed.
|
|
func (m *MemoryCarrier) readLocal(project, path string) ([]byte, time.Time, bool, error) {
|
|
if err := badName(project, path); err != nil {
|
|
return nil, time.Time{}, false, err
|
|
}
|
|
dir, ok := m.memoryFolder(project)
|
|
if !ok {
|
|
if _, err := fsys.Lstat(dir); err == nil {
|
|
return nil, time.Time{}, false, fmt.Errorf("the memory folder of %s is not a directory of its own", project)
|
|
}
|
|
return nil, time.Time{}, false, nil
|
|
}
|
|
full := filepath.Join(dir, filepath.FromSlash(path))
|
|
if !inside(dir, full) {
|
|
return nil, time.Time{}, false, fmt.Errorf("%s leaves the memory folder", path)
|
|
}
|
|
info, err := fsys.Lstat(full)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil, time.Time{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return nil, time.Time{}, false, err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return nil, time.Time{}, false, fmt.Errorf("%s/%s is not a plain file and is left as it is", project, path)
|
|
}
|
|
text, err := fsys.ReadFile(full)
|
|
return text, info.ModTime(), err == nil, err
|
|
}
|
|
|
|
// NameProblem says why a project and a path may not name a memory file, or "": the project is one entry of the
|
|
// projects directory, and the path is relative, each of its parts a name — never empty, "." or "..". It is judged
|
|
// before any file is touched and on every value taken from the state, so no value leads outside a memory folder.
|
|
func NameProblem(project, path string) string {
|
|
if !entryName(project) {
|
|
return fmt.Sprintf("the project %q is not one entry of the projects directory", project)
|
|
}
|
|
if path == "" || strings.HasPrefix(path, "/") || strings.ContainsRune(path, 0) {
|
|
return fmt.Sprintf("the path %q is not a relative path in a memory folder", path)
|
|
}
|
|
for _, part := range strings.Split(path, "/") {
|
|
if part == "" || part == "." || part == ".." {
|
|
return fmt.Sprintf("the path %q has an empty, \".\" or \"..\" part", path)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func badName(project, path string) error {
|
|
if why := NameProblem(project, path); why != "" {
|
|
return fmt.Errorf("%s; nothing is touched for it (novox/hq ADR 0343)", why)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func inside(dir, full string) bool {
|
|
rel, err := filepath.Rel(dir, full)
|
|
return err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
|
}
|
|
|
|
// keep copies a version into the copies before it is replaced or removed: nothing of a memory folder is lost.
|
|
func (m *MemoryCarrier) keep(project, path string, text []byte, why string) error {
|
|
if err := badName(project, path); err != nil {
|
|
return err
|
|
}
|
|
stamp := m.Now().UTC().Format("20060102T150405.000000000Z")
|
|
full := filepath.Join(m.copies(), project, filepath.FromSlash(path)+"."+stamp+"."+why)
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(full, text, 0o600)
|
|
}
|
|
|
|
// errChangedHere says a file changed on this node between its reading and its writing: it is not written, and the
|
|
// next look judges the change, a conflict where there is one.
|
|
var errChangedHere = errors.New("it changed here meanwhile, and is judged again on the next look")
|
|
|
|
// writeLocal writes one file of a memory folder over the version the caller read (expect: its digest, "" for none):
|
|
// the version there kept first, then the new text staged in the same folder and renamed into place, never through
|
|
// a symbolic link. A file that is no longer the version read is not written (errChangedHere).
|
|
func (m *MemoryCarrier) writeLocal(project, path string, text []byte, why, expect string) error {
|
|
if err := badName(project, path); err != nil {
|
|
return err
|
|
}
|
|
if was, _, ok, err := m.readLocal(project, path); err != nil {
|
|
return err
|
|
} else if !ok && expect != "" || ok && digestOf(was) != expect {
|
|
return fmt.Errorf("%s/%s: %w", project, path, errChangedHere)
|
|
} else if ok {
|
|
if bytes.Equal(was, text) {
|
|
return nil
|
|
}
|
|
if err := m.keep(project, path, was, why); err != nil {
|
|
return fmt.Errorf("the version of %s/%s here could not be kept, so it is not replaced: %w", project, path, err)
|
|
}
|
|
}
|
|
dir := filepath.Join(m.projects(), project, "memory")
|
|
full := filepath.Join(dir, filepath.FromSlash(path))
|
|
if !inside(dir, full) {
|
|
return fmt.Errorf("%s leaves the memory folder", path)
|
|
}
|
|
for d := filepath.Dir(full); ; d = filepath.Dir(d) {
|
|
if info, err := fsys.Lstat(d); err == nil && info.Mode()&fs.ModeSymlink != 0 {
|
|
return fmt.Errorf("%s is a symbolic link, and nothing is written through one", d)
|
|
}
|
|
if d == m.Home || d == filepath.Dir(d) {
|
|
break
|
|
}
|
|
}
|
|
if err := fsys.MkdirAll(filepath.Dir(full), 0o700); err != nil {
|
|
return err
|
|
}
|
|
stage := filepath.Join(filepath.Dir(full), stagePrefix+filepath.Base(full))
|
|
if err := fsys.WriteFile(stage, text, 0o600); err != nil {
|
|
return err
|
|
}
|
|
if err := fsys.Rename(stage, full); err != nil {
|
|
_ = fsys.Remove(stage)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// removeLocal moves one file of a memory folder into the copies.
|
|
func (m *MemoryCarrier) removeLocal(project, path string, text []byte) error {
|
|
if err := badName(project, path); err != nil {
|
|
return err
|
|
}
|
|
if now, _, ok, err := m.readLocal(project, path); err != nil {
|
|
return err
|
|
} else if !ok || !bytes.Equal(now, text) {
|
|
return fmt.Errorf("%s/%s: %w", project, path, errChangedHere)
|
|
}
|
|
if err := m.keep(project, path, text, "removed"); err != nil {
|
|
return fmt.Errorf("%s/%s could not be kept, so it is not removed: %w", project, path, err)
|
|
}
|
|
return fsys.Remove(filepath.Join(m.projects(), project, "memory", filepath.FromSlash(path)))
|
|
}
|
|
|
|
// ---- conflicts -----------------------------------------------------------------------------------------
|
|
|
|
// MergeVersions is one file holding both versions: the newer first, then a line naming each, then the older. Equal
|
|
// times are ordered by node name.
|
|
func MergeVersions(aText []byte, aNode string, aAt time.Time, bText []byte, bNode string, bAt time.Time) []byte {
|
|
newer, older := struct {
|
|
text []byte
|
|
node string
|
|
at time.Time
|
|
}{aText, aNode, aAt}, struct {
|
|
text []byte
|
|
node string
|
|
at time.Time
|
|
}{bText, bNode, bAt}
|
|
if bAt.After(aAt) || (bAt.Equal(aAt) && bNode < aNode) {
|
|
newer, older = older, newer
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString(strings.TrimRight(string(newer.text), "\n"))
|
|
fmt.Fprintf(&b, "\n\n<!-- novox mesh: this file was changed on two nodes, and both versions were kept (novox/hq ADR 0343). "+
|
|
"Above: the newer, changed on %s at %s. Below: the older, changed on %s at %s. Keep what is right and remove this line. -->\n\n",
|
|
newer.node, newer.at.UTC().Format("2006-01-02 15:04 MST"), older.node, older.at.UTC().Format("2006-01-02 15:04 MST"))
|
|
b.WriteString(strings.TrimRight(string(older.text), "\n"))
|
|
b.WriteString("\n")
|
|
return []byte(b.String())
|
|
}
|
|
|
|
// projectLabel names a project in plain words: the folder's name after its last "-projects-", where it has one.
|
|
func projectLabel(project string) string {
|
|
if i := strings.LastIndex(project, "-projects-"); i >= 0 && i+len("-projects-") < len(project) {
|
|
return project[i+len("-projects-"):]
|
|
}
|
|
return strings.Trim(project, "-")
|
|
}
|
|
|
|
func noticeKey(key string) string {
|
|
sum := sha256.Sum256([]byte(key))
|
|
return "claude-code.memory." + hex.EncodeToString(sum[:])[:16] + ".conflict"
|
|
}
|
|
|
|
// warn tells the operator of a conflict, once per file while it is open.
|
|
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()}
|
|
if m.Notify == nil {
|
|
return
|
|
}
|
|
label := projectLabel(project)
|
|
headline := "Claude memory of " + label + " kept twice"
|
|
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": 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)
|
|
}
|
|
}
|
|
|
|
// settle clears a conflict once its file changed without one.
|
|
func (m *MemoryCarrier) settle(key string) {
|
|
if _, open := m.rec.Conflicts[key]; !open {
|
|
return
|
|
}
|
|
delete(m.rec.Conflicts, key)
|
|
if m.Notify != nil {
|
|
_ = m.Notify(map[string]any{"key": noticeKey(key), "severity": "warning", "clear": true, "subject": "claude-code",
|
|
"summary": "a memory file kept twice was tidied"})
|
|
}
|
|
}
|
|
|
|
// ---- carrying ------------------------------------------------------------------------------------------
|
|
|
|
func (m *MemoryCarrier) current(key string) (*MemoryVersion, uint64, error) {
|
|
raw, revision, ok, err := m.Store.Get(key)
|
|
if err != nil || !ok {
|
|
return nil, 0, err
|
|
}
|
|
var v MemoryVersion
|
|
if err := json.Unmarshal(raw, &v); err != nil {
|
|
return nil, 0, fmt.Errorf("the state's value for %s does not read: %w", key, err)
|
|
}
|
|
return &v, revision, nil
|
|
}
|
|
|
|
// put writes a version at the revision read (0: there was none), and records it as carried once it is in the state.
|
|
func (m *MemoryCarrier) put(key string, v MemoryVersion, revision uint64) error {
|
|
if err := m.write(key, v, revision); err != nil {
|
|
return err
|
|
}
|
|
m.record(key, v)
|
|
return nil
|
|
}
|
|
|
|
// errNotCarried says a version was screened out before it reached the state.
|
|
var errNotCarried = errors.New("not carried")
|
|
|
|
// write puts a version into the state at the revision read — the one way anything reaches the state — after
|
|
// judging it as a file found here is judged (ADR 0343 §2): its project and path, and its text, on every path.
|
|
func (m *MemoryCarrier) write(key string, v MemoryVersion, revision uint64) error {
|
|
v.Kind = m.Kind
|
|
if why := NameProblem(v.Project, v.Path); why != "" {
|
|
return fmt.Errorf("%s: %w", why, errNotCarried)
|
|
}
|
|
if MemoryKey(m.Kind, v.Project, v.Path) != key {
|
|
return fmt.Errorf("%s does not name %s/%s: %w", key, v.Project, v.Path, errNotCarried)
|
|
}
|
|
if !v.Removed {
|
|
if why := memoryProblem([]byte(v.Text)); why != "" {
|
|
m.notCarried[v.Project+"/"+v.Path] = why
|
|
return fmt.Errorf("%s/%s is not carried, because %s: %w", v.Project, v.Path, why, errNotCarried)
|
|
}
|
|
if v.Digest != digestOf([]byte(v.Text)) {
|
|
return fmt.Errorf("%s/%s: its digest does not name its text: %w", v.Project, v.Path, errNotCarried)
|
|
}
|
|
}
|
|
var err error
|
|
if revision == 0 {
|
|
err = m.Store.Create(key, v)
|
|
} else {
|
|
err = m.Store.Update(key, v, revision)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("%s/%s was not carried, because the state changed meanwhile or did not answer; it is "+
|
|
"carried on the next look: %w", v.Project, v.Path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *MemoryCarrier) record(key string, v MemoryVersion) {
|
|
if v.Removed {
|
|
delete(m.rec.Files, key)
|
|
return
|
|
}
|
|
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 §5, §7, §9, §10).
|
|
func (m *MemoryCarrier) Look() error {
|
|
if m.Kind == "" {
|
|
return nil
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.load()
|
|
in, err := m.scan()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
files, folders, not := in.files, in.folders, in.not
|
|
m.notCarried = not
|
|
var problems []error
|
|
// 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{}
|
|
for _, k := range listed {
|
|
inState[k] = true
|
|
}
|
|
keys := make([]string, 0, len(files))
|
|
for k := range files {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
if !inState[key] {
|
|
delete(m.rec.Files, key)
|
|
}
|
|
if err := m.lookAt(key, files[key]); err != nil {
|
|
problems = append(problems, err)
|
|
}
|
|
}
|
|
// A carried file gone from a folder that still exists is a removal; a folder gone removes nothing.
|
|
gone := []string{}
|
|
for key, c := range m.rec.Files {
|
|
if _, here := files[key]; !here && folders[c.Project] && !in.unseen[key] && strings.HasPrefix(key, m.Kind+".") {
|
|
gone = append(gone, key)
|
|
}
|
|
}
|
|
sort.Strings(gone)
|
|
for _, key := range gone {
|
|
if err := m.removedHere(key); err != nil {
|
|
problems = append(problems, err)
|
|
}
|
|
}
|
|
m.prune()
|
|
if err := m.save(); err != nil {
|
|
problems = append(problems, err)
|
|
}
|
|
return errors.Join(problems...)
|
|
}
|
|
|
|
// lookAt carries one file found here.
|
|
func (m *MemoryCarrier) lookAt(key string, f localFile) error {
|
|
digest := digestOf(f.Text)
|
|
rec, known := m.rec.Files[key]
|
|
if known && rec.Digest == digest {
|
|
return nil
|
|
}
|
|
cur, revision, err := m.current(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mine := MemoryVersion{Project: f.Project, Path: f.Path, Text: string(f.Text), Digest: digest, Node: m.Node, Changed: f.Changed}
|
|
switch {
|
|
case cur != nil && !cur.Removed && cur.Digest == digest:
|
|
// The state already holds this text: agreed.
|
|
m.rec.Files[key] = carried{Project: f.Project, Path: f.Path, Digest: digest, Node: cur.Node, Changed: cur.Changed}
|
|
return nil
|
|
case cur == nil, cur.Removed, known && cur.Digest == rec.Digest, !known && cur.Node == m.Node:
|
|
// Nothing else changed it since this node's last version (or the state lost it): a plain change.
|
|
if known {
|
|
mine.Base = rec.Digest
|
|
}
|
|
if err := m.put(key, mine, revision); err != nil {
|
|
return err
|
|
}
|
|
m.settle(key)
|
|
return nil
|
|
case cur.Base == digest:
|
|
// The state's version replaced this very text: it is newer here too, and is written.
|
|
return m.apply(key, *cur, revision)
|
|
default:
|
|
// Changed here and elsewhere since it was last carried, or two nodes' first versions differ: both kept.
|
|
return m.conflict(key, f.Project, f.Path, f.Text, f.Changed, *cur, revision)
|
|
}
|
|
}
|
|
|
|
// conflict writes one file holding both versions, carries it, and warns. The merged version goes into the state
|
|
// first, at the revision the other version was read at: when another node got there first, nothing is written
|
|
// here, and its version is taken as it comes.
|
|
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 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()}
|
|
// The file first, over exactly the version merged: a write that fails leaves the state as it was, so the next
|
|
// look merges the same two versions again and nothing grows. Only then the state, at the revision read; when
|
|
// another node got there first, its version is judged against the merged file as it comes.
|
|
if err := m.writeLocal(project, path, merged, "before-merge", digestOf(local)); err != nil {
|
|
m.unwritten(key, project, path, err)
|
|
return err
|
|
}
|
|
if err := m.write(key, v, revision); err != nil {
|
|
return fmt.Errorf("%s/%s changed on two nodes; both are kept in it here, and the state changed again "+
|
|
"meanwhile, so it is judged again on the next change: %w", project, path, err)
|
|
}
|
|
m.record(key, v)
|
|
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}, false)
|
|
return nil
|
|
}
|
|
|
|
// unwritten says, once per file, that a memory file could not be written here: the operator is told, since a
|
|
// file that cannot be written cannot follow them (ADR 0343 §8).
|
|
func (m *MemoryCarrier) unwritten(key, project, path string, err error) {
|
|
if errors.Is(err, errChangedHere) {
|
|
return
|
|
}
|
|
if m.Say != nil {
|
|
m.Say("memory: %s/%s could not be written here: %v", project, path, err)
|
|
}
|
|
if _, told := m.failed[key]; told || m.Notify == nil {
|
|
return
|
|
}
|
|
m.failed[key] = true
|
|
label := projectLabel(project)
|
|
_ = m.Notify(map[string]any{
|
|
"key": noticeKey(key) + ".unwritten", "severity": "warning", "subject": "claude-code",
|
|
"summary": fmt.Sprintf("a memory file of project %s could not be written on %s", label, m.Node),
|
|
"headline": "Claude memory not written on " + m.Node,
|
|
"explanation": fmt.Sprintf("Needs you: make the memory folder of project %s writable on %s. Two versions of one "+
|
|
"of its files are waiting to be kept together there, and nothing is lost meanwhile.", label, m.Node),
|
|
"resolved": fmt.Sprintf("the memory of project %s is written on %s again", label, m.Node),
|
|
})
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
// The files first, then the state, as conflict does.
|
|
besideNow := ""
|
|
if was, _, ok, err := m.readLocal(project, beside); err != nil {
|
|
return err
|
|
} else if ok {
|
|
besideNow = digestOf(was)
|
|
}
|
|
if err := m.writeLocal(project, beside, older, "before-merge", besideNow); err != nil {
|
|
m.unwritten(key, project, path, err)
|
|
return err
|
|
}
|
|
if err := m.writeLocal(project, path, newer, "before-merge", digestOf(local)); err != nil {
|
|
m.unwritten(key, project, path, err)
|
|
return err
|
|
}
|
|
if err := m.write(key, v, revision); err != nil {
|
|
return fmt.Errorf("%s/%s changed on two nodes; both are kept here, and the state changed again meanwhile, "+
|
|
"so it is judged again on the next change: %w", project, path, 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
|
|
}
|
|
|
|
// removedHere carries the removal of a file this node carried before.
|
|
func (m *MemoryCarrier) removedHere(key string) error {
|
|
rec := m.rec.Files[key]
|
|
cur, revision, err := m.current(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch {
|
|
case cur != nil && cur.Removed:
|
|
delete(m.rec.Files, key)
|
|
return nil
|
|
case cur == nil || cur.Digest == rec.Digest:
|
|
return m.put(key, MemoryVersion{Project: rec.Project, Path: rec.Path, Base: rec.Digest, Node: m.Node,
|
|
Changed: m.Now().UTC(), Removed: true}, revision)
|
|
default:
|
|
// Changed elsewhere since: a change outlives a removal, and the file is written back.
|
|
return m.apply(key, *cur, revision)
|
|
}
|
|
}
|
|
|
|
// 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 §9); another kind's key is never taken (§4).
|
|
return nil
|
|
}
|
|
var v MemoryVersion
|
|
if err := json.Unmarshal(value, &v); err != nil {
|
|
return fmt.Errorf("the state's value for %s does not read: %w", key, err)
|
|
}
|
|
if v.Kind != m.Kind || MemoryKey(m.Kind, v.Project, v.Path) != key {
|
|
return fmt.Errorf("the state's value for %s names another file or kind, and is not taken", key)
|
|
}
|
|
if err := badName(v.Project, v.Path); err != nil {
|
|
return fmt.Errorf("the state's value for %s is not taken: %w", key, err)
|
|
}
|
|
// Only noted here, never applied: the runtime hands the watch's values over one at a time and gives the whole
|
|
// current set ten seconds, so a watch whose handler reads the state, waits for a look or writes a file never
|
|
// starts on a node with a few hundred memory files (issue 367, seen on shanks on 2026-10-13). Drain applies
|
|
// what was noted, the newest revision of each key.
|
|
m.queueMu.Lock()
|
|
if m.queue == nil {
|
|
m.queue = map[string]uint64{}
|
|
}
|
|
if m.queue[key] < revision {
|
|
m.queue[key] = revision
|
|
}
|
|
m.queueMu.Unlock()
|
|
select {
|
|
case m.wake <- struct{}{}:
|
|
default:
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Drain applies every change Take noted, each key once, at the state's current version (ADR 0343 §6).
|
|
func (m *MemoryCarrier) Drain() error {
|
|
m.queueMu.Lock()
|
|
queued := m.queue
|
|
m.queue = nil
|
|
m.queueMu.Unlock()
|
|
if len(queued) == 0 {
|
|
return nil
|
|
}
|
|
keys := make([]string, 0, len(queued))
|
|
for k := range queued {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.load()
|
|
var problems []error
|
|
for _, key := range keys {
|
|
if err := m.takeNow(key, queued[key]); err != nil {
|
|
problems = append(problems, err)
|
|
}
|
|
}
|
|
return errors.Join(append(problems, m.save())...)
|
|
}
|
|
|
|
// takeNow applies one key at the state's current version: a change noted late is never written over a newer one.
|
|
func (m *MemoryCarrier) takeNow(key string, revision uint64) error {
|
|
cur, rev, err := m.current(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cur == nil || rev < revision {
|
|
return nil
|
|
}
|
|
if cur.Kind != m.Kind || MemoryKey(m.Kind, cur.Project, cur.Path) != key {
|
|
return fmt.Errorf("the state's value for %s names another file or kind, and is not taken", key)
|
|
}
|
|
return m.apply(key, *cur, rev)
|
|
}
|
|
|
|
// 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 err := badName(v.Project, v.Path); err != nil {
|
|
return fmt.Errorf("the state's value for %s is not taken: %w", key, err)
|
|
}
|
|
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.
|
|
if text, _, ok, _ := m.readLocal(v.Project, v.Path); ok && digestOf(text) == v.Digest {
|
|
m.rec.Files[key] = carried{Project: v.Project, Path: v.Path, Digest: v.Digest, Node: v.Node, Changed: v.Changed}
|
|
}
|
|
return nil
|
|
}
|
|
if !v.Removed && (v.Digest != digestOf([]byte(v.Text)) || memoryProblem([]byte(v.Text)) != "") {
|
|
return fmt.Errorf("the state's version of %s/%s does not hold the text its digest names, or holds what is never carried; it is not written", v.Project, v.Path)
|
|
}
|
|
local, localAt, here, err := m.readLocal(v.Project, v.Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rec, known := m.rec.Files[key]
|
|
localDigest := ""
|
|
if here {
|
|
localDigest = digestOf(local)
|
|
}
|
|
if v.Removed {
|
|
switch {
|
|
case !here:
|
|
delete(m.rec.Files, key)
|
|
// The file here holds no change this node did not carry — it is the version removed, or the version this
|
|
// node last carried, which the removal replaced whether or not this node saw the versions between — so the
|
|
// removal takes it, into the copies. Anything else is a change made here, which outlives the removal (§9).
|
|
case localDigest == v.Base || (known && localDigest == rec.Digest):
|
|
if err := m.removeLocal(v.Project, v.Path, local); err != nil {
|
|
return err
|
|
}
|
|
delete(m.rec.Files, key)
|
|
if m.Say != nil {
|
|
m.Say("memory: %s/%s was removed on %s; the version here is kept in the copies", v.Project, v.Path, v.Node)
|
|
}
|
|
default:
|
|
// Changed here since the removed version: kept, and carried again.
|
|
if err := m.put(key, MemoryVersion{Project: v.Project, Path: v.Path, Text: string(local), Digest: localDigest,
|
|
Base: v.Base, Node: m.Node, Changed: localAt}, revision); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
switch {
|
|
case here && localDigest == v.Digest:
|
|
m.rec.Files[key] = carried{Project: v.Project, Path: v.Path, Digest: v.Digest, Node: v.Node, Changed: v.Changed}
|
|
m.settle(key)
|
|
return nil
|
|
case !here && (!known || rec.Digest != v.Digest),
|
|
here && known && localDigest == rec.Digest,
|
|
here && v.Base != "" && localDigest == v.Base:
|
|
// Nothing here, or here still the version it replaced: written, the version here kept first.
|
|
if err := m.writeLocal(v.Project, v.Path, []byte(v.Text), "replaced", localDigest); err != nil {
|
|
m.unwritten(key, v.Project, v.Path, err)
|
|
return err
|
|
}
|
|
m.rec.Files[key] = carried{Project: v.Project, Path: v.Path, Digest: v.Digest, Node: v.Node, Changed: v.Changed}
|
|
if !strings.Contains(v.Text, "<!-- novox mesh: this file was changed on two nodes") {
|
|
m.settle(key)
|
|
}
|
|
return nil
|
|
case !here:
|
|
// Removed here, and the state still holds the version removed: the next look carries the removal.
|
|
return nil
|
|
default:
|
|
if why := memoryProblem(local); why != "" {
|
|
// What is here is never carried, so it is not merged into what is: both stay where they are.
|
|
m.notCarried[v.Project+"/"+v.Path] = why
|
|
return fmt.Errorf("%s/%s changed here and elsewhere, and what is here is not carried, because %s; both "+
|
|
"stay as they are: %w", v.Project, v.Path, why, errNotCarried)
|
|
}
|
|
return m.conflict(key, v.Project, v.Path, local, localAt, v, revision)
|
|
}
|
|
}
|
|
|
|
// prune removes copies and removal markers older than MemoryKeep, once an hour.
|
|
func (m *MemoryCarrier) prune() {
|
|
now := m.Now()
|
|
if now.Sub(m.pruned) < time.Hour {
|
|
return
|
|
}
|
|
m.pruned = now
|
|
_ = filepath.WalkDir(m.copies(), func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return nil
|
|
}
|
|
if info, err := d.Info(); err == nil && now.Sub(info.ModTime()) > MemoryKeep {
|
|
_ = os.Remove(path)
|
|
}
|
|
return nil
|
|
})
|
|
keys, err := m.Store.Keys()
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, key := range keys {
|
|
if !strings.HasPrefix(key, m.Kind+".") {
|
|
continue
|
|
}
|
|
if v, _, err := m.current(key); err == nil && v != nil && v.Removed && now.Sub(v.Changed) > MemoryKeep {
|
|
_ = m.Store.Delete(key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- the tool ------------------------------------------------------------------------------------------
|
|
|
|
// Status is what `claude_code_memory` answers: fingerprints, never the text.
|
|
func (m *MemoryCarrier) Status() map[string]any {
|
|
if m == nil {
|
|
return map[string]any{"carrying": false, "why": "the module's long-running half has not started here"}
|
|
}
|
|
if m.Kind == "" {
|
|
return map[string]any{"carrying": false, "node": m.Node, "why": m.Why}
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.load()
|
|
projects := map[string][]map[string]any{}
|
|
for _, c := range m.rec.Files {
|
|
projects[c.Project] = append(projects[c.Project], map[string]any{"file": c.Path,
|
|
"fingerprint": Fingerprint(c.Digest), "from": c.Node, "changed": c.Changed.UTC().Format(time.RFC3339)})
|
|
}
|
|
for _, list := range projects {
|
|
sort.Slice(list, func(i, j int) bool { return list[i]["file"].(string) < list[j]["file"].(string) })
|
|
}
|
|
conflicts := []conflict{}
|
|
for _, c := range m.rec.Conflicts {
|
|
conflicts = append(conflicts, c)
|
|
}
|
|
sort.Slice(conflicts, func(i, j int) bool {
|
|
return conflicts[i].Project+conflicts[i].Path < conflicts[j].Project+conflicts[j].Path
|
|
})
|
|
kept := 0
|
|
_ = filepath.WalkDir(m.copies(), func(_ string, d fs.DirEntry, err error) error {
|
|
if err == nil && !d.IsDir() {
|
|
kept++
|
|
}
|
|
return nil
|
|
})
|
|
not := m.notCarried
|
|
if not == nil {
|
|
not = map[string]string{}
|
|
}
|
|
return map[string]any{"carrying": true, "node": m.Node, "home kind": m.Kind, "projects": projects,
|
|
"not carried": not, "conflicts": conflicts, "copies kept": kept,
|
|
"copies": m.copies() + " (each replaced or removed version, kept 30 days; getting one back is the operator's own act)"}
|
|
}
|
|
|
|
// Run follows the `memory` state with watch (the whole current set, then each change) and looks at the folders
|
|
// every LookEvery, for as long as the process runs.
|
|
func (m *MemoryCarrier) Run(watch func(handle func(key, op string, value json.RawMessage, revision uint64) error) error) {
|
|
if m.Kind == "" {
|
|
if m.Say != nil {
|
|
m.Say("memory: not carried here: %s", m.Why)
|
|
}
|
|
return
|
|
}
|
|
go func() {
|
|
for range m.wake {
|
|
if err := m.Drain(); err != nil && m.Say != nil {
|
|
m.Say("memory: %v", err)
|
|
}
|
|
}
|
|
}()
|
|
go persist("watching the agent's memory", func() error {
|
|
return watch(func(key, op string, value json.RawMessage, revision uint64) error {
|
|
if err := m.Take(key, op, value, revision); err != nil && m.Say != nil {
|
|
m.Say("memory: taking %s: %v", key, err)
|
|
}
|
|
return nil
|
|
})
|
|
}, func(n int) { say("watching the agent's memory%s", refusals(n)) })
|
|
for {
|
|
if err := m.Look(); err != nil && m.Say != nil {
|
|
m.Say("memory: %v", err)
|
|
}
|
|
time.Sleep(LookEvery)
|
|
}
|
|
}
|