"""隔离模型任务在原库断开后,从完整备份续接已批准任务且不重发旧回合。""" from dataclasses import replace from datetime import UTC, datetime, timedelta from decimal import Decimal from pathlib import Path import psycopg import pytest import test_两阶段写手 as 写手测试 from psycopg import sql from psycopg.conninfo import conninfo_to_dict, make_conninfo from muse.任务运行.接口 import 任务预算计划, 角色预算, 预算管理, 额度策略 from muse.共享.调用身份 import 用途 from muse.启动 import 构建默认运行 from muse.基础设施.备份恢复 import 创建备份, 恢复备份 from muse.编排.生成正文 import 发起生成正文 from muse.编排.运行装配 import 推进作者任务 from muse.配置 import 数据库引用 pytestmark = pytest.mark.数据库 生成环境 = 写手测试.生成环境 @pytest.mark.case_id( "NC-w29-29f005", environment="隔离PG", given="显式作者、隔离配置、合成数据与已知发布构建", when="经真实维护、业务或浏览器入口执行并读取持久结果", then=["断源恢复模型任务只补未执行步骤并保持原预算与候选"], contract="docs/系统架构/新版设计/迁移与验收.md#备份与恢复入口", ) def test_断源恢复模型任务只补未执行步骤并保持原预算与候选__29f005( 生成环境, 新空目标库, 隔离数据库URL, tmp_path ): env = 生成环境 config = env["装配"].配置 app = 构建默认运行(config, 计价=写手测试.合成计价()) author = env["作者"] # 第二个装配另有常驻池,必须自己进入生命周期。 with app.生命周期(): original = app.要求正文().读取正文(author, "ch-3") task_id = 发起生成正文( app, author, "model-backup", "new_chapter", work_id="gen-work", chapter_id="ch-3", 配置ID="gen-config", )["task_id"] 预算管理(env["库"], "synthetic").登记策略(额度策略("synthetic", "1", Decimal("20"), 40)) app.任务运行.批准作者预算( task_id, author.作者, 任务预算计划( Decimal("10"), (角色预算("writer", 8, 8, Decimal("1")),), "synthetic-backup-budget", datetime.now(UTC) + timedelta(minutes=10), ), ) budget = app.任务运行.读取作者预算(task_id, author.作者) env["剧本"].extend( 写手测试.正常剧本( [ "雨点敲着铁皮屋顶,林深把退回的信压在掌心。", "他沿跳板走向仓库,门后传来一声应答。", ] ) ) assert 推进作者任务(app, author.作者, task_id)["executed_step"]["step_id"] == "预组装" assert 推进作者任务(app, author.作者, task_id)["executed_step"]["step_id"] == "受限探索" assert len(env["收到"]) == 4 source_steps = app.任务运行.读取任务(task_id).步骤 backup_config = replace(config, 数据库=env["库组"][用途.维护].引用, 运行用途=用途.维护) archive = 创建备份(backup_config, tmp_path / "backup") source_name = conninfo_to_dict(Path(config.数据库.位置).read_text())["dbname"] with psycopg.connect(隔离数据库URL, autocommit=True) as admin: admin.execute( sql.SQL("ALTER DATABASE {} ALLOW_CONNECTIONS false").format( sql.Identifier(source_name) ) ) # 停用本例生成的旧暂存目录,恢复必须使用新目录;供应方凭据仍在受控存储。 raw = Path(config.原文暂存) raw.rename(tmp_path / "offline-original-raw") restored_raw = tmp_path / "restored-raw" restore_config = replace( backup_config, 数据库=新空目标库.工厂.引用, 原文暂存=str(restored_raw), ) restored = 恢复备份(tmp_path / "backup", restore_config, archive["backup_id"]) assert restored["status"] == "verified" and not restored["services_started"] params = conninfo_to_dict(Path(新空目标库.工厂.引用.位置).read_text()) params["user"] = "muse_app" secret = tmp_path / "restored-app.txt" secret.write_text(make_conninfo(**params)) secret.chmod(0o600) fresh = 构建默认运行( replace(config, 数据库=数据库引用("受控存储", str(secret)), 原文暂存=str(restored_raw)), 计价=写手测试.合成计价(), ) with fresh.生命周期(): assert fresh.任务运行.读取作者预算(task_id, author.作者) == budget assert fresh.任务运行.读取任务(task_id).步骤 == source_steps assert len(env["收到"]) == 4 for expected in ("冻结回放", "无工具写作", "检查与候选"): result = 推进作者任务(fresh, author.作者, task_id) assert result["executed_step"]["step_id"] == expected assert result["state"] == "completed" and len(env["收到"]) == 5 final = next(s["result"] for s in result["steps"] if s["step_id"] == "检查与候选") candidate = fresh.要求正文().读取候选(author, final["candidate_id"]) assert candidate["origin"] == "model" and candidate["decision"] == "undecided" assert fresh.要求正文().读取正文(author, "ch-3") == original