From bfa480c9ac1df901e2087246cdca6d663b926ef4 Mon Sep 17 00:00:00 2001 From: Cipher208 <269750686+Cipher208@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:14:35 +0100 Subject: [PATCH] fix(forgetting): a restored episode keeps the time it happened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_restore_episode` calls `EpisodicMemory.save(...)` without `created_at`, so a restored episode was stamped with the moment of its return. That is not only a display problem: `created_at` is what decay, the purge window and the L4 promotion order (`consolidation.py`, oldest first) all read, so a restored old episode never aged, never fell under a purge cutoff and queued as if it were new. The time was already lost before the restore: `archived_memories` had no `created_at` column, only `archived_at`. Measured on the live bases, that is 3 159 archived episodes in Hermes, 2 in Mimocode, 0 in CowAgent — all of them irrecoverable, because nothing recorded when they happened. `archive()` now takes `original_created_at` and every path that archives an episode passes the episode's own value (7 paths: `core/episodic.archive_old`, `features/compression`, both episode branches of the `forget` primitive, `dedup_episode_twins`, and the episode branches of `purge_foreign_rows` and `purge_speaker_layer_leaks`). Core-memory archive paths are untouched — that is a different table and a different finding. The column is added by `ensure()` with an idempotent `ALTER TABLE`, because `ensure()` is called from constructors that never run migrations: success is decided by re-reading the schema, never by the text of the error, so a real failure still raises and a lost race is treated as success. A matching alembic revision continues the chain from `20261004_2200_g27`; `ALTER TABLE ADD COLUMN` is schema-only, so the 3 159 existing rows are not rewritten and read as NULL. A NULL means "this row predates the column": the restore falls back to `now`, exactly as before. Substituting `archived_at` was considered and rejected — that is when the row was removed, not when it happened, and inventing a time is worse than admitting there is none. mypy drops 63 -> 42 with no new errors: passing `created_at` through `dedup_episode_twins` surfaced a pre-existing shadowing bug there (`d` used for both the classify() result and the loop variable), which is renamed here. The differential run on identical scratch bases produces byte-identical dry-run output and the same applied row sets. --- ...03_1200_a3_archived_original_created_at.py | 44 +++++++++ core/episodic.py | 4 + features/compression.py | 3 +- lifecycle/forgetting.py | 33 +++++-- mcp_server/tools/primitives/forget.py | 2 + scripts/dedup_episode_twins.py | 10 ++- scripts/purge_foreign_rows.py | 3 +- scripts/purge_speaker_layer_leaks.py | 3 +- shared/archived_memories.py | 54 ++++++++++- tests/test_lifecycle/test_restore_entries.py | 90 ++++++++++++++++++- tests/test_shared/test_shared.py | 57 ++++++++++++ 11 files changed, 284 insertions(+), 19 deletions(-) create mode 100644 alembic/versions/20261103_1200_a3_archived_original_created_at.py diff --git a/alembic/versions/20261103_1200_a3_archived_original_created_at.py b/alembic/versions/20261103_1200_a3_archived_original_created_at.py new file mode 100644 index 00000000..ddbe35b5 --- /dev/null +++ b/alembic/versions/20261103_1200_a3_archived_original_created_at.py @@ -0,0 +1,44 @@ +"""a3 — the bin remembers when an archived row happened. + +`archived_memories` kept only `archived_at`, the moment a row was removed, so a +restored episode was stamped with the moment of its return: it never aged, never +met the purge, and sorted to the end of the L4 queue (`created_at` order). + +`ArchivedMemories.ensure()` adds this column on bases that never run migrations — +it is called from constructors that do not migrate. This revision puts the same +column in the chain so `alembic upgrade head` alone yields the schema the code +expects. + +SQLite's ADD COLUMN is a schema-only change: no existing row is rewritten, and +every old row reads NULL (the time was never recorded, so NULL is the truth, not +a gap). The existence guard makes it a no-op when `ensure()` reached the base +first — a script or autohook can have run it before the server migrated. +""" + +from collections.abc import Sequence + +import sqlalchemy +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "20261103_1200_a3" +down_revision: str | Sequence[str] | None = "20261004_2200_g27" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _column_exists(conn: sqlalchemy.Connection, table: str, column: str) -> bool: + rows = conn.execute(sqlalchemy.text(f"PRAGMA table_info({table})")).fetchall() + return any(row[1] == column for row in rows) + + +def upgrade() -> None: + conn = op.get_bind() + if not _column_exists(conn, "archived_memories", "original_created_at"): + op.execute("ALTER TABLE archived_memories ADD COLUMN original_created_at REAL") + + +def downgrade() -> None: + conn = op.get_bind() + if _column_exists(conn, "archived_memories", "original_created_at"): + op.execute("ALTER TABLE archived_memories DROP COLUMN original_created_at") diff --git a/core/episodic.py b/core/episodic.py index e50e44aa..73555431 100644 --- a/core/episodic.py +++ b/core/episodic.py @@ -201,6 +201,10 @@ async def _run(conn: Any) -> int: importance=float(row["emotional_weight"]), original_id=int(row["episode_id"]), reason=f"inactive_{days}d", + # The row is already in hand and carries the moment it happened. + # Without it the bin keeps only `archived_at`, and a restore + # could not tell when this episode belonged to. + original_created_at=float(row["created_at"]), ) archived_count += 1 diff --git a/features/compression.py b/features/compression.py index d2c6c67f..7413b743 100644 --- a/features/compression.py +++ b/features/compression.py @@ -59,7 +59,7 @@ async def compress_episodes(self, user_id: str, min_weight: float = 0.3, layer: params = (user_id, layer, min_weight, cutoff) doomed = await self._dispatch.query( DB_NAME, - "SELECT episode_id, summary, emotional_weight FROM episodes WHERE user_id=? AND layer=? AND emotional_weight < ? AND created_at < ?", + "SELECT episode_id, summary, emotional_weight, created_at FROM episodes WHERE user_id=? AND layer=? AND emotional_weight < ? AND created_at < ?", params, ) if not doomed: @@ -78,6 +78,7 @@ async def compress_episodes(self, user_id: str, min_weight: float = 0.3, layer: importance=float(r["emotional_weight"]), original_id=int(r["episode_id"]), reason="compression_low_weight", + original_created_at=float(r["created_at"]), ) with contextlib.suppress(Exception): from lifecycle.transitions import record_transition diff --git a/lifecycle/forgetting.py b/lifecycle/forgetting.py index 167d6872..55042652 100644 --- a/lifecycle/forgetting.py +++ b/lifecycle/forgetting.py @@ -334,11 +334,13 @@ async def restore_entries(self, ids: list[int], user_id: str, memory_type: str | is the argument that resolves it rather than a contradiction of it. WHAT IS LOST. `layer` is not in the archive and neither are an episode's - `tags`, `created_at` or its layer, so a restored episode lands in this - system's own layer and carries the moment of its return. Data the archive - never kept cannot be restored, and this method does not pretend it can. - The rule is settled rather than pending: separating that metadata is - separate work, deliberately not done here. + `tags`, so a restored episode lands in this system's own layer. Its + `created_at` is now kept — in `original_created_at` — but only for rows + archived after the bin started recording it; an older row still carries + the moment of its return, and `archived_at` is not used as a stand-in for a + time nobody recorded. Data the archive never kept cannot be restored, and + this method does not pretend it can. The rule is settled rather than + pending: separating the rest is separate work, deliberately not done here. """ from shared.archived_memories import ( REFUSAL_ALREADY_PRESENT, @@ -520,6 +522,10 @@ async def _restore_episode(self, row: Any, user_id: str) -> int: whose summary was re-added is a no-op on the row count. That is counted as restored — the text is in the table, which is what the caller asked for — and it is a deliberate reading rather than an accident of the dedup. + + The episode is given back with the time it happened when the bin recorded + it (`original_created_at`). A row archived before that column existed has + no time to give back, so it is stamped with the moment of its return. """ from core.episodic import EpisodicMemory @@ -533,7 +539,22 @@ async def _restore_episode(self, row: Any, user_id: str) -> int: # `forget_primitive_fuzzy_l3`, so the old form would have restored that episode # at 0.5. There is no single base to measure — the archive is per instance. weight = 0.5 if row["importance"] is None else float(row["importance"]) - return await mem.save(user_id, str(row["content"]), emotional_weight=weight) + # The bin now carries the moment the episode happened. It is absent on rows + # archived before the column existed (3159 of them on one live base, + # measured 2026-11-03), and `archived_at` is not a substitute: it is when + # the row was removed, not when it happened. So a missing value keeps the + # old behaviour and the episode is stamped with its return. + # + # The key check is for a base whose bin predates the column and where + # `ensure()` has not run in this process yet — `restore_entries` reads the + # table directly and must not crash on a schema it can still serve. + created_at = None + # `in row` would test VALUES, not column names on sqlite3.Row, so the key + # check has to go through `.keys()` (SIM118 does not apply to this type). + if "original_created_at" in row.keys(): # noqa: SIM118 + recorded = row["original_created_at"] + created_at = None if recorded is None else float(recorded) + return await mem.save(user_id, str(row["content"]), emotional_weight=weight, created_at=created_at) async def _restore_core(self, conn: Any, row: Any, original_id: int, now: float) -> None: """Put an archived L4 row back into `core_memory`. diff --git a/mcp_server/tools/primitives/forget.py b/mcp_server/tools/primitives/forget.py index a83190da..319012a7 100644 --- a/mcp_server/tools/primitives/forget.py +++ b/mcp_server/tools/primitives/forget.py @@ -274,6 +274,7 @@ async def _forget_episode_ids(app: AppContext, episode_ids: list[int], user_id: importance=episode.emotional_weight, original_id=episode.episode_id, reason="forget_primitive_episode_ids", + original_created_at=episode.created_at, ) outcome.deleted_l3 = int(await mem.l3.delete_by_ids([e.episode_id for e in found], user_id=user_id)) return outcome @@ -296,6 +297,7 @@ async def _delete_matching_episodes( importance=episode.emotional_weight, original_id=episode.episode_id, reason="forget_primitive_fuzzy_l3", + original_created_at=episode.created_at, ) return int(await mem.l3.delete_by_ids([e.episode_id for e in episodes], user_id=user_id)), hits diff --git a/scripts/dedup_episode_twins.py b/scripts/dedup_episode_twins.py index 207587f5..4fbac3e3 100755 --- a/scripts/dedup_episode_twins.py +++ b/scripts/dedup_episode_twins.py @@ -134,10 +134,13 @@ async def run(apply: bool) -> int: drop: list[dict] = [] klass = Counter() for members in groups.values(): - k, d, name = classify(members) + # Named apart from `keep`/`drop` and from the loop variables below: + # unpacking into `k, d` bound `d` to `list[dict]`, so every later + # `for d in drop` looked like a dict assigned to a list. + group_keep, group_drop, name = classify(members) klass[name] += 1 - keep.extend(k) - drop.extend(d) + keep.extend(group_keep) + drop.extend(group_drop) before_total = await dispatch.scalar(DB_NAME, "SELECT COUNT(*) FROM episodes") or 0 orphans_sql = ( @@ -206,6 +209,7 @@ async def run(apply: bool) -> int: importance=float(d["emotional_weight"] or 0.0), original_id=int(d["episode_id"]), reason="duplicate_episode_replay", + original_created_at=float(d["created_at"]), ) with contextlib.suppress(Exception): from lifecycle.transitions import record_transition diff --git a/scripts/purge_foreign_rows.py b/scripts/purge_foreign_rows.py index 966d3495..1d80a674 100755 --- a/scripts/purge_foreign_rows.py +++ b/scripts/purge_foreign_rows.py @@ -117,7 +117,7 @@ def plan(db: Path) -> dict[str, Any]: # --- episodes: via the raw: provenance tag --- foreign_eps: list[sqlite3.Row] = [] keep_summaries: set[str] = set() - for row in conn.execute("SELECT episode_id, summary, layer, user_id, emotional_weight, tags FROM episodes"): + for row in conn.execute("SELECT episode_id, summary, layer, user_id, emotional_weight, tags, created_at FROM episodes"): raws = _raw_tags(row["tags"]) if raws and any(r in marked for r in raws): foreign_eps.append(row) @@ -220,6 +220,7 @@ async def apply(p: dict[str, Any], db: Path) -> None: importance=float(r["emotional_weight"] or 0.0), original_id=int(r["episode_id"]), reason=ARCHIVE_REASON, + original_created_at=float(r["created_at"]), ) archived += 1 for r in p["facts"]: diff --git a/scripts/purge_speaker_layer_leaks.py b/scripts/purge_speaker_layer_leaks.py index 66189b25..4416aa02 100755 --- a/scripts/purge_speaker_layer_leaks.py +++ b/scripts/purge_speaker_layer_leaks.py @@ -102,7 +102,7 @@ def plan(db: Path) -> dict[str, Any]: # every legitimate agent episode (6030 of them) and would delete the whole # agent layer instead of the leak. doomed_eps: list[sqlite3.Row] = [] - for row in conn.execute("SELECT episode_id, summary, tags, user_id, emotional_weight FROM episodes WHERE layer='user'"): + for row in conn.execute("SELECT episode_id, summary, tags, user_id, emotional_weight, created_at FROM episodes WHERE layer='user'"): try: tags = json.loads(row["tags"]) if row["tags"] else [] except (TypeError, ValueError): @@ -227,6 +227,7 @@ async def _audit(target_id: str, details: dict[str, Any]) -> None: importance=float(r["emotional_weight"] or 0.0), original_id=int(r["episode_id"]), reason=ARCHIVE_REASON, + original_created_at=float(r["created_at"]), ) ph = ",".join("?" * len(ep_ids)) await conn.execute(f"DELETE FROM episodes WHERE episode_id IN ({ph})", tuple(ep_ids)) diff --git a/shared/archived_memories.py b/shared/archived_memories.py index 49e9696a..01e51566 100644 --- a/shared/archived_memories.py +++ b/shared/archived_memories.py @@ -1,6 +1,8 @@ """ArchivedMemories — async archived memory storage.""" from __future__ import annotations + +import sqlite3 from typing import Any from shared.connection import AsyncConnectionManager, connection_manager @@ -163,14 +165,48 @@ async def ensure(self) -> None: first. Run alone, or with `-p no:randomly`, it failed with `no such table: archived_memories`. A test that needs a neighbour to pass is not testing the class. + + `original_created_at` is added here and not only in the migration because + this method is what a base without migrations gets: constructors call it + on bases that never ran `alembic upgrade`. A bin written before the column + existed holds 3159 archived episodes on one live base (measured 2026-11-03), + and they must survive the ALTER — it is a schema-only change, so no row is + rewritten and every old row reads NULL. """ if self._ready: return - cols = [r["name"] for r in await self._dispatch.query(DB_NAME, "PRAGMA table_info(archived_memories)")] + cols = await self._columns() if not cols: await self._init_db() + # Re-read instead of trusting the CREATE: another process may have won + # the race and created the table with an older shape. The ALTER below + # is what actually guarantees the column. + cols = await self._columns() + if cols and "original_created_at" not in cols: + await self._add_original_created_at() self._ready = True + async def _columns(self) -> list[str]: + rows = await self._dispatch.query(DB_NAME, "PRAGMA table_info(archived_memories)") + return [r["name"] for r in rows] + + async def _add_original_created_at(self) -> None: + """Add the time an archived row happened, once, without touching a row. + + WHY THE EXCEPTION IS TOLERATED AND NOT MATCHED BY TEXT. Two processes can + reach `ensure()` on one base at the same moment: both read the old schema, + then serialize on SQLite's write lock, and the loser's ALTER finds the + column already there. That is the operation having succeeded, so success is + decided by re-reading the schema — not by the error message, which would + also swallow a real failure that happens to read alike. A genuine failure + leaves the column missing and is re-raised. + """ + try: + await self._dispatch.write(DB_NAME, "ALTER TABLE archived_memories ADD COLUMN original_created_at REAL") + except sqlite3.OperationalError: + if "original_created_at" not in await self._columns(): + raise + async def _init_db(self) -> None: await self._dispatch.execute_script( DB_NAME, @@ -181,7 +217,8 @@ async def _init_db(self) -> None: original_id INTEGER, content TEXT NOT NULL, memory_type TEXT, importance REAL, archive_reason TEXT NOT NULL, - archived_at DATETIME DEFAULT CURRENT_TIMESTAMP + archived_at DATETIME DEFAULT CURRENT_TIMESTAMP, + original_created_at REAL ); CREATE INDEX IF NOT EXISTS idx_archived_user ON archived_memories(user_id, archived_at DESC); """, @@ -195,6 +232,7 @@ async def archive( importance: float | None = None, original_id: int | None = None, reason: str = "manual", + original_created_at: float | None = None, ) -> int: """Record a row in the bin. @@ -214,12 +252,20 @@ async def archive( repository — `core/memory.py`, `hooks/shared.py`, `importance_scheduler.py` — but those are not calls to this method and were counted as though they were in an earlier version of this docstring.) + + `original_created_at` is the moment the row *happened*, which only the + caller knows: an episode carries it in its own `created_at`. Recording it + here is what lets a restore give the row back with its own age instead of + stamping it with the moment of its return. `None` is honest for rows that + never had a time, and for core rows, whose restore path does not use it. """ await self.ensure() return await self._dispatch.insert( DB_NAME, - "INSERT INTO archived_memories (user_id, original_id, content, memory_type, importance, archive_reason) VALUES (?, ?, ?, ?, ?, ?)", - (user_id, original_id, content, memory_type, importance, reason), + "INSERT INTO archived_memories " + "(user_id, original_id, content, memory_type, importance, archive_reason, original_created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (user_id, original_id, content, memory_type, importance, reason, original_created_at), ) async def get_archived(self, user_id: str = "default", limit: int = 50) -> dict[str, Any]: diff --git a/tests/test_lifecycle/test_restore_entries.py b/tests/test_lifecycle/test_restore_entries.py index 0a832c1f..cc0f5138 100644 --- a/tests/test_lifecycle/test_restore_entries.py +++ b/tests/test_lifecycle/test_restore_entries.py @@ -36,6 +36,7 @@ import pytest +from core.episodic import EpisodicMemory from lifecycle.forgetting import ForgettingSystem from shared.archived_memories import PURGE_REASONS, ArchivedMemories from shared.connection import connection_manager @@ -144,9 +145,10 @@ async def test_an_episode_returns_to_episodes_and_not_to_core_memory(cm: Any) -> async def test_a_restored_episode_loses_what_the_archive_never_kept(cm: Any) -> None: - """The limit, asserted rather than left to be discovered: the archive holds a - summary and a weight, so `tags`, `created_at` and `layer` cannot come back. - A restored episode carries the moment of its return.""" + """The limit, asserted rather than left to be discovered: this row was + archived without a time and without a layer, and neither can be recovered — + the archive never kept them. `created_at` is the moment of return, as before; + a row that did record its time keeps it (see the test below).""" await _archive(cm, original_id=31, content="an old moment", memory_type="episode", importance=0.4) before = time.time() @@ -158,6 +160,49 @@ async def test_a_restored_episode_loses_what_the_archive_never_kept(cm: Any) -> assert row["layer"] == "agent", "the restore must land in the system's own layer, the only one it has" +async def test_a_restored_episode_keeps_the_moment_it_happened(cm: Any) -> None: + """The bin is a place a row waits, not a place it is reborn. + + Measured 2026-11-03: `_restore_episode` called `save()` without `created_at`, + and `archived_memories` had no column to carry one, so a restored episode was + stamped with the moment of its return. It then never aged, never met the + purge, and sorted to the end of the L4 queue — `lifecycle/consolidation.py` + orders by `created_at`. + + This walks the real path end to end: `archive_old` writes the bin and the + restore reads it, so both the archiving writer and the restoring reader are + exercised rather than a hand-written row. + """ + epi = EpisodicMemory(cm=cm, layer="user") + t0 = time.time() - 90 * 86400 + episode_id = await epi.save(U1, "an old moment", emotional_weight=0.1, created_at=t0) + assert await epi.archive_old(U1, days=90) == 1 + + outcome = await _fs(cm).restore_entries([episode_id], user_id=U1, memory_type="episode") + + assert outcome.restored == 1 + row = (await _rows(cm, "SELECT created_at FROM episodes WHERE summary='an old moment'"))[0] + assert row["created_at"] == pytest.approx(t0), "the restored episode was stamped with the moment of its return" + + +async def test_an_archived_episode_with_no_recorded_time_comes_back_now(cm: Any) -> None: + """The 3159 rows already in the bin have no time to give back — measured on + the Hermes base 2026-11-03 — and `archived_at` is when the row was removed, + not when the moment happened, so it is not used as a stand-in. Such a row + must restore exactly as it did before: no exception, and `created_at` is now. + """ + await _archive(cm, original_id=32, content="an old moment", memory_type="episode", importance=0.4) + archived = await _rows(cm, "SELECT original_created_at FROM archived_memories WHERE original_id=32") + assert archived[0]["original_created_at"] is None, "a row archived without a time must not invent one" + + before = time.time() + outcome = await _fs(cm).restore_entries([32], user_id=U1) + + assert outcome.restored == 1 + row = (await _rows(cm, "SELECT created_at FROM episodes WHERE summary='an old moment'"))[0] + assert before <= row["created_at"] <= time.time() + 1 + + async def test_an_episode_whose_text_was_re_added_is_still_counted_as_present(cm: Any) -> None: """`EpisodicMemory.save()` dedups on (layer, user_id, summary). Restoring text that is already there is a no-op on rows and a success for the caller — a @@ -177,6 +222,45 @@ async def test_an_episode_whose_text_was_re_added_is_still_counted_as_present(cm assert await _archived_count(cm, 41) == 0, "the archive row must still be consumed — the restore did succeed" +async def test_a_bin_without_the_new_column_still_restores(tmp_path: Any) -> None: + """A base whose bin predates `original_created_at`, and where `ensure()` has + not run in this process. `restore_entries` reads the table directly, so the + missing column must read as "no recorded time", not crash the restore — the + alternative is that an upgraded server cannot return anything from an old bin + until something happens to archive a row. + """ + from shared.connection import AsyncConnectionManager + + cm = AsyncConnectionManager(base_dir=str(tmp_path)) + try: + await EpisodicMemory(cm=cm, layer="user")._init_db() + conn = await cm.get("memory.db") + await conn.executescript( + """ + CREATE TABLE archived_memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL DEFAULT 'default', + original_id INTEGER, content TEXT NOT NULL, + memory_type TEXT, importance REAL, + archive_reason TEXT NOT NULL, + archived_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + INSERT INTO archived_memories (user_id, original_id, content, memory_type, archive_reason) + VALUES ('u_restore_1', 81, 'an old moment', 'episode', 'inactive_90d'); + """ + ) + await conn.commit() + + before = time.time() + outcome = await _fs(cm).restore_entries([81], user_id=U1, memory_type="episode") + + assert outcome.restored == 1 + row = (await _rows(cm, "SELECT created_at FROM episodes WHERE summary='an old moment'"))[0] + assert before <= row["created_at"] <= time.time() + 1 + finally: + await cm.close_all() + + async def test_a_purged_row_is_refused_with_a_reason(cm: Any) -> None: """A purged row is out of reach, and the reason is why. A silent 0 would look like an id that names nothing; the caller has to be able to tell the two apart. diff --git a/tests/test_shared/test_shared.py b/tests/test_shared/test_shared.py index b3e88925..14c4bcb0 100644 --- a/tests/test_shared/test_shared.py +++ b/tests/test_shared/test_shared.py @@ -84,6 +84,63 @@ async def t(): asyncio.run(t()) +def test_ensure_adds_original_created_at_to_an_existing_bin(tmp_path): + """Корзина, созданная до появления колонки, должна получить её без потерь. + + Замерено 2026-11-03: в базе Hermes 3159 архивных эпизодов и колонки + `original_created_at` нет. Миграции на такой базе не запускаются — `ensure()` + зовут конструкторы, — поэтому ALTER живёт в нём. Два прохода подряд обязаны + пройти молча: второй не должен падать на «duplicate column name». + """ + import sqlite3 + + from shared.archived_memories import ArchivedMemories + from shared.connection import AsyncConnectionManager + from shared.constants import DB_NAME + from shared.write_dispatch import get_write_dispatch + + raw = sqlite3.connect(tmp_path / DB_NAME) + try: + raw.executescript( + """ + CREATE TABLE archived_memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL DEFAULT 'default', + original_id INTEGER, content TEXT NOT NULL, + memory_type TEXT, importance REAL, + archive_reason TEXT NOT NULL, + archived_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + INSERT INTO archived_memories (user_id, original_id, content, memory_type, archive_reason) + VALUES ('test_sh', 1, 'an old archived episode', 'episode', 'inactive_90d'); + """ + ) + raw.commit() + finally: + raw.close() + + async def t(): + cm = AsyncConnectionManager(base_dir=str(tmp_path)) + dispatch = get_write_dispatch(cm) + try: + await ArchivedMemories(cm=cm).ensure() + # A fresh instance has its own `_ready` flag; it must not re-ALTER. + await ArchivedMemories(cm=cm).ensure() + # The loser of two concurrent ALTERs sees `duplicate column name`. + # That must be read as success — decided by the schema, not by text. + await ArchivedMemories(cm=cm)._add_original_created_at() + cols = [r["name"] for r in await dispatch.query(DB_NAME, "PRAGMA table_info(archived_memories)")] + assert "original_created_at" in cols + rows = await dispatch.query(DB_NAME, "SELECT content, original_created_at FROM archived_memories") + assert len(rows) == 1, "the ALTER lost the rows that were already in the bin" + assert rows[0]["content"] == "an old archived episode" + assert rows[0]["original_created_at"] is None + finally: + await cm.close_all() + + asyncio.run(t()) + + def test_embedding_cache(tmp_path): """Тот же принцип: свой manager, чтобы файл не трогал общий каталог вовсе.""" from shared.connection import AsyncConnectionManager