From d5a336c58b7f18c85b28bce025b04d84f4958707 Mon Sep 17 00:00:00 2001 From: senna-lang Date: Mon, 7 Sep 2026 08:56:16 +0900 Subject: [PATCH 1/2] fix(db): stop v11 from clobbering non-claude harness labels (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v11's canonical-session migration unconditionally set `harness = COALESCE(harness, 'claude')` for every pre-existing conversation, before `_backfill_exchange_provenance` (which infers harness from source_path) ever ran. Since v11 always filled the column first, the backfill's `WHERE harness IS NULL` guard never matched, so grok/omp-pi/opencode/codex exchanges predating v11 stayed mislabeled as claude forever. Extract the source_path -> (harness, source_session_id) heuristic into a single `_infer_harness_and_source_session_id` helper shared by v11 and `_backfill_exchange_provenance`, so both derive identical labels and session_ids instead of one silently overriding the other. Also fixes `file_renames.py`'s `git log --follow` call to pass `-c core.quotepath=false`, so non-ASCII paths aren't returned as octal-escaped quoted strings (`"caf\303\251.py"`), which broke rename alias matching for such files. Investigated the third checklist item (grok/omp-pi/opencode missing git_branch) against tests/fixtures/harness_logs/README.md, which documents key names/nesting verified against real logs (grok: 39, omp-pi: 99, opencode: 1). None of the three carry a git-branch field anywhere in their raw schema (grok's ACP session/update envelope, omp-pi's `{type: "session", cwd}` envelope, opencode's project/session/message/part tables) — unlike claude's `gitBranch` or codex's `session_meta.git.branch`. Left `git_branch=None` for all three with comments recording this finding instead of fabricating an extraction path or a live `git branch` shell-out (which would record the branch at index time, not at conversation time, diverging from what the field means for claude/codex). Closes #19 --- src/codeatrium/db.py | 51 +++++++++++++++++++------------ src/codeatrium/file_renames.py | 3 +- src/codeatrium/indexer.py | 18 +++++++++++ tests/test_db.py | 56 ++++++++++++++++++++++++++++++++++ tests/test_file_renames.py | 22 +++++++++++++ 5 files changed, 129 insertions(+), 21 deletions(-) diff --git a/src/codeatrium/db.py b/src/codeatrium/db.py index 19a50a7..575019d 100644 --- a/src/codeatrium/db.py +++ b/src/codeatrium/db.py @@ -382,22 +382,26 @@ def _migrate_v11_add_canonical_sessions(con: sqlite3.Connection) -> None: ).fetchall() for row in rows: source_path = row["source_path"] - session_id = hashlib.sha256(f"claude:{source_path}".encode()).hexdigest() + harness, source_session_id = _infer_harness_and_source_session_id(source_path) + session_id = hashlib.sha256(f"{harness}:{source_session_id}".encode()).hexdigest() cursor = f"v1:ply:{row['last_ply_end']}" con.execute( """ INSERT OR IGNORE INTO sessions (id, harness, source_session_id, primary_ref, project_key, cursor, started_at, updated_at) - VALUES (?, 'claude', ?, ?, '', ?, ?, COALESCE(?, CURRENT_TIMESTAMP)) + VALUES (?, ?, ?, ?, '', ?, ?, COALESCE(?, CURRENT_TIMESTAMP)) """, - (session_id, source_path, source_path, cursor, row["started_at"], row["started_at"]), + ( + session_id, harness, source_session_id, source_path, cursor, + row["started_at"], row["started_at"], + ), ) con.execute( """ UPDATE exchanges SET session_id = ?, - harness = COALESCE(harness, 'claude'), + harness = COALESCE(harness, ?), session_ref = COALESCE( session_ref, ? || '#ply=' || ply_start || '-' || ply_end ), @@ -405,9 +409,30 @@ def _migrate_v11_add_canonical_sessions(con: sqlite3.Connection) -> None: source_turn_id = COALESCE(source_turn_id, CAST(ply_start AS TEXT)) WHERE conversation_id = ? """, - (session_id, source_path, source_path, row["id"]), + (session_id, harness, source_path, source_session_id, row["id"]), ) + +def _infer_harness_and_source_session_id(source_path: str) -> tuple[str, str]: + """`source_path` の形状から harness と source_session_id を推定する唯一の正規定義。 + + v11 マイグレーションと _backfill_exchange_provenance の両方がこれを使う。 + 以前は v11 が全 pre-existing conversation を無条件に 'claude' 固定していたため、 + 直後に走る _backfill_exchange_provenance の `WHERE harness IS NULL` が + ヒットせず、非claude 由来(grok/omp-pi/opencode/codex)の会話も claude 誤ラベルの + まま恒久化していた(issue #19)。ヒューリスティックを1箇所に集約し、 + 両者が常に同じ判定・同じ session_id を導出するようにする。 + """ + if "opencode.db#" in source_path: + return "opencode", source_path.rsplit("#", 1)[1] + if "rollout-" in source_path: + return "codex", source_path + if "/.omp/" in source_path: + return "omp-pi", source_path + if "/.grok/" in source_path: + return "grok", source_path + return "claude", source_path + def _backfill_exchange_provenance(con: sqlite3.Connection) -> None: exists = con.execute( "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'exchanges'" @@ -424,21 +449,7 @@ def _backfill_exchange_provenance(con: sqlite3.Connection) -> None: ).fetchall() for row in rows: source_path = row["source_path"] - if "opencode.db#" in source_path: - harness = "opencode" - source_session_id = source_path.rsplit("#", 1)[1] - elif "rollout-" in source_path: - harness = "codex" - source_session_id = source_path - elif "/.omp/" in source_path: - harness = "omp-pi" - source_session_id = source_path - elif "/.grok/" in source_path: - harness = "grok" - source_session_id = source_path - else: - harness = "claude" - source_session_id = source_path + harness, source_session_id = _infer_harness_and_source_session_id(source_path) session_id = hashlib.sha256( f"{harness}:{source_session_id}".encode() ).hexdigest() diff --git a/src/codeatrium/file_renames.py b/src/codeatrium/file_renames.py index d854996..c66c73d 100644 --- a/src/codeatrium/file_renames.py +++ b/src/codeatrium/file_renames.py @@ -53,7 +53,8 @@ def _run_git_follow(project_root: str, file_path: str) -> str: try: result = subprocess.run( [ - "git", "log", "--follow", "--name-status", + "git", "-c", "core.quotepath=false", + "log", "--follow", "--name-status", "--diff-filter=R", "--format=", "--", file_path, ], cwd=project_root, diff --git a/src/codeatrium/indexer.py b/src/codeatrium/indexer.py index c025539..41be7db 100644 --- a/src/codeatrium/indexer.py +++ b/src/codeatrium/indexer.py @@ -473,6 +473,12 @@ def parse_grok_exchanges( user_content=user_text, agent_content=agent_text, files=files, + # grok の ACP envelope (`session/update`) には git ブランチが一切載らない + # (tests/fixtures/harness_logs/README.md の実ログ39本再調査で確認済み。 + # session/update・tool_call・tool_call_update いずれの params にも + # git 関連フィールドは存在しない)。claude の gitBranch・codex の + # session_meta.git.branch に相当するデータがそもそも無いため、 + # 実在しないフィールドを捏造せず None のままにする(issue #19)。 git_branch=None, ) ) @@ -555,6 +561,13 @@ def parse_omp_pi_exchanges( user_content=user_text, agent_content=agent_text, files=files, + # omp-pi のセッション envelope には `{type: "session", cwd}` の + # 作業ディレクトリしか無く、git ブランチは記録されない + # (tests/fixtures/harness_logs/README.md の実ログ99本再調査で確認済み)。 + # cwd から index 時点のブランチを別途 git 問い合わせすることは、 + # 発話当時のブランチではなく現在のブランチを記録してしまい claude/codex の + # 意味と食い違うため行わない。実在しないフィールドを捏造せず None のままに + # する(issue #19)。 git_branch=None, ) ) @@ -688,6 +701,11 @@ def parse_opencode_exchanges( user_content=user_text, agent_content=agent_text, files=files, + # opencode の project/session テーブルには worktree/directory/vcs は + # あるが git ブランチ列は無い (tests/fixtures/harness_logs/README.md の + # 実 opencode.db 再調査で確認済み。message/part の data JSON にも + # ブランチ相当のキーは登場しない)。実在しないフィールドを捏造せず + # None のままにする(issue #19)。 git_branch=None, ) ) diff --git a/tests/test_db.py b/tests/test_db.py index 08b6a95..23d5da9 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1454,6 +1454,62 @@ def test_migration_v12_adds_exchange_conversation_ply_index_to_existing_db(tmp_p con.close() +def test_migration_v11_infers_harness_from_source_path_for_non_claude_sessions( + tmp_path: Path, +) -> None: + """v11 が pre-existing conversation を無条件に harness='claude' 固定すると、 + 直後の _backfill_exchange_provenance が `harness IS NULL` にヒットせず + grok/omp-pi/opencode/codex 由来の会話も claude 誤ラベルのまま恒久化する + (issue #19)。v11 は _backfill_exchange_provenance と同じヒューリスティックで + 非claude の source_path を正しく判定しなければならない。 + """ + db_path = tmp_path / "memory.db" + grok_path = tmp_path / ".grok" / "session.jsonl" + + raw_con = sqlite3.connect(db_path) + raw_con.execute( + """CREATE TABLE conversations ( + id TEXT PRIMARY KEY, + source_path TEXT NOT NULL UNIQUE, + started_at TIMESTAMP, + last_ply_end INT NOT NULL DEFAULT -1, + parent_session_ref TEXT + )""" + ) + raw_con.execute( + "INSERT INTO conversations(id, source_path) VALUES ('conv1', ?)", + (str(grok_path),), + ) + raw_con.execute( + """CREATE TABLE exchanges ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + ply_start INT NOT NULL, + ply_end INT NOT NULL, + user_content TEXT NOT NULL, + agent_content TEXT NOT NULL, + distilled_at TIMESTAMP, + distill_status TEXT NOT NULL DEFAULT 'pending', + git_branch TEXT + )""" + ) + raw_con.execute( + "INSERT INTO exchanges VALUES ('ex1', 'conv1', 0, 1, 'user', 'agent', NULL, 'pending', NULL)" + ) + raw_con.execute("PRAGMA user_version = 10") + raw_con.commit() + raw_con.close() + + init_db(db_path) + + con = sqlite3.connect(db_path) + con.row_factory = sqlite3.Row + row = con.execute("SELECT harness FROM exchanges WHERE id='ex1'").fetchone() + con.close() + + assert row["harness"] == "grok" + + def test_backfill_parent_session_ref_populates_subagent_conversations(tmp_path: Path) -> None: """design §2.3・§4.2: 既存 DB のサブエージェント会話にも parent_session_ref を後付けする""" db_path = tmp_path / "memory.db" diff --git a/tests/test_file_renames.py b/tests/test_file_renames.py index 43b5d1d..6afce00 100644 --- a/tests/test_file_renames.py +++ b/tests/test_file_renames.py @@ -148,3 +148,25 @@ def test_resolve_aliases_returns_empty_when_git_unavailable(tmp_path: Path) -> N aliases = resolve_aliases(con, str(non_repo), "whatever.py") assert aliases == [] + + +def test_resolve_aliases_handles_non_ascii_renamed_path(tmp_path: Path) -> None: + """git のデフォルト `core.quotepath=true` は非ASCIIパスを `git log --name-status` + で `"caf\\303\\251.py"` のように8進数エスケープした二重引用符付き文字列で出力する。 + `-c core.quotepath=false` を付けないと resolve_aliases が返す旧パスがエスケープ + されたままで実際のファイル名と一致しない(issue #19)。""" + repo = tmp_path / "repo" + repo.mkdir() + _run(["git", "init", "-q"], repo) + _run(["git", "config", "user.email", "test@example.com"], repo) + _run(["git", "config", "user.name", "test"], repo) + (repo / "café.py").write_text("def f():\n return 1\n", encoding="utf-8") + _run(["git", "add", "."], repo) + _run(["git", "commit", "-q", "-m", "init"], repo) + _run(["git", "mv", "café.py", "new_name.py"], repo) + _run(["git", "commit", "-q", "-m", "rename"], repo) + con = _setup_db(tmp_path) + + aliases = resolve_aliases(con, str(repo), "new_name.py") + + assert aliases == ["café.py"] From 328f40a79d991e813ccac62a6f9d621b18314910 Mon Sep 17 00:00:00 2001 From: senna-lang Date: Mon, 7 Sep 2026 09:21:30 +0900 Subject: [PATCH 2/2] fix(db): repair exchanges already mislabeled by the old buggy v11 (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #47: the previous fix only stops v11 from mislabeling *new* migrations (DBs still below user_version 11). Any DB that already ran the old buggy v11 already has harness='claude' and a claude-derived session_id/canonical_exchange_id written for non-claude conversations. _run_migrations skips v11 for such DBs (already applied per PRAGMA user_version), and _backfill_exchange_provenance only touches rows WHERE session_id IS NULL OR harness IS NULL — which is none of them, since the old v11 already filled both. Those rows stayed permanently mislabeled. Add migration v13 (_migrate_v13_repair_legacy_claude_mislabel): walks every conversation, re-derives the correct harness from source_path via the same _infer_harness_and_source_session_id heuristic, and for exchanges whose stored harness disagrees, rewrites harness/session_id/source_session_id and (when the column already exists) canonical_exchange_id. Also drops the stale claude-hashed sessions row for that conversation once nothing references it anymore (its id is unique per source_path, so this is safe). Naturally idempotent: re-deriving already-correct rows is a no-op, and as a versioned migration it only runs once per DB. Also fixed test_migration_v12_adds_exchange_conversation_ply_index_to_existing_db, which hardcoded `len(_MIGRATIONS) - 1` assuming v12 was the last migration; now targets `_MIGRATIONS.index(_migrate_v12_add_exchange_conversation_ply_index)` so it still isolates v12 regardless of migration count. Verification: - RED: added test_migration_v13_repairs_exchanges_already_mislabeled_by_old_buggy_v11, which builds a DB frozen at user_version=12 with harness='claude' and a claude-derived session_id/canonical_exchange_id already written for a grok conversation (simulating a DB that already ran the pre-fix v11). Confirmed it fails with `assert 'claude' == 'grok'` when v13 is not registered in _MIGRATIONS, and passes once it is. - make check (ruff + pyright + full pytest suite): 664 passed, 0 lint issues, 0 typecheck errors. --- src/codeatrium/db.py | 107 ++++++++++++++++++++++++++++++++++++ tests/test_db.py | 128 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 234 insertions(+), 1 deletion(-) diff --git a/src/codeatrium/db.py b/src/codeatrium/db.py index 575019d..d8d8c40 100644 --- a/src/codeatrium/db.py +++ b/src/codeatrium/db.py @@ -498,6 +498,112 @@ def _migrate_v12_add_exchange_conversation_ply_index(con: sqlite3.Connection) -> ) +def _migrate_v13_repair_legacy_claude_mislabel(con: sqlite3.Connection) -> None: + """Migration v13: repair exchanges the original (buggy) v11 already + mislabeled as harness='claude' before the fix for issue #19 landed. + + The old v11 unconditionally wrote `harness='claude'` and a + `claude:`-derived `session_id`/`canonical_exchange_id` for + every pre-existing conversation, including grok/omp-pi/opencode/codex + ones. Fixing v11 itself (this migration's predecessor) only stops *new* + mislabeling — on a DB that already ran the buggy v11, `user_version` + is already >= 11, so v11 never runs again, and `_backfill_exchange_provenance` + only touches rows `WHERE session_id IS NULL OR harness IS NULL`, which the + old buggy v11 already filled. Those rows stay wrong forever without an + explicit repair pass. + + This walks every conversation, re-derives the correct harness with the + same `_infer_harness_and_source_session_id` heuristic used by v11/backfill, + and — only where an exchange disagrees with that derivation — rewrites + `harness`, `session_id`, `source_session_id`, and (if the column already + exists — i.e. `_backfill_canonical_exchange_ids` already ran against the + stale values) `canonical_exchange_id`. The stale `claude`-hashed `sessions` + row for that conversation is deleted once nothing references it anymore. + Idempotent: re-deriving already-correct rows is a no-op. + """ + conversations_exists = con.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'conversations'" + ).fetchone() + exchanges_exists = con.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'exchanges'" + ).fetchone() + if conversations_exists is None or exchanges_exists is None: + return + + exchange_columns = { + row[1] for row in con.execute("PRAGMA table_info(exchanges)").fetchall() + } + has_canonical_column = "canonical_exchange_id" in exchange_columns + + conversations = con.execute( + "SELECT id, source_path FROM conversations" + ).fetchall() + for conversation in conversations: + source_path = conversation["source_path"] + harness, source_session_id = _infer_harness_and_source_session_id(source_path) + + mislabeled = con.execute( + "SELECT id, source_turn_id FROM exchanges " + "WHERE conversation_id = ? AND harness IS NOT NULL AND harness != ?", + (conversation["id"], harness), + ).fetchall() + if not mislabeled: + continue + + session_id = hashlib.sha256( + f"{harness}:{source_session_id}".encode() + ).hexdigest() + con.execute( + """ + INSERT OR IGNORE INTO sessions + (id, harness, source_session_id, primary_ref, project_key, + cursor_version, updated_at) + VALUES (?, ?, ?, ?, '', 1, CURRENT_TIMESTAMP) + """, + (session_id, harness, source_session_id, source_path), + ) + for exchange in mislabeled: + canonical_exchange_id = None + if has_canonical_column and exchange["source_turn_id"] is not None: + canonical_exchange_id = hashlib.sha256( + f"{harness}:{source_session_id}:{exchange['source_turn_id']}".encode() + ).hexdigest() + if has_canonical_column: + con.execute( + """ + UPDATE exchanges + SET harness = ?, session_id = ?, source_session_id = ?, + canonical_exchange_id = ? + WHERE id = ? + """, + (harness, session_id, source_session_id, canonical_exchange_id, exchange["id"]), + ) + else: + con.execute( + """ + UPDATE exchanges + SET harness = ?, session_id = ?, source_session_id = ? + WHERE id = ? + """, + (harness, session_id, source_session_id, exchange["id"]), + ) + + # The old buggy v11 always hashed `claude:` regardless of + # actual harness. Each conversation's source_path is unique, so this id + # is unique to the row just repaired — safe to drop once unreferenced. + stale_claude_session_id = hashlib.sha256( + f"claude:{source_path}".encode() + ).hexdigest() + con.execute( + """ + DELETE FROM sessions + WHERE id = ? + AND id NOT IN (SELECT DISTINCT session_id FROM exchanges WHERE session_id IS NOT NULL) + """, + (stale_claude_session_id,), + ) + + _MIGRATIONS: list[Callable[[sqlite3.Connection], None]] = [ _migrate_v1_add_last_ply_end, _migrate_v2_add_distill_status, @@ -511,6 +617,7 @@ def _migrate_v12_add_exchange_conversation_ply_index(con: sqlite3.Connection) -> _migrate_v10_add_file_renames, _migrate_v11_add_canonical_sessions, _migrate_v12_add_exchange_conversation_ply_index, + _migrate_v13_repair_legacy_claude_mislabel, ] diff --git a/tests/test_db.py b/tests/test_db.py index 23d5da9..f6b21a9 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -11,6 +11,7 @@ from codeatrium.db import ( _MIGRATIONS, _backfill_touch_time_symbol_edges, + _migrate_v12_add_exchange_conversation_ply_index, check_drift, get_connection, init_db, @@ -1440,7 +1441,8 @@ def test_migration_v12_adds_exchange_conversation_ply_index_to_existing_db(tmp_p init_db(db_path) con = sqlite3.connect(db_path) con.execute("DROP INDEX idx_exchanges_conversation_ply") - con.execute(f"PRAGMA user_version = {len(_MIGRATIONS) - 1}") + v12_index = _MIGRATIONS.index(_migrate_v12_add_exchange_conversation_ply_index) + con.execute(f"PRAGMA user_version = {v12_index}") con.commit() con.close() @@ -1510,6 +1512,130 @@ def test_migration_v11_infers_harness_from_source_path_for_non_claude_sessions( assert row["harness"] == "grok" +def test_migration_v13_repairs_exchanges_already_mislabeled_by_old_buggy_v11( + tmp_path: Path, +) -> None: + """`_migrate_v11_add_canonical_sessions`(現行の修正版)はもう非claude を + 誤ラベルしない。しかし、修正が入る**前**に v11 が既に走っていた DB + (`PRAGMA user_version >= 11`)は、その時点で `harness='claude'` と + claude 由来の `session_id`/`canonical_exchange_id` を永続化済みであり、 + v11 は二度と走らない。`_backfill_exchange_provenance` も + `WHERE session_id IS NULL OR harness IS NULL` にしかヒットしないため、 + これらの行は v11 の修正だけでは直らない(issue #19 レビュー指摘)。 + v13 の修復マイグレーションが、既に `claude` 固定されてしまった行を + source_path から再判定し、harness・session_id・source_session_id・ + canonical_exchange_id を正しい値へ書き換えることを確認する。 + """ + db_path = tmp_path / "memory.db" + grok_path = str(tmp_path / ".grok" / "session.jsonl") + + # 旧バグ版 v11 が実際に書き込んでいた(誤った)値を手で再現する。 + stale_session_id = hashlib.sha256(f"claude:{grok_path}".encode()).hexdigest() + stale_canonical_id = hashlib.sha256( + f"claude:{grok_path}:0".encode() + ).hexdigest() + + raw_con = sqlite3.connect(db_path) + raw_con.execute( + """CREATE TABLE conversations ( + id TEXT PRIMARY KEY, + source_path TEXT NOT NULL UNIQUE, + started_at TIMESTAMP, + last_ply_end INT NOT NULL DEFAULT -1, + parent_session_ref TEXT + )""" + ) + raw_con.execute( + "INSERT INTO conversations(id, source_path) VALUES ('conv1', ?)", + (grok_path,), + ) + raw_con.execute( + """CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + harness TEXT NOT NULL, + source_session_id TEXT NOT NULL, + primary_ref TEXT NOT NULL, + project_key TEXT NOT NULL, + cursor TEXT, + cursor_version INTEGER NOT NULL DEFAULT 1, + started_at TEXT, + title TEXT, + git_branch_last TEXT, + updated_at TEXT NOT NULL, + UNIQUE(harness, source_session_id) + )""" + ) + raw_con.execute( + """INSERT INTO sessions + (id, harness, source_session_id, primary_ref, project_key, updated_at) + VALUES (?, 'claude', ?, ?, '', CURRENT_TIMESTAMP)""", + (stale_session_id, grok_path, grok_path), + ) + raw_con.execute( + """CREATE TABLE exchanges ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + ply_start INT NOT NULL, + ply_end INT NOT NULL, + user_content TEXT NOT NULL, + agent_content TEXT NOT NULL, + distilled_at TIMESTAMP, + distill_status TEXT NOT NULL DEFAULT 'pending', + git_branch TEXT, + session_id TEXT, + harness TEXT, + session_ref TEXT, + source_session_id TEXT, + source_turn_id TEXT, + agent_model TEXT, + agent_provider TEXT, + canonical_exchange_id TEXT + )""" + ) + raw_con.execute( + "CREATE UNIQUE INDEX idx_exchanges_canonical_id ON exchanges(canonical_exchange_id) " + "WHERE canonical_exchange_id IS NOT NULL" + ) + raw_con.execute( + """INSERT INTO exchanges VALUES ( + 'ex1', 'conv1', 0, 1, 'user', 'agent', NULL, 'pending', NULL, + ?, 'claude', ? || '#ply=0-1', ?, '0', NULL, NULL, ? + )""", + (stale_session_id, grok_path, grok_path, stale_canonical_id), + ) + raw_con.execute(f"PRAGMA user_version = {len(_MIGRATIONS) - 1}") + raw_con.commit() + raw_con.close() + + init_db(db_path) + + con = sqlite3.connect(db_path) + con.row_factory = sqlite3.Row + row = con.execute( + "SELECT harness, session_id, source_session_id, canonical_exchange_id " + "FROM exchanges WHERE id='ex1'" + ).fetchone() + stale_session_row = con.execute( + "SELECT 1 FROM sessions WHERE id = ?", (stale_session_id,) + ).fetchone() + correct_session_id = hashlib.sha256(f"grok:{grok_path}".encode()).hexdigest() + corrected_session_row = con.execute( + "SELECT harness, source_session_id FROM sessions WHERE id = ?", + (correct_session_id,), + ).fetchone() + con.close() + + assert row["harness"] == "grok" + assert row["session_id"] == correct_session_id + assert row["source_session_id"] == grok_path + assert row["canonical_exchange_id"] == hashlib.sha256( + f"grok:{grok_path}:0".encode() + ).hexdigest() + assert stale_session_row is None + assert corrected_session_row is not None + assert corrected_session_row["harness"] == "grok" + + def test_backfill_parent_session_ref_populates_subagent_conversations(tmp_path: Path) -> None: """design §2.3・§4.2: 既存 DB のサブエージェント会話にも parent_session_ref を後付けする""" db_path = tmp_path / "memory.db"