diff --git a/docs/architecture/rfcs/complete-state-recovery-v0.md b/docs/architecture/rfcs/complete-state-recovery-v0.md index a3e23b57f7..2321615007 100644 --- a/docs/architecture/rfcs/complete-state-recovery-v0.md +++ b/docs/architecture/rfcs/complete-state-recovery-v0.md @@ -79,8 +79,9 @@ This RFC makes seven decisions: emit an isolated audit, but every result states `execution_authority_granted=false`. -Current backup creation remains unchanged. No command in this RFC may turn byte -integrity into execution permission. +Existing backup flags, output paths and the v0 format remain supported. The +non-overwriting publication/retry boundary is described in Section 7. No command +in this RFC may turn byte integrity into execution permission. ## 2. Problem and motivation @@ -521,6 +522,18 @@ private evidence where safe; unknown required semantics fail closed. Older binaries do not gain a restore path and cannot open an inert workspace through fallback discovery. +Full-backup publication now refuses occupied archive or manifest names instead +of replacing them independently. It prefers exclusive filesystem links, manifest +before archive, and flushes directory entries on POSIX. When linking is unsupported, +exclusive private-file copy preserves non-overwrite behavior, at the cost of another +copy and possible visibility of an unfinished file. There is no overwrite fallback. An interrupted +attempt can leave a partial pair; a lost response can leave a complete pair. +Neither is silently overwritten or deleted by retry. Verify both files and their +checksum relation, and choose a fresh backup id/output directory for a new capture. +This preserves earlier verification units; it does not qualify an M2 capture +profile or establish cross-owner consistency. See the +[backup publication guide](../../reference/configuration-backup.md#full-backup-publication-and-retries). + Credentials present in an archive remain private historical bytes. Recovery does not claim they are current, copy them into a live credential store, or use them for provider probes. Cross-machine transfer, encryption at rest, revocation, diff --git a/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md b/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md index 712253f715..73c32f81ba 100644 --- a/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md +++ b/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md @@ -64,7 +64,8 @@ session 和 effect owner 准入目标后,该 Goal 可以继续未完成工作 新目录并生成隔离审计,但每个结果都必须声明 `execution_authority_granted=false`。 -当前备份创建行为保持不变。本 RFC 中没有任何命令可以把字节完整性转换为执行权限。 +现有备份 flag、输出路径和 v0 格式继续受支持;第 7 节说明不覆盖的发布与重试边界。 +本 RFC 中没有任何命令可以把字节完整性转换为执行权限。 ## 2. 问题与动机 @@ -451,6 +452,14 @@ path 不变。安全时未知字段保留在私有证据中;未知 required se 旧 binary 不会获得 restore path,也不能通过 fallback discovery 打开 inert workspace。 +完整备份发布现在拒绝已占用的归档或清单名称,不再分别替换两者。优先通过排他 +文件系统硬链接先发布清单、再发布归档,并在 POSIX 上刷写目录项。不支持链接时, +通过排他创建的私有文件复制,保持不覆盖;代价是多一次复制,且可能看到未完成文件。 +不会回退为覆盖。中断可能留下部分文件对,响应丢失也可能留下完整文件对; +重试均不会静默覆盖或删除它们。应验证两个文件及其校验和关系,为新的捕获选择新 +backup id 或输出目录。这保留此前的验证单元,不证明 M2 capture profile 或跨 owner +一致性。参见[备份发布指南](../../reference/configuration-backup.md#full-backup-publication-and-retries)。 + Archive 中的 credential 仍是私有历史字节。恢复不宣称它们当前有效,不把它们 复制到 live credential store,也不使用它们探测 provider。跨机器转移、静态加密、 撤销及目标认证仍是显式 operator/security 决策。 diff --git a/docs/reference/configuration-backup.md b/docs/reference/configuration-backup.md index e7568cf42c..8e94f64697 100644 --- a/docs/reference/configuration-backup.md +++ b/docs/reference/configuration-backup.md @@ -130,6 +130,37 @@ register it as a second live Goal or start a Host using an old grant. Explicit identity/path adoption, pending-effect disposition and authorized reactivation, including their App journey, remain separate acceptance work. +## Full-backup publication and retries + +Full `backup-state --execute` now requires unused archive **and** manifest names. +Unlike the earlier two-file replacement, it never overwrites either existing +file, directory or dangling symlink. Reusing `--backup-id` in the same output +directory fails; omit it for a new timestamp or choose a fresh id/output directory. +A dry-run does not reserve these names. + +The producer publishes the manifest first and archive second, preferring exclusive +links from private staging. If filesystem policy does not support linking, it +copies through exclusively created private files instead; that fallback adds a +file copy and may expose an unfinished file while writing. It never falls back +to replacement. Concurrent producers cannot both publish the same names. +This is not an atomic two-file transaction: +interruption or an I/O error may leave an incomplete pair, and a lost response may +leave a complete pair. Preserve those files and verify that both exist and the +archive checksum matches the manifest before using them. A retry never deletes +partial output or treats its presence as success; use fresh names for a new capture. +Earlier backup pairs remain untouched. + +This protects captured bytes and their verification identity. It does not provide +cross-owner capture consistency, recover volatile work, or reactivate a Goal. + +中文:完整备份现在拒绝复用已占用的归档或清单文件名,不再覆盖旧文件。预览不保留 +名称;执行以排他方式先发布清单、再发布归档,优先使用硬链接;不支持链接时,通过 +排他创建的私有文件复制,多一次文件复制,写入中可能看到未完成文件,但不会覆盖。 +并发同名备份至多一个成功。中断可能留下不完整文件对,响应丢失也可能留下完整文件对; +应保留现有文件,先检查两者及归档校验和,再使用新的备份 id 或输出目录重新捕获。 +命令不会自动删除部分输出,也不会因文件存在就宣称成功。这不证明跨 owner 一致性 +或恢复后的执行权限。 + ## Privacy, consistency and limits Backups are **private by default**. Full Goal rows can contain private paths, diff --git a/loopx/cli_commands/support_control_backup.py b/loopx/cli_commands/support_control_backup.py index f584e18456..4ffabeaad2 100644 --- a/loopx/cli_commands/support_control_backup.py +++ b/loopx/cli_commands/support_control_backup.py @@ -45,7 +45,7 @@ def register_backup_state_command( ) parser.add_argument( "--backup-id", - help="Stable id for the archive name. Defaults to a UTC timestamp.", + help="Id for a new backup. Defaults to a UTC timestamp; existing output names are never replaced.", ) parser.add_argument( "--no-automations", @@ -102,7 +102,7 @@ def handle_backup_state_command( "dry_run": not bool(getattr(args, "execute", False)), "execute_requested": bool(getattr(args, "execute", False)), "error": str(exc), - "recommended_action": "fix backup planning before retrying", + "recommended_action": "fix the reported backup error before retrying; preserve existing archive and manifest files", } print_payload(payload, output_format(args), render_state_backup_markdown) return 0 if payload.get("ok") else 1 diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index f11803304e..1551a3a3d8 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -2191,7 +2191,7 @@ }, { "site": "loopx/state_backup.py::._registry_goals::direct_json_read:json.loads#1", - "line": 54, + "line": 58, "column": 15, "kind": "direct_json_read", "api": "json.loads", diff --git a/loopx/state_backup.py b/loopx/state_backup.py index d35f6dab38..eb34869f1d 100644 --- a/loopx/state_backup.py +++ b/loopx/state_backup.py @@ -1,19 +1,23 @@ from __future__ import annotations from datetime import datetime, timezone +import errno import hashlib import io import json import os from pathlib import Path import re +import shutil import tarfile import tempfile from typing import Any, Protocol from . import __version__ from .paths import _is_redirected_path, select_default_runtime_root +from .presentation.markdown import markdown_scalar from .control_plane.effect_runtime import effect_runtime_result +from .control_plane.runtime.document_io import fsync_state_directory STATE_BACKUP_SCHEMA_VERSION = "loopx_state_backup_v0" @@ -541,6 +545,28 @@ def _sha256_file(path: Path) -> str: return digest.hexdigest() +def _publish_backup_file(source: Path, destination: Path) -> None: + """Create a new name, retaining exclusive creation on link-less filesystems.""" + try: + os.link(source, destination) + except OSError as exc: + if exc.errno not in (errno.ENOTSUP, errno.EOPNOTSUPP, errno.ENOSYS, errno.EPERM): + raise + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0) + with os.fdopen(os.open(destination, flags, 0o600), "wb") as output: + with source.open("rb") as input_stream: + shutil.copyfileobj(input_stream, output) + output.flush() + os.fsync(output.fileno()) + fsync_state_directory(destination) + + +def _backup_name_conflict(path: Path) -> FileExistsError: + return FileExistsError(errno.EEXIST, + "backup output already exists; preserve existing files and choose " + "a new --backup-id or --output-dir", str(path)) + + def execute_state_backup_plan(payload: dict[str, Any]) -> dict[str, Any]: archive_path = Path(str(payload["archive_path"])).expanduser() manifest_path = Path(str(payload["manifest_path"])).expanduser() @@ -553,6 +579,9 @@ def execute_state_backup_plan(payload: dict[str, Any]) -> dict[str, Any]: updated["recommended_action"] = "no backup targets found; nothing was written" return updated + for path in (archive_path, manifest_path): + if os.path.lexists(path): + raise _backup_name_conflict(path) output_dir.mkdir(parents=True, exist_ok=True) exclude_roots = [_resolved(output_dir)] updated = dict(payload) @@ -623,12 +652,26 @@ def execute_state_backup_plan(payload: dict[str, Any]) -> dict[str, Any]: # the durability flush. with path.open("rb+") as handle: os.fsync(handle.fileno()) - os.replace(staged_archive, archive_path) - os.replace(staged_manifest, manifest_path) + # New names only: the preflight cannot reserve them against another + # producer. Prefer links to avoid copying; the fallback also creates + # exclusively. Publish the manifest first, then the archive. + # A crash may leave an incomplete pair; never delete an output path + # that another process could have replaced, or silently retry over it. + for staged, destination in ((staged_manifest, manifest_path), (staged_archive, archive_path)): + try: + _publish_backup_file(staged, destination) + except FileExistsError as exc: + raise _backup_name_conflict(destination) from exc return updated def render_state_backup_markdown(payload: dict[str, Any]) -> str: + if payload.get("error"): + return "\n".join([ + "# LoopX State Backup", "", f"- OK: `{payload.get('ok')}`", + f"- Error: {markdown_scalar(payload['error'])}", + f"- Recommended action: {markdown_scalar(payload.get('recommended_action'))}", + ]) + "\n" summary = payload.get("summary") if not isinstance(summary, dict): summary = {} diff --git a/tests/control_plane/test_state_backup_member_integrity.py b/tests/control_plane/test_state_backup_member_integrity.py index 6f2abaa848..e033da307d 100644 --- a/tests/control_plane/test_state_backup_member_integrity.py +++ b/tests/control_plane/test_state_backup_member_integrity.py @@ -115,7 +115,8 @@ def test_short_source_read_cannot_publish_partial_inventory_or_replace_backup(tm payload, state = plan(tmp_path) output = Path(payload["output_dir"]) output.mkdir() - archive, manifest = Path(payload["archive_path"]), Path(payload["manifest_path"]) + archive = Path(payload["archive_path"]).with_name("previous.tar.gz") + manifest = Path(payload["manifest_path"]).with_name("previous.manifest.json") archive.write_bytes(b"previous archive") manifest.write_bytes(b"previous manifest") addfile = tarfile.TarFile.addfile diff --git a/tests/test_configuration_backup.py b/tests/test_configuration_backup.py index f255ed1fda..078f7f65d5 100644 --- a/tests/test_configuration_backup.py +++ b/tests/test_configuration_backup.py @@ -121,7 +121,8 @@ def test_lossy_source_values_abort_without_replacing_previous_backup(environment plan = build_state_backup_plan(project=source.parent.parent, runtime_root=runtime, output_dir=tmp_path / "previous-backup", include_skills=False, include_automations=False) from pathlib import Path - archive, manifest = Path(plan["archive_path"]), Path(plan["manifest_path"]) + archive = Path(plan["archive_path"]).with_name("previous.tar.gz") + manifest = Path(plan["manifest_path"]).with_name("previous.manifest.json") archive.parent.mkdir() archive.write_bytes(b"previous verified archive") manifest.write_bytes(b"previous verified manifest") diff --git a/tests/test_state_backup.py b/tests/test_state_backup.py index d45741816f..b43eabb6b2 100644 --- a/tests/test_state_backup.py +++ b/tests/test_state_backup.py @@ -1,5 +1,6 @@ from __future__ import annotations +import errno import json import hashlib import os @@ -12,6 +13,7 @@ import pytest +import loopx.state_backup as state_backup from loopx.control_plane import effect_runtime from loopx.state_backup import build_state_backup_plan, execute_state_backup_plan @@ -142,7 +144,8 @@ def test_invalid_sqlite_fails_without_replacing_previous_backup(tmp_path): runtime.mkdir() (runtime / "history.data").write_bytes(b"SQLite format 3\x00" + b"invalid" * 100) plan = backup_plan(tmp_path) - archive, manifest = Path(plan["archive_path"]), Path(plan["manifest_path"]) + archive = Path(plan["archive_path"]).with_name("previous.tar.gz") + manifest = Path(plan["manifest_path"]).with_name("previous.manifest.json") archive.parent.mkdir() archive.write_bytes(b"previous verified archive") manifest.write_bytes(b"previous manifest") @@ -177,3 +180,222 @@ def test_real_cli_backs_up_committed_wal_with_qualified_ts_runtime(tmp_path): restore(payload, tmp_path / "restored") with sqlite3.connect(tmp_path / "restored/runtime-root/authority.sqlite") as copy: assert copy.execute("SELECT * FROM receipts").fetchall() == expected + + +def test_failed_republication_preserves_the_original_verified_pair(tmp_path, monkeypatch): + runtime = tmp_path / "runtime" + runtime.mkdir() + source = runtime / "progress.txt" + source.write_text("original accepted progress") + plan = backup_plan(tmp_path) + execute_state_backup_plan(plan) + archive, manifest = Path(plan["archive_path"]), Path(plan["manifest_path"]) + original = (archive.read_bytes(), manifest.read_bytes()) + source.write_text("later unfinished work") + replace = state_backup.os.replace + + def fail_manifest(src, dst): + if Path(dst) == manifest: + raise PermissionError("injected manifest publication failure") + return replace(src, dst) + + monkeypatch.setattr(state_backup.os, "replace", fail_manifest) + with pytest.raises(OSError): + execute_state_backup_plan(plan) + assert (archive.read_bytes(), manifest.read_bytes()) == original + assert hashlib.sha256(archive.read_bytes()).hexdigest() == json.loads( + manifest.read_text())["execution"]["archive_sha256"] + + +def _backup_cli(root: Path) -> list[str]: + return [sys.executable, "-I", "-m", "loopx.cli", "--format", "json", + "--runtime-root", str(root / "runtime"), "backup-state", + "--project", str(root / "project"), "--output-dir", str(root / "backups"), + "--backup-id", "exclusive", "--no-automations", "--no-skills", + "--current-project-only", "--execute"] + + +def test_native_cli_refuses_a_reused_backup_id_and_keeps_original_data(tmp_path): + runtime = tmp_path / "runtime" + runtime.mkdir() + source = runtime / "progress.txt" + source.write_text("accepted original") + first = subprocess.run(_backup_cli(tmp_path), cwd=tmp_path, capture_output=True, text=True, check=True) + payload = json.loads(first.stdout) + archive, manifest = Path(payload["archive_path"]), Path(payload["manifest_path"]) + original = (archive.read_bytes(), manifest.read_bytes()) + source.write_text("new work must use another backup identity") + retry = subprocess.run(_backup_cli(tmp_path), cwd=tmp_path, capture_output=True, text=True) + assert retry.returncode == 1, retry.stdout + assert json.loads(retry.stdout)["ok"] is False + assert "backup-id" in json.loads(retry.stdout)["error"] + markdown_command = _backup_cli(tmp_path) + markdown_command[markdown_command.index("json")] = "markdown" + markdown = subprocess.run(markdown_command, cwd=tmp_path, capture_output=True, text=True) + assert markdown.returncode == 1 + assert "backup output already exists" in markdown.stdout + assert "--backup-id" in markdown.stdout and archive.name in markdown.stdout + assert "None" not in markdown.stdout + assert (archive.read_bytes(), manifest.read_bytes()) == original + restore(payload, tmp_path / "readback") + assert (tmp_path / "readback/runtime-root/progress.txt").read_text() == "accepted original" + + +def test_concurrent_native_backups_publish_one_matching_pair(tmp_path): + runtime = tmp_path / "runtime" + runtime.mkdir() + (runtime / "progress.txt").write_text("one immutable recovery unit") + workers = [subprocess.Popen(_backup_cli(tmp_path), cwd=tmp_path, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + for _ in range(2)] + results = [] + try: + for worker in workers: + stdout, stderr = worker.communicate(timeout=60) + results.append((worker.returncode, json.loads(stdout), stderr)) + finally: + for worker in workers: + if worker.poll() is None: + worker.kill() + worker.communicate(timeout=10) + assert sorted(code for code, _, _ in results) == [0, 1], results + payload = next(result for code, result, _ in results if code == 0) + archive, manifest = Path(payload["archive_path"]), Path(payload["manifest_path"]) + assert json.loads(manifest.read_text()) == payload + assert hashlib.sha256(archive.read_bytes()).hexdigest() == payload["execution"]["archive_sha256"] + assert sorted(path.name for path in archive.parent.iterdir()) == sorted([archive.name, manifest.name]) + + +@pytest.mark.parametrize("failed_member", ["archive", "manifest"]) +def test_new_publication_failure_preserves_prior_backups_and_allows_a_fresh_id( + tmp_path, monkeypatch, failed_member, +): + runtime = tmp_path / "runtime" + runtime.mkdir() + (runtime / "progress.txt").write_text("retained progress") + previous = execute_state_backup_plan(backup_plan(tmp_path, "previous")) + old_paths = [Path(previous[key]) for key in ("archive_path", "manifest_path")] + old_bytes = [path.read_bytes() for path in old_paths] + plan = backup_plan(tmp_path) + archive, manifest = Path(plan["archive_path"]), Path(plan["manifest_path"]) + target = archive if failed_member == "archive" else manifest + link = state_backup.os.link + + def fail_publication(source, destination, **kwargs): + if Path(destination) == target: + raise PermissionError("injected exclusive publication failure") + return link(source, destination, **kwargs) + + with monkeypatch.context() as failure: + failure.setattr(state_backup.os, "link", fail_publication) + with pytest.raises(PermissionError, match="publication failure"): + execute_state_backup_plan(plan) + assert [path.read_bytes() for path in old_paths] == old_bytes + assert not archive.exists() + assert manifest.exists() is (failed_member == "archive") + if manifest.exists(): + partial = manifest.read_bytes() + with pytest.raises(FileExistsError, match="backup-id"): + execute_state_backup_plan(plan) + assert manifest.read_bytes() == partial + retried = execute_state_backup_plan(backup_plan(tmp_path, "fresh-retry")) + restore(retried, tmp_path / "readback") + assert (tmp_path / "readback/runtime-root/progress.txt").read_text() == "retained progress" + + +@pytest.mark.parametrize("last_published", ["manifest.json", "tar.gz"]) +def test_process_exit_during_publication_preserves_prior_units(tmp_path, last_published): + runtime = tmp_path / "runtime" + runtime.mkdir() + (runtime / "progress.txt").write_text("durable input") + previous = execute_state_backup_plan(backup_plan(tmp_path, "previous")) + old_paths = [Path(previous[key]) for key in ("archive_path", "manifest_path")] + old_bytes = [path.read_bytes() for path in old_paths] + interruption = """ +import os, runpy, sys +suffix = sys.argv.pop(1) +link = os.link +def publish(source, destination, **kwargs): + link(source, destination, **kwargs) + if str(destination).endswith(suffix): + os._exit(86) +os.link = publish +runpy.run_module('loopx.cli', run_name='__main__') +""" + command = _backup_cli(tmp_path) + interrupted = subprocess.run([sys.executable, "-I", "-c", interruption, + last_published, *command[4:]], cwd=tmp_path, + capture_output=True, text=True, timeout=60) + assert interrupted.returncode == 86, interrupted.stdout + interrupted.stderr + archive = tmp_path / "backups/loopx-state-exclusive.tar.gz" + manifest = tmp_path / "backups/loopx-state-exclusive.manifest.json" + assert manifest.is_file() + assert archive.exists() is (last_published == "tar.gz") + if archive.exists(): + payload = json.loads(manifest.read_text()) + assert hashlib.sha256(archive.read_bytes()).hexdigest() == payload["execution"]["archive_sha256"] + restore(payload, tmp_path / "readback") + assert (tmp_path / "readback/runtime-root/progress.txt").read_text() == "durable input" + retained_manifest = manifest.read_bytes() + retry = subprocess.run(command, cwd=tmp_path, capture_output=True, text=True, timeout=60) + assert retry.returncode == 1 and "backup-id" in json.loads(retry.stdout)["error"] + assert manifest.read_bytes() == retained_manifest + assert [path.read_bytes() for path in old_paths] == old_bytes + + +def test_linkless_filesystem_uses_exclusive_private_copy(tmp_path, monkeypatch): + runtime = tmp_path / "runtime" + runtime.mkdir() + original = b"\x00\r\n\xff" * 32768 + (runtime / "progress.data").write_bytes(original) + + def unsupported(*args, **kwargs): + raise OSError(errno.EOPNOTSUPP, "filesystem has no hard links") + + monkeypatch.setattr(state_backup.os, "link", unsupported) + plan = backup_plan(tmp_path) + payload = execute_state_backup_plan(plan) + archive, manifest = Path(payload["archive_path"]), Path(payload["manifest_path"]) + saved = (archive.read_bytes(), manifest.read_bytes()) + assert hashlib.sha256(saved[0]).hexdigest() == json.loads(saved[1])["execution"]["archive_sha256"] + restore(payload, tmp_path / "readback") + assert (tmp_path / "readback/runtime-root/progress.data").read_bytes() == original + if os.name == "posix": + assert archive.stat().st_mode & 0o777 == manifest.stat().st_mode & 0o777 == 0o600 + with pytest.raises(FileExistsError, match="backup-id"): + execute_state_backup_plan(plan) + assert (archive.read_bytes(), manifest.read_bytes()) == saved + + +@pytest.mark.parametrize("occupied_member", ["archive_path", "manifest_path"]) +def test_linkless_publication_refuses_names_occupied_after_preflight(tmp_path, monkeypatch, occupied_member): + runtime = tmp_path / "runtime" + runtime.mkdir() + (runtime / "progress.txt").write_text("private staged source") + plan = backup_plan(tmp_path) + occupied = Path(plan[occupied_member]) + + def occupied_before_copy(source, destination): + if Path(destination) == occupied: + occupied.write_bytes(b"other writer output") + raise OSError(errno.EOPNOTSUPP, "filesystem has no hard links") + + monkeypatch.setattr(state_backup.os, "link", occupied_before_copy) + with pytest.raises(FileExistsError, match="backup-id"): + execute_state_backup_plan(plan) + assert occupied.read_bytes() == b"other writer output" + + +def test_dangling_manifest_is_occupied_and_is_not_replaced(tmp_path): + runtime = tmp_path / "runtime" + runtime.mkdir() + (runtime / "progress.txt").write_text("preserve existing output names") + plan = backup_plan(tmp_path) + manifest = Path(plan["manifest_path"]) + manifest.parent.mkdir() + target = tmp_path / "absent-owner-file" + manifest.symlink_to(target) + with pytest.raises(FileExistsError, match="backup-id"): + execute_state_backup_plan(plan) + assert manifest.is_symlink() and manifest.readlink() == target + assert not target.exists() and not Path(plan["archive_path"]).exists()