claude-code: carry each project's memory folder between nodes (issue 367, ADR 0343)
mesh/merge-gate head 6aa30eae66be merged into trunk a84d0094a0a3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 6aa30eae66be 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 6aa30eae66be merged into trunk a84d0094a0a3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 6aa30eae66be 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 session started on another node began without what the sessions elsewhere had learned, because Claude Code keeps project memory on one node. The module's own process now carries the memory folders through its kept memory state: a change is written elsewhere only over the version it replaced, a file changed on two nodes keeps both (newer first) with a warning, and every replaced or removed version is kept. No tool writes memory.
This commit is contained in:
@@ -11,8 +11,9 @@ Two directories, declared, so the mesh refuses a second module owning either:
|
||||
- `~/.claude` under the home of the account agents run as (the operator's, unless the machine names an agent
|
||||
account of its own), that account's, `0700`. The module owns the directory —
|
||||
that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else
|
||||
in it (memory, history, projects, local settings, a person's own rules and skills) is the person's
|
||||
and is never read or written (hq ADR 0182). Unassigned, the module leaves the directory: the host
|
||||
in it (history, projects, local settings, a person's own rules and skills) is the person's
|
||||
and is never read or written (hq ADR 0182) — but for each project's memory folder, which the module
|
||||
carries between nodes (hq ADR 0343, below). Unassigned, the module leaves the directory: the host
|
||||
removes a directory only when it is empty.
|
||||
|
||||
And, for where the agent runs as an account of its own (hq ADR 0266):
|
||||
@@ -69,8 +70,33 @@ must see, a node that joins later included — kept, so it carries no secret eit
|
||||
| an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
|
||||
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
|
||||
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open |
|
||||
| a project's memory (hq ADR 0343) | a key in the module's `memory` state per file — `<kind>.<project and path, base64url>`, `operator` for the operator's own homes and `agent` for an agent account's — holding the text, its digest, the digest of the version it replaced, the node and when it changed; a removal is a value marked removed, never a deleted key. Written at the revision read, so of two nodes writing one file only one wins. Every node of the same kind watches it. No event and no tool answer carries memory text |
|
||||
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name. A hook, settings, or an item that would run something by itself is never taken, whenever it was put there (hq issue 340) |
|
||||
|
||||
## A session's memory follows the operator (hq ADR 0343)
|
||||
|
||||
A session started on another node starts with what the sessions on every other node learned (hq issue 367). The
|
||||
module's long-running process carries each project's memory folder, `~/.claude/projects/<project>/memory/`, and
|
||||
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
|
||||
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 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
|
||||
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
|
||||
missing folder, a key the state lost and a deleted key remove nothing.
|
||||
- **The two home kinds never mix**: the operator's own homes carry among themselves, an agent account's among
|
||||
themselves (hq ADR 0266), so a confined agent's notes never reach a session run as the operator.
|
||||
- **No tool writes memory** (hq issue 340): `claude_code_memory` only lists what is carried — fingerprints, never the
|
||||
text — the files not carried, the open conflicts and the copies kept. A value put straight into the state by a
|
||||
holder of a tool runner's credential is not yet told apart (hq issue 638).
|
||||
|
||||
## The mesh's tools first (hq ADR 0245)
|
||||
|
||||
The agent kept reaching for `ssh <machine> journalctl` while the service manager's `journal` verb existed. So:
|
||||
|
||||
@@ -152,6 +152,36 @@ func (s stateOf) Create(key string, value any) error {
|
||||
return err
|
||||
}
|
||||
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
|
||||
|
||||
// memoryStateOf adapts the SDK's state to the memory carrier: every write at the revision read (ADR 0343).
|
||||
type memoryStateOf struct{ s stdio.KeptState }
|
||||
|
||||
func (s memoryStateOf) Get(key string) (json.RawMessage, uint64, bool, error) {
|
||||
e, err := s.s.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
return e.Value, e.Revision, true, nil
|
||||
}
|
||||
func (s memoryStateOf) Create(key string, value any) error {
|
||||
_, err := s.s.Create(key, value)
|
||||
return err
|
||||
}
|
||||
func (s memoryStateOf) Update(key string, value any, revision uint64) error {
|
||||
_, err := s.s.Update(key, value, revision)
|
||||
return err
|
||||
}
|
||||
func (s memoryStateOf) Keys() ([]string, error) { return s.s.Keys() }
|
||||
func (s memoryStateOf) Delete(key string) error { return s.s.Delete(key) }
|
||||
|
||||
// memoryCarrier carries this node's memory folders (ADR 0343); nil until the long-running half starts it.
|
||||
var memoryCarrier *MemoryCarrier
|
||||
|
||||
// notifyOperator tells the operator through the operator channel's notify verb (ADR 0253).
|
||||
func notifyOperator(args map[string]any) error {
|
||||
_, err := ask("seat:operator-channel.notify", args)
|
||||
return err
|
||||
}
|
||||
func (s stateOf) Get(key string) (json.RawMessage, bool, error) {
|
||||
e, err := s.s.Get(key)
|
||||
if err != nil || e == nil {
|
||||
@@ -354,6 +384,9 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
|
||||
out = append(out, stdio.Tool{Name: "claude_code_registrations",
|
||||
Description: "Read-only: everything registered through this module that a Claude Code session runs or obeys — the tool servers, the hooks, the settings and the skills, subagents, commands, output styles and instructions — by key, with who registered it, when, and whether it is still rendered (novox/hq issue 340), beside what the operator set for this node at the controller's terminal (managed_settings, mcp_servers). `by` is the node whose tool runner served the call, never the caller.",
|
||||
Run: func(map[string]any) (any, error) { return Registrations(p, servers, config) }})
|
||||
out = append(out, stdio.Tool{Name: "claude_code_memory",
|
||||
Description: "Read-only: what this node carries of the agent's memory between nodes (novox/hq ADR 0343) — the home kind it carries for (the operator's own homes, or an agent account's), each project's memory files with their fingerprint and the node and time of their last version, the files not carried and why, the conflicts still open (a file changed on two nodes, kept twice in one file, the newer first), and how many replaced or removed versions are kept. Fingerprints only, never the text; it changes nothing.",
|
||||
Run: func(map[string]any) (any, error) { return memoryCarrier.Status(), nil }})
|
||||
out = append(out, proposalTools(p)...)
|
||||
judge := NewJudge(p)
|
||||
out = append(out, judgeTool(judge), judgedTool(judge))
|
||||
@@ -430,6 +463,12 @@ func main() {
|
||||
proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant,
|
||||
Now: time.Now, Write: writeManaged}
|
||||
go proposing.watch(p)
|
||||
memoryCarrier = NewMemoryCarrier(p, memoryStateOf{stdio.State(MemoryState)}, notifyOperator)
|
||||
go memoryCarrier.Run(func(handle func(key, op string, value json.RawMessage, revision uint64) error) error {
|
||||
return stdio.State(MemoryState).Watch("", func(c stdio.StateChange) error {
|
||||
return handle(c.Key, c.Op, c.Value, c.Revision)
|
||||
})
|
||||
})
|
||||
go run(p, view, configView)
|
||||
if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil {
|
||||
say("%v", err)
|
||||
|
||||
@@ -0,0 +1,839 @@
|
||||
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"
|
||||
)
|
||||
|
||||
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
|
||||
// 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 §2).
|
||||
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
|
||||
rec memoryRecord
|
||||
loaded bool
|
||||
notCarried map[string]string
|
||||
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 §2)"
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
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 §1): 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)
|
||||
}
|
||||
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{}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
dir := filepath.Join(m.projects(), project, "memory")
|
||||
info, err := fsys.Lstat(dir)
|
||||
return dir, err == nil && info.IsDir() && info.Mode()&fs.ModeSymlink == 0
|
||||
}
|
||||
|
||||
// scan reads every memory folder: the files it may carry, the folders that exist, and why any file is not carried.
|
||||
func (m *MemoryCarrier) scan() (map[string]localFile, map[string]bool, map[string]string) {
|
||||
files, folders, not := map[string]localFile{}, map[string]bool{}, map[string]string{}
|
||||
seen := map[string]seenFile{}
|
||||
defer func() { m.seen = seen }()
|
||||
projects, err := fsys.ReadDir(m.projects())
|
||||
if err != nil {
|
||||
return files, folders, not
|
||||
}
|
||||
for _, pe := range projects {
|
||||
if !pe.IsDir() {
|
||||
continue
|
||||
}
|
||||
project := pe.Name()
|
||||
dir, ok := m.memoryFolder(project)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
folders[project] = true
|
||||
_ = fsys.WalkDir(dir, func(full string, d fs.DirEntry, err error) error {
|
||||
if err != nil || full == dir {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(dir, full)
|
||||
rel = filepath.ToSlash(rel)
|
||||
if strings.HasPrefix(d.Name(), stagePrefix) {
|
||||
return nil
|
||||
}
|
||||
if d.Type()&fs.ModeSymlink != 0 {
|
||||
not[project+"/"+rel] = "it is a symbolic link, which is never carried"
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() || !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
info, err := fsys.Lstat(full)
|
||||
if err != nil {
|
||||
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)
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
seen[full] = seenFile{size: info.Size(), mod: info.ModTime(), text: text}
|
||||
if why := memoryProblem(text); why != "" {
|
||||
not[project+"/"+rel] = why
|
||||
return nil
|
||||
}
|
||||
files[MemoryKey(m.Kind, project, rel)] = localFile{Project: project, Path: rel, Text: text, Changed: info.ModTime()}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return files, folders, not
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
// writeLocal writes one file of a memory folder: the version there kept first, then the new text staged in the
|
||||
// same folder and renamed into place, never through a symbolic link.
|
||||
func (m *MemoryCarrier) writeLocal(project, path string, text []byte, why string) error {
|
||||
if was, _, ok, err := m.readLocal(project, path); err != nil {
|
||||
return err
|
||||
} 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 := 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) {
|
||||
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)
|
||||
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.",
|
||||
label, strings.Join(nodes, " and ")),
|
||||
"resolved": fmt.Sprintf("the memory file of project %s was tidied", 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 {
|
||||
v.Kind = m.Kind
|
||||
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)
|
||||
}
|
||||
m.record(key, v)
|
||||
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 §3, §5, §7, §8).
|
||||
func (m *MemoryCarrier) Look() error {
|
||||
if m.Kind == "" {
|
||||
return nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.load()
|
||||
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.
|
||||
inState := map[string]bool{}
|
||||
if listed, err := m.Store.Keys(); err == nil {
|
||||
for _, k := range listed {
|
||||
inState[k] = true
|
||||
}
|
||||
} else {
|
||||
inState = nil
|
||||
}
|
||||
keys := make([]string, 0, len(files))
|
||||
for k := range files {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if inState != nil && !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] && strings.HasPrefix(key, m.Kind+".") {
|
||||
if _, skipped := not[c.Project+"/"+c.Path]; !skipped {
|
||||
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 fmt.Errorf("%s/%s changed on two nodes, and both together are more than is carried; both stay as they are", project, path)
|
||||
}
|
||||
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()}
|
||||
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, path, merged, "before-merge"); err != nil {
|
||||
// The state holds both; this node's folder still holds its own, which the next look merges again.
|
||||
return 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})
|
||||
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 §7); another kind's key is never taken (§2).
|
||||
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)
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.load()
|
||||
// A change handed over late is never written over a newer one: what is applied is the state's current version.
|
||||
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)
|
||||
}
|
||||
err = m.apply(key, *cur, rev)
|
||||
return errors.Join(err, m.save())
|
||||
}
|
||||
|
||||
// apply writes one incoming version, read at revision, into this node's folder (ADR 0343 §4, §5, §7).
|
||||
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.
|
||||
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)
|
||||
case localDigest == v.Base || (known && localDigest == rec.Digest && rec.Digest == v.Base):
|
||||
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"); err != nil {
|
||||
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:
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
package main
|
||||
|
||||
// A session's memory follows the operator (novox/hq ADR 0343, issue 367). Every test runs against temporary homes
|
||||
// and a state in memory with the bus's watch semantics: never the live memory folders.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// memState is the `memory` state in memory: revisions, create and compare-and-set, and every change handed to every
|
||||
// watcher, the writer included, as the bus does.
|
||||
type memState struct {
|
||||
mu sync.Mutex
|
||||
values map[string]json.RawMessage
|
||||
revs map[string]uint64
|
||||
rev uint64
|
||||
pending []change
|
||||
watchers []*MemoryCarrier
|
||||
}
|
||||
|
||||
type change struct {
|
||||
key, op string
|
||||
value json.RawMessage
|
||||
rev uint64
|
||||
}
|
||||
|
||||
func newMemState() *memState {
|
||||
return &memState{values: map[string]json.RawMessage{}, revs: map[string]uint64{}}
|
||||
}
|
||||
|
||||
var errChanged = errors.New("wrong last revision")
|
||||
|
||||
func (s *memState) Get(key string) (json.RawMessage, uint64, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, ok := s.values[key]
|
||||
return v, s.revs[key], ok, nil
|
||||
}
|
||||
|
||||
func (s *memState) write(key string, value any) {
|
||||
raw, _ := json.Marshal(value)
|
||||
s.rev++
|
||||
s.values[key], s.revs[key] = raw, s.rev
|
||||
s.pending = append(s.pending, change{key, "put", raw, s.rev})
|
||||
}
|
||||
|
||||
func (s *memState) Create(key string, value any) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.values[key]; ok {
|
||||
return errChanged
|
||||
}
|
||||
s.write(key, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memState) Update(key string, value any, revision uint64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.revs[key] != revision {
|
||||
return errChanged
|
||||
}
|
||||
s.write(key, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memState) Keys() ([]string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var keys []string
|
||||
for k := range s.values {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (s *memState) Delete(key string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.values, key)
|
||||
delete(s.revs, key)
|
||||
s.rev++
|
||||
s.pending = append(s.pending, change{key, "delete", nil, s.rev})
|
||||
return nil
|
||||
}
|
||||
|
||||
// pump hands every change so far to every watcher, until none is left.
|
||||
func (s *memState) pump(t *testing.T) {
|
||||
t.Helper()
|
||||
for i := 0; i < 50; i++ {
|
||||
s.mu.Lock()
|
||||
todo := s.pending
|
||||
s.pending = nil
|
||||
s.mu.Unlock()
|
||||
if len(todo) == 0 {
|
||||
return
|
||||
}
|
||||
for _, c := range todo {
|
||||
for _, w := range s.watchers {
|
||||
if err := w.Take(c.key, c.op, c.value, c.rev); err != nil && !errors.Is(err, errChanged) {
|
||||
t.Logf("%s taking %s: %v", w.Node, c.key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatal("the state never settled")
|
||||
}
|
||||
|
||||
// clock is a clock a test moves.
|
||||
type clock struct{ now time.Time }
|
||||
|
||||
func (c *clock) Now() time.Time { return c.now }
|
||||
|
||||
// memNode is one node's carrier over its own temporary home and state directory.
|
||||
type memNode struct {
|
||||
*MemoryCarrier
|
||||
notices []map[string]any
|
||||
}
|
||||
|
||||
func newMemNode(t *testing.T, root, name, kind string, state *memState, c *clock) *memNode {
|
||||
t.Helper()
|
||||
n := &memNode{}
|
||||
home := filepath.Join(root, name, "home")
|
||||
stateDir := filepath.Join(root, name, "state")
|
||||
for _, d := range []string{filepath.Join(home, ".claude", "projects"), stateDir} {
|
||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
n.MemoryCarrier = &MemoryCarrier{Home: home, Node: name, State: stateDir, Kind: kind, Store: state, Now: c.Now,
|
||||
Notify: func(a map[string]any) error { n.notices = append(n.notices, a); return nil }}
|
||||
state.watchers = append(state.watchers, n.MemoryCarrier)
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *memNode) path(project, file string) string {
|
||||
return filepath.Join(n.Home, ".claude", "projects", project, "memory", filepath.FromSlash(file))
|
||||
}
|
||||
|
||||
func (n *memNode) write(t *testing.T, project, file, text string, at time.Time) {
|
||||
t.Helper()
|
||||
p := n.path(project, file)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Chtimes(p, at, at)
|
||||
}
|
||||
|
||||
func (n *memNode) read(t *testing.T, project, file string) (string, bool) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(n.path(project, file))
|
||||
if os.IsNotExist(err) {
|
||||
return "", false
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw), true
|
||||
}
|
||||
|
||||
func (n *memNode) look(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := n.Look(); err != nil && !errors.Is(err, errChanged) {
|
||||
t.Fatalf("%s's look: %v", n.Node, err)
|
||||
}
|
||||
}
|
||||
|
||||
// copies is every text kept in the node's copies.
|
||||
func (n *memNode) copies(t *testing.T) []string {
|
||||
t.Helper()
|
||||
var out []string
|
||||
_ = filepath.WalkDir(n.MemoryCarrier.copies(), func(p string, d os.DirEntry, err error) error {
|
||||
if err == nil && !d.IsDir() {
|
||||
raw, _ := os.ReadFile(p)
|
||||
out = append(out, string(raw))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func holdsText(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const project = "-home-op-projects-novox-hq"
|
||||
|
||||
func twoNodes(t *testing.T) (*memState, *clock, *memNode, *memNode) {
|
||||
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
|
||||
root := t.TempDir()
|
||||
return state, c, newMemNode(t, root, "g14", HomeOperator, state, c), newMemNode(t, root, "shanks", HomeOperator, state, c)
|
||||
}
|
||||
|
||||
// carried writes a file on g14 and carries it to shanks.
|
||||
func carriedOnBoth(t *testing.T, state *memState, c *clock, a, b *memNode, file, text string) {
|
||||
t.Helper()
|
||||
a.write(t, project, file, text, c.now)
|
||||
a.look(t)
|
||||
state.pump(t)
|
||||
b.look(t)
|
||||
state.pump(t)
|
||||
if got, _ := b.read(t, project, file); got != text {
|
||||
t.Fatalf("not carried to %s: %q", b.Node, got)
|
||||
}
|
||||
}
|
||||
|
||||
// §3, §4: 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)
|
||||
g14.write(t, project, "notes/state.md", "ADR 0343 proposed.\n", c.now)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
for file, want := range map[string]string{"MEMORY.md": "- [state](state.md) — where things stand\n", "notes/state.md": "ADR 0343 proposed.\n"} {
|
||||
if got, ok := shanks.read(t, project, file); !ok || got != want {
|
||||
t.Fatalf("%s on shanks: %q, %v", file, got, ok)
|
||||
}
|
||||
}
|
||||
before := state.rev
|
||||
shanks.look(t)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
if state.rev != before {
|
||||
t.Fatalf("a carried file was carried again: %d changes", state.rev-before)
|
||||
}
|
||||
// A change on shanks comes back to g14, over the version it replaced, which g14 keeps.
|
||||
c.now = c.now.Add(time.Minute)
|
||||
shanks.write(t, project, "notes/state.md", "ADR 0343 accepted.\n", c.now)
|
||||
shanks.look(t)
|
||||
state.pump(t)
|
||||
if got, _ := g14.read(t, project, "notes/state.md"); got != "ADR 0343 accepted.\n" {
|
||||
t.Fatalf("the change did not reach g14: %q", got)
|
||||
}
|
||||
if !holdsText(g14.copies(t), "ADR 0343 proposed.\n") {
|
||||
t.Fatal("the version replaced on g14 was not kept")
|
||||
}
|
||||
if len(g14.notices)+len(shanks.notices) != 0 {
|
||||
t.Fatalf("a plain change warned: %v %v", g14.notices, shanks.notices)
|
||||
}
|
||||
// No value in the state is anything but a version of a memory file, and no tool answer carries the text.
|
||||
raw, _ := json.Marshal(g14.Status())
|
||||
if strings.Contains(string(raw), "accepted") {
|
||||
t.Fatalf("the memory tool answered the text: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// §1: 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)
|
||||
projectDir := filepath.Join(g14.Home, ".claude", "projects", project)
|
||||
g14.write(t, project, "MEMORY.md", "index\n", c.now)
|
||||
for path, text := range map[string]string{
|
||||
filepath.Join(projectDir, "session.jsonl"): "{}\n",
|
||||
filepath.Join(g14.Home, ".claude", "settings.json"): "{}\n",
|
||||
filepath.Join(g14.Home, ".claude", "history.jsonl"): "{}\n",
|
||||
} {
|
||||
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
g14.write(t, project, "key.md", "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n", c.now)
|
||||
g14.write(t, project, "url.md", "https://op:hunter2@git.example.org/x\n", c.now)
|
||||
g14.write(t, project, "big.md", strings.Repeat("x", MemoryMostBytes+1), c.now)
|
||||
g14.write(t, project, "hash.md", "head 1fe6ea4ee47379007598e163784dbd7ac50b7f79\n", c.now)
|
||||
outside := filepath.Join(t.TempDir(), "elsewhere.md")
|
||||
_ = os.WriteFile(outside, []byte("not memory\n"), 0o600)
|
||||
if err := os.Symlink(outside, g14.path(project, "link.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
|
||||
if _, ok := shanks.read(t, project, file); ok {
|
||||
t.Errorf("%s was carried", file)
|
||||
}
|
||||
}
|
||||
for _, file := range []string{"MEMORY.md", "hash.md"} {
|
||||
if _, ok := shanks.read(t, project, file); !ok {
|
||||
t.Errorf("%s was not carried", file)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", "projects", project, "session.jsonl")); err == nil {
|
||||
t.Error("a session was carried")
|
||||
}
|
||||
for _, f := range []string{"settings.json", "history.jsonl"} {
|
||||
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", f)); err == nil {
|
||||
t.Errorf("%s was carried", f)
|
||||
}
|
||||
}
|
||||
keys, _ := state.Keys()
|
||||
if len(keys) != 2 {
|
||||
t.Fatalf("the state holds %d keys, not the two memory files: %v", len(keys), keys)
|
||||
}
|
||||
not, _ := g14.Status()["not carried"].(map[string]string)
|
||||
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
|
||||
if not[project+"/"+file] == "" {
|
||||
t.Errorf("%s is not listed as not carried: %v", file, not)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// §2: 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()
|
||||
laptop := newMemNode(t, root, "g14", HomeOperator, state, c)
|
||||
control := newMemNode(t, root, "novox", HomeAgent, state, c)
|
||||
control.write(t, project, "MEMORY.md", "from a confined agent\n", c.now)
|
||||
control.look(t)
|
||||
state.pump(t)
|
||||
if _, ok := laptop.read(t, project, "MEMORY.md"); ok {
|
||||
t.Fatal("a confined agent's memory reached the operator's home")
|
||||
}
|
||||
laptop.write(t, project, "MEMORY.md", "the operator's\n", c.now)
|
||||
laptop.look(t)
|
||||
state.pump(t)
|
||||
if got, _ := control.read(t, project, "MEMORY.md"); got != "from a confined agent\n" {
|
||||
t.Fatalf("the operator's memory reached the agent account's home: %q", got)
|
||||
}
|
||||
// A value of the other kind put under this kind's key is refused too.
|
||||
key := MemoryKey(HomeOperator, project, "x.md")
|
||||
raw, _ := json.Marshal(MemoryVersion{Kind: HomeAgent, Project: project, Path: "x.md", Text: "x", Digest: digestOf([]byte("x")), Node: "novox"})
|
||||
if err := laptop.Take(key, "put", raw, 99); err == nil {
|
||||
t.Fatal("a value of another kind was taken")
|
||||
}
|
||||
// Which kind a node carries for.
|
||||
if k, _ := HomeKind(Paths{Agent: "jochen", Operator: "jochen", Home: "/home/jochen"}); k != HomeOperator {
|
||||
t.Fatalf("the operator's own home is %q", k)
|
||||
}
|
||||
if k, _ := HomeKind(Paths{Agent: "agent", Operator: "jochen", Home: "/home/agent", AgentHome: "/home/agent", OperatorHome: "/home/jochen"}); k != HomeAgent {
|
||||
t.Fatalf("an agent account's home is %q", k)
|
||||
}
|
||||
if k, why := HomeKind(Paths{Agent: "agent", Operator: "jochen", Home: "/home/jochen", AgentHome: "/home/agent", OperatorHome: "/home/jochen"}); k != "" || why == "" {
|
||||
t.Fatalf("a confined agent in the operator's home carries %q", k)
|
||||
}
|
||||
}
|
||||
|
||||
// §5, §6: 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)
|
||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "- base\n")
|
||||
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n", c.now.Add(time.Minute))
|
||||
shanks.write(t, project, "MEMORY.md", "- base\n- learned on shanks\n", 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)
|
||||
a, _ := g14.read(t, project, "MEMORY.md")
|
||||
b, _ := shanks.read(t, project, "MEMORY.md")
|
||||
if a != b {
|
||||
t.Fatalf("the nodes did not end on one text:\n%s\n----\n%s", a, b)
|
||||
}
|
||||
newer, older := strings.Index(a, "learned on shanks"), strings.Index(a, "learned on g14")
|
||||
marker := strings.Index(a, "<!-- novox mesh: this file was changed on two nodes")
|
||||
if newer < 0 || older < 0 || marker < 0 || !(newer < marker && marker < older) {
|
||||
t.Fatalf("not both versions, the newer first, with the line between:\n%s", a)
|
||||
}
|
||||
if !strings.Contains(a[marker:], "changed on shanks") || !strings.Contains(a[marker:], "changed on g14") {
|
||||
t.Fatalf("the line does not name both nodes:\n%s", a)
|
||||
}
|
||||
notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...)
|
||||
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)
|
||||
}
|
||||
}
|
||||
all := append(g14.copies(t), shanks.copies(t)...)
|
||||
for _, v := range []string{"- base\n- learned on g14\n", "- base\n- learned on shanks\n"} {
|
||||
if !holdsText(all, v) {
|
||||
t.Errorf("the version %q was not kept", v)
|
||||
}
|
||||
}
|
||||
if st := g14.Status(); len(st["conflicts"].([]conflict))+len(shanks.Status()["conflicts"].([]conflict)) != 1 {
|
||||
t.Fatalf("the conflict is not listed: %v", st)
|
||||
}
|
||||
// Tidied on either node, the warning clears.
|
||||
c.now = c.now.Add(time.Minute)
|
||||
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n- learned on shanks\n", c.now)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
cleared := false
|
||||
for _, n := range append(g14.notices, shanks.notices...) {
|
||||
if n["clear"] == true {
|
||||
cleared = true
|
||||
}
|
||||
}
|
||||
if !cleared {
|
||||
t.Fatal("the warning did not clear once the file was tidied")
|
||||
}
|
||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "- base\n- learned on g14\n- learned on shanks\n" {
|
||||
t.Fatalf("the tidied file did not reach shanks: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The order of the two versions: the later change first, equal times by node name.
|
||||
func TestMergeVersionsPutsTheNewerFirst(t *testing.T) {
|
||||
at := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
||||
m := string(MergeVersions([]byte("old\n"), "g14", at, []byte("new\n"), "shanks", at.Add(time.Second)))
|
||||
if strings.Index(m, "new") > strings.Index(m, "old") {
|
||||
t.Fatalf("the newer is not first: %s", m)
|
||||
}
|
||||
m = string(MergeVersions([]byte("from shanks\n"), "shanks", at, []byte("from ace\n"), "ace", at))
|
||||
if strings.Index(m, "from ace") > strings.Index(m, "from shanks") {
|
||||
t.Fatalf("equal times are not ordered by node name: %s", m)
|
||||
}
|
||||
}
|
||||
|
||||
// §7: 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")
|
||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
|
||||
if err := os.Remove(g14.path(project, "old.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
if _, ok := shanks.read(t, project, "old.md"); ok {
|
||||
t.Fatal("the removal did not reach shanks")
|
||||
}
|
||||
if !holdsText(shanks.copies(t), "an old note\n") {
|
||||
t.Fatal("the removed version was not kept on shanks")
|
||||
}
|
||||
raw, _, ok, _ := state.Get(MemoryKey(HomeOperator, project, "old.md"))
|
||||
var v MemoryVersion
|
||||
_ = json.Unmarshal(raw, &v)
|
||||
if !ok || !v.Removed || v.Text != "" {
|
||||
t.Fatalf("the removal is not a marker in the state: %s", raw)
|
||||
}
|
||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||
t.Fatal("another file was touched")
|
||||
}
|
||||
}
|
||||
|
||||
// §7: 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)
|
||||
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
|
||||
_ = os.Remove(g14.path(project, "note.md"))
|
||||
shanks.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
|
||||
if removalFirst {
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
shanks.look(t)
|
||||
} else {
|
||||
shanks.look(t)
|
||||
g14.look(t)
|
||||
}
|
||||
state.pump(t)
|
||||
g14.look(t)
|
||||
shanks.look(t)
|
||||
state.pump(t)
|
||||
for _, n := range []*memNode{g14, shanks} {
|
||||
if got, _ := n.read(t, project, "note.md"); got != "v2\n" {
|
||||
t.Fatalf("removal first %v: the change did not outlive the removal on %s: %q", removalFirst, n.Node, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// §7: 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")
|
||||
key := MemoryKey(HomeOperator, project, "MEMORY.md")
|
||||
// The folder gone on g14.
|
||||
if err := os.RemoveAll(filepath.Join(g14.Home, ".claude", "projects", project, "memory")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||
t.Fatal("a missing folder removed the file elsewhere")
|
||||
}
|
||||
// A deleted key.
|
||||
_ = state.Delete(key)
|
||||
state.pump(t)
|
||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||
t.Fatal("a deleted key removed the file")
|
||||
}
|
||||
// The state lost: the file is carried again, not removed.
|
||||
shanks.look(t)
|
||||
state.pump(t)
|
||||
if _, _, ok, _ := state.Get(key); !ok {
|
||||
t.Fatal("a file whose key the state lost was not carried again")
|
||||
}
|
||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||
t.Fatalf("the file was touched on shanks: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// §8: 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)
|
||||
g14.write(t, project, "laptop.md", "only on the laptop\n", c.now)
|
||||
shanks.write(t, project, "MEMORY.md", "- workstation's index\n", c.now.Add(time.Hour))
|
||||
shanks.write(t, project, "workstation.md", "only on the workstation\n", c.now)
|
||||
shanks.write(t, project, "same.md", "same\n", c.now)
|
||||
g14.write(t, project, "same.md", "same\n", c.now)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
shanks.look(t)
|
||||
state.pump(t)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
for _, n := range []*memNode{g14, shanks} {
|
||||
index, _ := n.read(t, project, "MEMORY.md")
|
||||
if !strings.Contains(index, "laptop's index") || !strings.Contains(index, "workstation's index") ||
|
||||
strings.Index(index, "workstation's index") > strings.Index(index, "laptop's index") {
|
||||
t.Fatalf("%s's index does not hold both, the newer first:\n%s", n.Node, index)
|
||||
}
|
||||
for file, want := range map[string]string{"laptop.md": "only on the laptop\n", "workstation.md": "only on the workstation\n", "same.md": "same\n"} {
|
||||
if got, _ := n.read(t, project, file); got != want {
|
||||
t.Fatalf("%s's %s: %q", n.Node, file, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
if n := len(g14.notices) + len(shanks.notices); n != 1 {
|
||||
t.Fatalf("%d warnings for one conflict", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing is written through a symbolic link: a memory folder that is one is neither read nor written.
|
||||
func TestNothingIsWrittenThroughASymbolicLink(t *testing.T) {
|
||||
state, c, g14, shanks := twoNodes(t)
|
||||
elsewhere := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(shanks.Home, ".claude", "projects", project), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(elsewhere, filepath.Join(shanks.Home, ".claude", "projects", project, "memory")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g14.write(t, project, "MEMORY.md", "index\n", c.now)
|
||||
g14.look(t)
|
||||
state.pump(t)
|
||||
if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 {
|
||||
t.Fatalf("written through a symbolic link: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// The manifest declares the state and the tool; the tool reads and never writes.
|
||||
func TestTheManifestDeclaresTheMemoryState(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
State []string `json:"state"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
if !holdsText(m.State, MemoryState) {
|
||||
t.Fatalf("the manifest does not declare the %q state: %v", MemoryState, m.State)
|
||||
}
|
||||
var carrier *MemoryCarrier
|
||||
if st := carrier.Status(); st["carrying"] != false {
|
||||
t.Fatalf("a carrier not started says it carries: %v", st)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@
|
||||
"holdings",
|
||||
"config",
|
||||
"proposals",
|
||||
"questions"
|
||||
"questions",
|
||||
"memory"
|
||||
],
|
||||
"reads": [
|
||||
"claude-licence-manager.bindings"
|
||||
@@ -32,6 +33,7 @@
|
||||
"claude_code_grant",
|
||||
"claude_code_add_api_key",
|
||||
"claude_code_registrations",
|
||||
"claude_code_memory",
|
||||
"claude_code_mcp_list",
|
||||
"claude_code_mcp_register",
|
||||
"claude_code_mcp_unregister",
|
||||
|
||||
Reference in New Issue
Block a user