diff --git a/cloud/attempt_ledger.py b/cloud/attempt_ledger.py index 3b5fc9d..5df2b94 100644 --- a/cloud/attempt_ledger.py +++ b/cloud/attempt_ledger.py @@ -168,11 +168,52 @@ OLD_TABLE_ALTER = ( ) +def _existing_columns(conn, table): + """已有列集合(automations.py 同款口径:SQLite 走 PRAGMA, + 其它后端异常时回空集、后续 ALTER 逐条 try/except 兜底)。""" + try: + return {row[1] for row in conn.execute( + "PRAGMA table_info(%s)" % table).fetchall()} + except Exception: + return set() + + def ensure_task_attempts_table(conn): """Create the ledger table if absent. Idempotent; does not touch existing rows. The caller owns the commit (``record_attempt`` - commits; a bare ensure inside a larger transaction does not).""" + commits; a bare ensure inside a larger transaction does not). + + 演练注 FLAG(合入语义取舍点,主结论见演练报告): + main 侧 init_db/worker 用 cloud.attempts.py 的旧 DDL 建同名 + task_attempts 表(kind/interrupted_reason/checkpoint_round/ + result_text/error_text,无 outcome 系列);本模块的 CREATE + TABLE IF NOT EXISTS 在旧表已存在时是空操作,之后按台账 + 列写行即报 no column named outcome。本解法把表升级为两 + 组列的并集:旧表先建时补台账 4 列,台账表先建时补旧 5 + 列(新增列一律可空/带默认值,绝不对已有行加 NOT NULL, + 否则旧行会挡住 ALTER)。两边读路径均按列名取数,互不 + 干扰;但这只是演练期保守解法——真合前须用户定:并集表、 + 台账改名、或统一 schema 三选一,不可悄悄定为并集表。 + """ conn.execute(SQLITE_DDL) + existing = _existing_columns(conn, "task_attempts") + for col, ddl in ( + ("outcome", "TEXT"), + ("is_defect", "INTEGER DEFAULT 0"), + ("defect_category", "TEXT"), + ("corrects_attempt_no", "INTEGER"), + ("kind", "TEXT"), + ("interrupted_reason", "TEXT"), + ("checkpoint_round", "INTEGER"), + ("result_text", "TEXT"), + ("error_text", "TEXT")): + if col not in existing: + try: + conn.execute( + "ALTER TABLE task_attempts ADD COLUMN %s %s" + % (col, ddl)) + except Exception: + pass def record_attempt(conn, task_id, outcome, is_defect=False, diff --git a/cloud/tests/test_attempt_hook.py b/cloud/tests/test_attempt_hook.py index 060e7d8..6f6c922 100644 --- a/cloud/tests/test_attempt_hook.py +++ b/cloud/tests/test_attempt_hook.py @@ -149,6 +149,19 @@ def _table_exists(db_path, table): return row[0] == 1 +def _task_attempts_row_count(db_path): + """task_attempts 行数(表不存在时计 0;COUNT(*) 与列集无关, + 旧 DDL 表与并集表均可查)。""" + conn = sqlite3.connect(db_path) + try: + if not _table_exists(db_path, "task_attempts"): + return 0 + return conn.execute( + "SELECT COUNT(*) FROM task_attempts").fetchone()[0] + finally: + conn.close() + + def _completed_runner(cmd): """Fake CLI: dispatch/run always succeed, host.py-style JSON.""" if "dispatch" in cmd: @@ -466,7 +479,11 @@ class TestWorkerHook(unittest.TestCase): self.assertFalse(worker.attempt_ledger_hook) result = worker.run_one() self.assertEqual(result["status"], "completed") - self.assertFalse(_table_exists(self.db_path, "task_attempts")) + # 演练注:main 侧 init_db 已先建同名旧 DDL 的 + # task_attempts 表(见 attempt_ledger 并集表说明), + # “表不存在”在合入后不再是可观测口径;保留的意图是 + # “关时不写行”,故此处断言零行而非表不存在。 + self.assertEqual(_task_attempts_row_count(self.db_path), 0) self.assertFalse( _table_exists(self.db_path, "task_attempt_origin_markers")) @@ -559,7 +576,9 @@ class TestWorkerHook(unittest.TestCase): worker = self._worker(attempt_ledger_hook=True, env={}) result = worker.run_one() self.assertEqual(result["status"], "failed") - self.assertFalse(_table_exists(self.db_path, "task_attempts")) + # 演练注:同上,门失败不调钩子、故零行;表本身由 + # main 侧 init_db 预建,不再断言表不存在。 + self.assertEqual(_task_attempts_row_count(self.db_path), 0) def test_hook_on_cancelled_dispatch_writes_interrupted_row(self): # Established pattern (test_worker diff --git a/cloud/tests/test_audit_log_single_writer.py b/cloud/tests/test_audit_log_single_writer.py index 93b0c07..3de402f 100644 --- a/cloud/tests/test_audit_log_single_writer.py +++ b/cloud/tests/test_audit_log_single_writer.py @@ -43,10 +43,25 @@ from pathlib import Path _PATTERN = re.compile(r"INSERT\s+INTO\s+audit_log", re.IGNORECASE) # Fixture files that deliberately insert audit_log rows into # throwaway test databases (see module docstring). +# 演练注:合入时 main 侧 9 个审计测试文件同样只往临时库插 +# audit_log 行(TemporaryDirectory/control.db,逐文件核过), +# 与已列 3 个属同类 fixture,按名补入——生产侧口径不变, +# 仍只允许 cloud/auth.py。若用户要更严口径(测试也一律走 +# AuthStore.audit),那是另起一批改 9 个文件的活,本演练 +# 不悄悄替用户定(见演练报告 FLAG)。 _FIXTURE_FILES = { "cloud/tests/test_tamper_ledger.py", "cloud/tests/test_chat_history.py", "cloud/tests/test_automation_run_due.py", + "cloud/tests/test_95_code.py", + "cloud/tests/test_audit_batch4.py", + "cloud/tests/test_audit_batch5.py", + "cloud/tests/test_audit_batch6.py", + "cloud/tests/test_audit_fixes4.py", + "cloud/tests/test_audit_fixes5.py", + "cloud/tests/test_audit_loop_coord.py", + "cloud/tests/test_audit_loop_coord_fix.py", + "cloud/tests/test_routing_shadow.py", } diff --git a/cloud/tests/test_v2cov_gaps_d2.py b/cloud/tests/test_v2cov_gaps_d2.py index d9984ac..3ffc67f 100644 --- a/cloud/tests/test_v2cov_gaps_d2.py +++ b/cloud/tests/test_v2cov_gaps_d2.py @@ -374,6 +374,18 @@ class GatewayTestCase(_Base): # 2564-2565 class _BadEnv: def get(self, *a, **k): + # 演练注:main 侧 Worker.__init__ 新增路由策略 + # 环境读取(CLOUD_ROUTING_POLICY_FILE/JSON, + # 未包在 try 内);假 env 若全键抛错,构造即 + # 炸、测不到 key_available 本身。故仅对路由 + # 策略键回 None(= 未配策略),其余键仍抛错, + # key_available 的失败回 False 断言意图不变。 + # 另:__init__ 两处裸 env.get 是否应包 try, + # 属 main 侧跟进项,本演练不改生产 __init__。 + key = a[0] if a else k.get("key") + if key in ("CLOUD_ROUTING_POLICY_FILE", + "CLOUD_ROUTING_POLICY_JSON"): + return None raise RuntimeError("env down") w = Worker(self.db_path, "/fake/host.py", env=_BadEnv())