resilient memory sync: decouple push/pull, startup full resync, auth failure handling

- Decouple push and pull in _sync_once() so pull always runs even if push fails
- Add startup full resync to catch drift from other agents and schema changes
- Add periodic full resync every ~10 minutes for continuous drift correction
- Add auth failure detection (401/403) with graceful SQLite-only degradation
- Add /api/auth-check endpoint for lightweight key validation
- Add retry cap (5 attempts) on pending ops to prevent infinite queue buildup
- Add orphan reconciliation: push local-only records with content dedup
- Add memory_count MCP tool for sync diagnostics
- Add version-based SQLite schema migration (PRAGMA user_version)
- Fix API key in ~/.claude.json to match server
- Update README with sync resilience docs, test structure, project layout
- Add 30 new tests covering all new behaviors (155 total, all passing)
This commit is contained in:
Viktor Barzin 2026-03-16 18:35:09 +00:00
parent a18b94d310
commit e47efee6b6
No known key found for this signature in database
GPG key ID: 0EB088298288D958
8 changed files with 948 additions and 134 deletions

View file

@ -238,9 +238,9 @@ class TestMCPProtocol:
def test_handle_tools_list(self, server):
result = server.handle_tools_list({})
tools = result["tools"]
assert len(tools) == 5
assert len(tools) == 6
names = {t["name"] for t in tools}
assert names == {"memory_store", "memory_recall", "memory_list", "memory_delete", "secret_get"}
assert names == {"memory_store", "memory_recall", "memory_list", "memory_delete", "secret_get", "memory_count"}
def test_handle_tools_call_store(self, server):
result = server.handle_tools_call({
@ -291,7 +291,7 @@ class TestProcessMessage:
"params": {},
})
assert "result" in response
assert len(response["result"]["tools"]) == 5
assert len(response["result"]["tools"]) == 6
def test_tools_call(self, server):
response = server.process_message({
@ -340,3 +340,71 @@ class TestProcessMessage:
parsed = json.loads(serialized)
assert parsed["jsonrpc"] == "2.0"
assert parsed["id"] == 5
class TestMemoryCount:
def test_count_empty(self, server):
result = server.memory_count({})
assert "0" in result
def test_count_after_store(self, server):
server.memory_store({
"content": "test memory",
"expanded_keywords": "test memory keywords data",
})
result = server.memory_count({})
assert "1" in result
assert "facts" in result
def test_count_multiple_categories(self, server):
server.memory_store({
"content": "a fact",
"category": "facts",
"expanded_keywords": "fact test data words",
})
server.memory_store({
"content": "a preference",
"category": "preferences",
"expanded_keywords": "preference test data words",
})
result = server.memory_count({})
assert "facts: 1" in result
assert "preferences: 1" in result
def test_count_via_tools_call(self, server):
result = server.handle_tools_call({
"name": "memory_count",
"arguments": {},
})
assert not result.get("isError", False)
assert "0" in result["content"][0]["text"]
class TestSchemaMigration:
def test_schema_version_set(self, tmp_path):
db_path = str(tmp_path / "test.db")
srv = MemoryServer(sqlite_db_path=db_path)
cursor = srv.sqlite_conn.cursor()
version = cursor.execute("PRAGMA user_version").fetchone()[0]
assert version == 2
srv.sqlite_conn.close()
def test_migration_idempotent(self, tmp_path):
"""Running _init_sqlite twice should not error."""
from claude_memory.mcp_server import _init_sqlite
db_path = str(tmp_path / "test.db")
conn1, _ = _init_sqlite(db_path)
conn1.close()
conn2, _ = _init_sqlite(db_path)
version = conn2.execute("PRAGMA user_version").fetchone()[0]
assert version == 2
conn2.close()
def test_server_id_column_exists(self, tmp_path):
db_path = str(tmp_path / "test.db")
srv = MemoryServer(sqlite_db_path=db_path)
cursor = srv.sqlite_conn.cursor()
cursor.execute("PRAGMA table_info(memories)")
columns = {row["name"] for row in cursor.fetchall()}
assert "server_id" in columns
srv.sqlite_conn.close()