Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions docs/architecture/rfcs/complete-state-recovery-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ session 和 effect owner 准入目标后,该 Goal 可以继续未完成工作
新目录并生成隔离审计,但每个结果都必须声明
`execution_authority_granted=false`。

当前备份创建行为保持不变。本 RFC 中没有任何命令可以把字节完整性转换为执行权限。
现有备份 flag、输出路径和 v0 格式继续受支持;第 7 节说明不覆盖的发布与重试边界。
本 RFC 中没有任何命令可以把字节完整性转换为执行权限。

## 2. 问题与动机

Expand Down Expand Up @@ -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 决策。
Expand Down
31 changes: 31 additions & 0 deletions docs/reference/configuration-backup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions loopx/cli_commands/support_control_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion loopx/semantics/project_registry_io_manifest_v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -2191,7 +2191,7 @@
},
{
"site": "loopx/state_backup.py::<module>._registry_goals::direct_json_read:json.loads#1",
"line": 54,
"line": 58,
"column": 15,
"kind": "direct_json_read",
"api": "json.loads",
Expand Down
47 changes: 45 additions & 2 deletions loopx/state_backup.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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 = {}
Expand Down
3 changes: 2 additions & 1 deletion tests/control_plane/test_state_backup_member_integrity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion tests/test_configuration_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading