diff --git a/app/harness/log.py b/app/harness/log.py index d2f54fd..7231abe 100644 --- a/app/harness/log.py +++ b/app/harness/log.py @@ -1,8 +1,9 @@ """HarnessLog:SQLite 薄包装 + RunLogImpl 只读查询端口。 HarnessLog 提供统一的结构化日志接口,从 TRM4 直搬,保留全部线程安全与幂等语义。 +同 run_id 重复创建时通过 ON CONFLICT DO UPDATE 更新 started_at/config/status。 RunLogImpl 实现 core/evolution/protocols.py::RunLog Protocol,用独立连接做只读 SELECT, -不经 HarnessLog 生命周期(不触发 _runs INSERT OR IGNORE),避免污染运行状态。 +不经 HarnessLog 生命周期(不触发 _runs upsert),避免污染运行状态。 """ from __future__ import annotations @@ -41,7 +42,7 @@ class HarnessLog: 关键设计: - WAL 模式 + threading.Lock 保证共享连接下并发安全。 - - INSERT OR IGNORE INTO _runs 保证幂等(同 run_id 多次创建不报错)。 + - ON CONFLICT DO UPDATE 保证幂等(同 run_id 多次创建不报错)并更新 started_at/config/status。 - query 也持锁:共享连接(check_same_thread=False)下并发 SELECT + INSERT 在同一连接上 execute 会损坏游标状态,故读也须串行化。 - context manager 语义:正常退出 completed,异常退出 failed。 @@ -70,9 +71,13 @@ class HarnessLog: resolved_sha = git_sha or _get_git_sha() config_json = json.dumps(config_snapshot, ensure_ascii=False) if config_snapshot else None self._conn.execute( - "INSERT OR IGNORE INTO _runs" + "INSERT INTO _runs" " (run_id, git_sha, started_at, config, status)" - " VALUES (?, ?, ?, ?, ?)", + " VALUES (?, ?, ?, ?, ?)" + " ON CONFLICT(run_id) DO UPDATE SET" + " started_at=excluded.started_at," + " config=excluded.config," + " status=excluded.status", (run_id, resolved_sha, _now_iso(), config_json, "running"), ) self._conn.commit() diff --git a/tests/unit/test_harness_log.py b/tests/unit/test_harness_log.py index ed86871..d98b07e 100644 --- a/tests/unit/test_harness_log.py +++ b/tests/unit/test_harness_log.py @@ -8,6 +8,7 @@ from __future__ import annotations import sqlite3 import threading +import time from typing import TYPE_CHECKING import pytest @@ -118,8 +119,8 @@ class TestHarnessLog: assert row["status"] == "failed" - def test_insert_or_ignore_idempotent(self, db_path: str, run_id: str) -> None: - """同一 run_id 多次创建 HarnessLog 不报错(INSERT OR IGNORE 幂等)。""" + def test_upsert_idempotent(self, db_path: str, run_id: str) -> None: + """同一 run_id 多次创建 HarnessLog 不报错(ON CONFLICT DO UPDATE 幂等)。""" with HarnessLog(db_path, run_id): pass @@ -134,7 +135,7 @@ class TestHarnessLog: ).fetchone()[0] conn.close() - assert count == 1, "INSERT OR IGNORE 应保证 _runs 只有一行" + assert count == 1, "ON CONFLICT DO UPDATE 应保证 _runs 只有一行" def test_wal_mode(self, db_path: str, run_id: str) -> None: """连接初始化后 journal_mode 应为 WAL。""" @@ -195,6 +196,34 @@ class TestHarnessLog: assert rows[0]["val"] == "2" +# =========================================================================== +# HarnessLog upsert 行为测试 +# =========================================================================== + + +class TestHarnessLogUpsert: + """_runs 表 upsert 行为。""" + + def test_same_run_id_updates_started_at(self, tmp_path: Path) -> None: + """同 run_id 第二次创建 HarnessLog 应更新 started_at。""" + db = str(tmp_path / "test.db") + with HarnessLog(db, "run_1", git_sha="abc") as log1: + rows = log1.query("SELECT started_at FROM _runs WHERE run_id='run_1'") + first_time = rows[0]["started_at"] + + time.sleep(0.05) + + with HarnessLog(db, "run_1", git_sha="abc") as log2: + rows = log2.query("SELECT started_at FROM _runs WHERE run_id='run_1'") + second_time = rows[0]["started_at"] + + assert second_time > first_time + + with HarnessLog(db, "run_1") as log3: + rows = log3.query("SELECT COUNT(*) as cnt FROM _runs WHERE run_id='run_1'") + assert rows[0]["cnt"] == 1 + + # =========================================================================== # RunLogImpl 测试 # ===========================================================================