From aae04397028c056a014c99642676d1454eadcbc4 Mon Sep 17 00:00:00 2001 From: alexeybe1kin <210597588+alexeybe1kin@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:35:03 +0200 Subject: [PATCH 1/2] fix: a write is not lost to a memory under review --- CHANGELOG.md | 5 +++ services/api/app/routes/memory.py | 33 ++++++++++--------- services/api/tests/test_write_dedup_active.py | 33 +++++++++++++++++++ 3 files changed, 55 insertions(+), 16 deletions(-) create mode 100644 services/api/tests/test_write_dedup_active.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 91336d4..c0bdcc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ a decision with visible consequences. ## Unreleased +- **A write is not lost to a memory under review.** `POST /memory/write` took a memory with the + same text as its duplicate whatever its status, so a write equal to a memory under review or + expired answered ok with `duplicate_of` and nothing became active; it also looked only at the + newest 100. Now only an active memory of the agent counts, however old, and two writes of one + text are serialised (Postgres). - **Merging entities keeps lineage and the graph whole.** The startup dedup and the manual merge (`POST /entity/merge`) now share one routine: lineage links to the removed entity move to the kept one, an edge between the two (a would-be self-loop) or one the kept entity already has diff --git a/services/api/app/routes/memory.py b/services/api/app/routes/memory.py index 48e9bb0..34b208b 100644 --- a/services/api/app/routes/memory.py +++ b/services/api/app/routes/memory.py @@ -18,6 +18,7 @@ from app.services.agent_config_service import get_or_create_config from app.services.classifier import CURRENT_MEMORY_TYPES, classify_memory, normalize_memory_type from app.services.lexical_retrieval import escape_like, memory_candidates, prepare_query +from app.services.memory_text_lock import lock_memory_text from app.services.memory_truth import add_revision, detect_conflicts from app.services.qdrant_store import ( INDEX_UNREACHABLE, @@ -245,23 +246,23 @@ def write_memory(payload: MemoryWriteRequest, header_agent_id: str = Depends(get final_do_not_generalize = payload.do_not_generalize if payload.do_not_generalize is not None else False final_review_by = _default_review_by(final_memory_type, parse_timestamp(payload.review_by, "review_by")) - normalized_text = payload.text.strip().lower() - existing = db.execute( + # Exact duplicate: the same text as an active memory of this agent, however old. A memory + # under review or expired is not a duplicate (the write would be lost); two writes of + # one text are serialised until this transaction commits. + lock_memory_text(db, agent_id, payload.text) + duplicate = db.execute( select(Memory) - .where(Memory.agent_id == agent_id) - .order_by(Memory.created_at.desc()) - .limit(100) - ).scalars().all() - - # exact duplicate - for row in existing: - if row.text.strip().lower() == normalized_text: - return _upgrade_existing_memory( - db, row, payload, - final_memory_type, - final_confidence, - final_summary, - ) + .where(Memory.agent_id == agent_id, Memory.status == "active", + func.lower(func.trim(Memory.text)) == payload.text.strip().lower()) + .order_by(Memory.created_at.asc()) + ).scalars().first() + if duplicate is not None: + return _upgrade_existing_memory( + db, duplicate, payload, + final_memory_type, + final_confidence, + final_summary, + ) # Novelty check via vector similarity, scoped to this agent. Without a # working vector path there is no near-duplicate signal at all, so the diff --git a/services/api/tests/test_write_dedup_active.py b/services/api/tests/test_write_dedup_active.py new file mode 100644 index 0000000..d49b2e5 --- /dev/null +++ b/services/api/tests/test_write_dedup_active.py @@ -0,0 +1,33 @@ +"""A write matches only an active memory as its duplicate, however old that memory is.""" + +from datetime import UTC, datetime, timedelta + +from app.models.memory import Memory +from memory_stack import stack as stack + +TEXT = "I prefer the release checklist printed on paper" + + +def test_a_write_equal_to_a_memory_under_review_makes_an_active_memory(stack): + with stack.sessions() as db: + db.add(Memory(id="m_review", agent_id="owner", text=TEXT, status="needs_review")) + db.commit() + + written = stack.write(TEXT) + + assert written.get("duplicate_of") is None and written["id"] != "m_review" + with stack.sessions() as db: + assert db.get(Memory, written["id"]).status == "active" + + +def test_an_old_active_memory_is_still_found_as_the_duplicate(stack): + first = stack.write(TEXT)["id"] + with stack.sessions() as db: + later = datetime.now(UTC) + timedelta(days=1) + db.add_all([Memory(id=f"m{n:03d}", agent_id="owner", text=f"Filler memory number {n} about lunch", + created_at=later + timedelta(seconds=n)) for n in range(120)]) + db.commit() + + again = stack.write(" " + TEXT.upper() + " ") + + assert again["duplicate_of"] == first From e37000e3d8f46ea94d4537229dee0be0f5aeb9e3 Mon Sep 17 00:00:00 2001 From: alexeybe1kin <210597588+alexeybe1kin@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:25:41 +0200 Subject: [PATCH 2/2] changelog: place entry apart from other open PRs --- CHANGELOG.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8fe958..a477f44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,6 @@ a decision with visible consequences. ## Unreleased -- **A write is not lost to a memory under review.** `POST /memory/write` took a memory with the - same text as its duplicate whatever its status, so a write equal to a memory under review or - expired answered ok with `duplicate_of` and nothing became active; it also looked only at the - newest 100. Now only an active memory of the agent counts, however old, and two writes of one - text are serialised (Postgres). - - **A lockout stops wrong keys, never the right one.** After five wrong keys from one address, every key from that address was refused for 300 s, so one misconfigured caller behind a proxy or the host network locked out the rest, and failures never expired. Now the key is checked @@ -30,6 +24,12 @@ a decision with visible consequences. `user` or `owner_statement`) is no longer taken out of recall because of one that is not: the conflict is still recorded, and only the other memory goes to review. +- **A write is not lost to a memory under review.** `POST /memory/write` took a memory with the + same text as its duplicate whatever its status, so a write equal to a memory under review or + expired answered ok with `duplicate_of` and nothing became active; it also looked only at the + newest 100. Now only an active memory of the agent counts, however old, and two writes of one + text are serialised (Postgres). + - **"Don't remember" keeps no words.** A conversation message the owner asked not to keep ("don't remember ...", "не запоминай ...") used to be stored word for word as evidence (only the memory was skipped). Its evidence now holds no text (`normalized_payload` is