From db328efd2191dd88a14fbc00fb0a7d36951a6e1e Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 19 Oct 2026 12:07:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8E=9F=E5=AD=90=E5=86=99=E5=92=8C=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E8=84=9A=E6=9C=AC=E7=9A=84=E5=87=A0=E5=A4=84=EF=BC=88?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=20R-a11=E3=80=81R-a12=E3=80=81R-a13=20?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E5=86=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R-a12 create_bytes:硬链接不了的文件系统(exFAT、一部分 SMB / FUSE,EPERM / ENOTSUP / EXDEV)不再首次启动就崩,退回 O_CREAT | O_EXCL 直接建(仍然只建一次,说明里写了写的那一会儿读的一方可能读到不完整);建成后 fsync 目录(同 raw_store) - R-a13 file_write 的追加改回原地追加写(O_APPEND,core/atomic.append_bytes):不换文件(inode、权限、开着的句柄不变),原有内容不动,文件大了也不用整份读出来重写;取舍写在函数说明里——读的一方最多看到半截新内容。覆盖照旧先写临时文件再改名,并沿用原文件的权限 - R-a11 deploy.sh 改属主前认数据目录收紧:只认 agentlab.db、.secret_key、.agentlab-data 三样之一(不再认 uploads/ 这种普通目录名);.agentlab-data 是服务启动时(Settings.ensure_dirs)建的标记,只建一次不覆盖,库在 PostgreSQL、密钥由环境变量给的部署也认得出;老部署有 agentlab.db 或 .secret_key。deploy/README 写明这个文件别删 - 测试:模拟不能硬链接仍只建一次、权限 0600、不留临时文件;覆盖沿用原权限;追加不换 inode、权限不变;file_write 追加不换文件、覆盖照常;数据目录里有标记、重复启动不覆盖 --- backend/app/core/atomic.py | 67 +++++++++++++++++++++++++++-- backend/app/core/config.py | 14 ++++++ backend/app/tools/builtin/files.py | 10 +++-- backend/tests/test_atomic_writes.py | 55 ++++++++++++++++++++++- deploy/README.md | 3 +- deploy/deploy.sh | 7 +-- 6 files changed, 142 insertions(+), 14 deletions(-) diff --git a/backend/app/core/atomic.py b/backend/app/core/atomic.py index 732ef089..8a3eb8e4 100644 --- a/backend/app/core/atomic.py +++ b/backend/app/core/atomic.py @@ -4,7 +4,8 @@ 看不到写了一半的;进程写到一半没了,留下的是一个没人认的临时文件,不是半个正式文件。临时文件名带随机后缀: 两个进程同时写同一个文件(内容寻址的工件撞了同一份内容)各写各的,不会一个把另一个写到一半的截断。 -- write_bytes / write_text:写完 os.replace 到目标(目标已有就替换); +- write_bytes / write_text:写完 os.replace 到目标(目标已有就替换,沿用它的权限); +- append_bytes:追加写,不走改名(理由见函数说明); - create_bytes / create_text:只在目标还没有时建(os.link,已有就不动、返回 False)——给只该生成一次的东西用 (加密密钥、签名私钥):两个进程同时第一次启动,后到的读先到的那一份,不会各拿一把。 @@ -47,8 +48,25 @@ def _write_tmp(path: Path, data: bytes, *, mode: int | None, fsync: bool) -> Pat return tmp +def fsync_dir(folder: Path) -> None: + """把目录项(新建、改名)落盘。有的平台、文件系统打不开目录或不支持,就算了(同 data/raw_store.fsync_dir)。""" + try: + fd = os.open(folder, os.O_RDONLY) + except OSError: + return + try: + os.fsync(fd) + except OSError: + pass + finally: + os.close(fd) + + def write_bytes(path: Path, data: bytes, *, mode: int | None = None, fsync: bool = False) -> None: - """原子地写(替换)path。""" + """原子地写(替换)path。没给 mode、原来已有这个文件时沿用它的权限(改名换掉的是文件本身,权限不跟着走)。""" + if mode is None: + with contextlib.suppress(OSError): + mode = path.stat().st_mode & 0o7777 tmp = _write_tmp(path, data, mode=mode, fsync=fsync) try: os.replace(tmp, path) @@ -64,16 +82,57 @@ def write_text(path: Path, text: str, *, encoding: str = "utf-8", mode: int | No def create_bytes(path: Path, data: bytes, *, mode: int | None = None, fsync: bool = True) -> bool: - """path 还没有时原子地建出来,返回 True;已经有了(别人先建了)什么都不动,返回 False。""" + """path 还没有时原子地建出来,返回 True;已经有了(别人先建了)什么都不动,返回 False。建成后把目录项也落盘。 + + 硬链接不了的文件系统(exFAT、一部分 SMB / FUSE 挂载报 EPERM、ENOTSUP、EXDEV)退回 O_CREAT | O_EXCL 直接建目标文件: + 仍然只有一个进程建得成,但写的那一小会儿别的进程可能读到不完整的内容——只在头一次生成时有这个窗口。""" tmp = _write_tmp(path, data, mode=mode, fsync=fsync) try: os.link(tmp, path) - return True except FileExistsError: return False + except OSError: + return _create_directly(path, data, mode=mode, fsync=fsync) finally: with contextlib.suppress(OSError): tmp.unlink() + fsync_dir(path.parent) + return True + + +def _create_directly(path: Path, data: bytes, *, mode: int | None, fsync: bool) -> bool: + try: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666 if mode is None else 0o600) + except FileExistsError: + return False + try: + with os.fdopen(fd, "wb") as fh: + fh.write(data) + fh.flush() + if fsync: + os.fsync(fh.fileno()) + if mode is not None: + os.chmod(path, mode) + except BaseException: + with contextlib.suppress(OSError): + path.unlink() + raise + fsync_dir(path.parent) + return True + + +def append_bytes(path: Path, data: bytes, *, fsync: bool = False) -> None: + """追加写(O_APPEND):不换文件(inode、权限、别人手里开着的句柄都不变),原有内容一个字节都不动。 + + 不走「临时文件再改名」:那得把整份读出来重写,文件越大越慢,还会换掉 inode 和权限。代价是追加到一半时读的一方可能 + 看到半截新内容——它看到的永远是「原有的整份 + 新内容的一个前缀」,原有的内容不会丢。""" + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o666) + with os.fdopen(fd, "ab") as fh: + fh.write(data) + fh.flush() + if fsync: + os.fsync(fh.fileno()) def create_text(path: Path, text: str, *, encoding: str = "utf-8", mode: int | None = None) -> bool: diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2ddb6629..6029d133 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -31,6 +31,10 @@ def sanitize_session(session_id: str | None) -> str: return safe[:_SESSION_MAX] or "default" +#: 数据目录里的标记文件(Settings.ensure_dirs 建,deploy/deploy.sh 认) +DATA_DIR_MARKER = ".agentlab-data" + + class Settings(BaseSettings): """全局配置。所有项都可以用 AGENTLAB_ 前缀的环境变量覆盖。""" @@ -302,6 +306,16 @@ class Settings(BaseSettings): def ensure_dirs(self) -> None: for d in (self.data_dir, self.workspace_dir, self.uploads_dir): d.mkdir(parents=True, exist_ok=True) + # 认得出「这是 AgentLab 的数据目录」的标记:应用库放在 PostgreSQL 时这里没有 agentlab.db,设了 AGENTLAB_SECRET_KEY + # 时也没有 .secret_key。deploy.sh 改属主前按它认,免得 DATA_DIR 写错把别人的目录 chown 了。只建一次,不覆盖 + marker = self.data_dir / DATA_DIR_MARKER + if not marker.exists(): + from app.core import atomic + + try: + atomic.create_text(marker, "AgentLab 的数据目录(deploy.sh 按这个文件认,别删)\n") + except OSError: + pass @lru_cache diff --git a/backend/app/tools/builtin/files.py b/backend/app/tools/builtin/files.py index 87c35cae..cd988987 100644 --- a/backend/app/tools/builtin/files.py +++ b/backend/app/tools/builtin/files.py @@ -75,10 +75,12 @@ async def file_write(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]: target = _resolve(args.path, ctx) except ValueError as e: return {"error": str(e)} - # 先写临时文件再改名(core/atomic):同一个工作目录里正在跑的代码、另一台机器上读共享数据目录的一方,看到的要么是 - # 旧的整份、要么是新的整份。追加也一样:读出原有的、接上新的、整份换掉 - before = target.read_bytes() if args.append and target.is_file() else b"" - atomic.write_bytes(target, before + args.content.encode("utf-8")) + # 覆盖:先写临时文件再改名(core/atomic),读的一方看到的要么是旧的整份、要么是新的整份,权限照旧。追加:原地追加写 + # (O_APPEND),不整份重写——文件的 inode、权限不变,原有内容不动,读的一方最多看到半截新内容 + if args.append: + atomic.append_bytes(target, args.content.encode("utf-8")) + else: + atomic.write_bytes(target, args.content.encode("utf-8")) return {"path": args.path, "bytes": target.stat().st_size, "ok": True} diff --git a/backend/tests/test_atomic_writes.py b/backend/tests/test_atomic_writes.py index 76c326d4..8ac67581 100644 --- a/backend/tests/test_atomic_writes.py +++ b/backend/tests/test_atomic_writes.py @@ -77,16 +77,67 @@ def test_content_addressed_artifacts_are_written_atomically(tmp_path, monkeypatc assert _leftovers(folder) == [] -async def test_file_write_tool_appends_by_replacing_the_whole_file(): +async def test_file_write_tool_overwrites_atomically_and_appends_in_place(): from types import SimpleNamespace from app.tools.builtin.files import file_write ctx = SimpleNamespace(sandbox_session=f"atomic-{os.getpid()}") assert (await file_write(ctx, path="notes/a.txt", content="第一行\n"))["ok"] - out = await file_write(ctx, path="notes/a.txt", content="第二行\n", append=True) from app.core.config import settings path = settings.session_dir(ctx.sandbox_session) / "notes" / "a.txt" + inode = path.stat().st_ino + out = await file_write(ctx, path="notes/a.txt", content="第二行\n", append=True) assert path.read_text(encoding="utf-8") == "第一行\n第二行\n" and out["bytes"] == len("第一行\n第二行\n".encode()) + assert path.stat().st_ino == inode, "追加不换文件" + await file_write(ctx, path="notes/a.txt", content="重写\n") + assert path.read_text(encoding="utf-8") == "重写\n" assert _leftovers(path.parent) == [] + + +def test_create_falls_back_when_the_filesystem_cannot_hard_link(tmp_path, monkeypatch): + """exFAT、一部分 SMB / FUSE 挂载不支持硬链接(EPERM、ENOTSUP、EXDEV):退回 O_EXCL 直接建,照样只建一次,不崩。""" + import errno + + def no_links(src, dst): + raise OSError(errno.EPERM, "Operation not permitted") + + monkeypatch.setattr(os, "link", no_links) + target = tmp_path / ".secret" + assert atomic.create_text(target, "第一把", mode=0o600) is True + assert atomic.create_text(target, "第二把", mode=0o600) is False + assert target.read_text() == "第一把" and stat.S_IMODE(target.stat().st_mode) == 0o600 + assert _leftovers(tmp_path) == [] + + +def test_replacing_keeps_the_permissions_of_the_old_file(tmp_path): + target = tmp_path / "run.sh" + target.write_text("echo 1") + target.chmod(0o750) + atomic.write_text(target, "echo 2") + assert target.read_text() == "echo 2" and stat.S_IMODE(target.stat().st_mode) == 0o750 + + +def test_appending_keeps_the_same_file(tmp_path): + """追加写不换文件:inode、权限都不变,原有内容不动(以前整份读出来重写、再改名)。""" + target = tmp_path / "log.txt" + target.write_text("一\n") + target.chmod(0o640) + before = target.stat().st_ino + atomic.append_bytes(target, "二\n".encode()) + assert target.read_text() == "一\n二\n" + assert target.stat().st_ino == before and stat.S_IMODE(target.stat().st_mode) == 0o640 + assert _leftovers(tmp_path) == [] + + +def test_the_data_dir_carries_a_marker_deploy_can_recognise(): + """数据目录里有 .agentlab-data:库在 PostgreSQL、密钥由环境变量给时,deploy.sh 也认得出这是 AgentLab 的数据目录。""" + from app.core.config import DATA_DIR_MARKER, settings + + settings.ensure_dirs() + marker = settings.data_dir / DATA_DIR_MARKER + assert marker.is_file() + text = marker.read_text(encoding="utf-8") + settings.ensure_dirs() + assert marker.read_text(encoding="utf-8") == text, "已有的不覆盖" diff --git a/deploy/README.md b/deploy/README.md index db66533e..c1958e92 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -58,7 +58,8 @@ deploy.sh 依次:检查 docker 和架构 → 导入镜像(`docker load -i im 所有数据都在 `DATA_DIR`(默认是包目录下的 `data/`):数据库 `agentlab.db`、执行断点 `checkpoints.db`、 工件、上传文件、沙箱工作区,以及加密密钥 `.secret_key`。**要备份的就是这个目录**, -丢了 `.secret_key`,存进去的 API Key 就解不开了。数据库放在 PostgreSQL 时(下面「用 PostgreSQL」)没有这两个库文件, +丢了 `.secret_key`,存进去的 API Key 就解不开了。目录里的 `.agentlab-data` 是服务启动时建的标记,deploy.sh 改属主前按它 +(或 `agentlab.db`、`.secret_key`)认出这是 AgentLab 的数据目录,别删。数据库放在 PostgreSQL 时(下面「用 PostgreSQL」)没有这两个库文件, 另用 `pg_dump` 备份数据库。 deploy.sh 会把数据目录的属主改成容器里的用户(uid 10001),所以备份、删除要用 sudo。 diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 9e74e29f..0d579dda 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -266,10 +266,11 @@ fix_data_owner() { owner="$(ls -nd "$DATA_ABS" | awk '{print $3}')" [ "$owner" = "$uid" ] && return 0 # 改属主前确认这确实是一个新目录或者 AgentLab 的数据目录,免得 DATA_DIR 写错改了别的东西 - # 应用库放在 PostgreSQL 时(AGENTLAB_DATABASE_URL)数据目录里没有 agentlab.db,认 .secret_key 和 uploads/ + # 认这三样里的一样:agentlab.db(库在 SQLite)、.secret_key(自动生成的加密密钥)、.agentlab-data(服务启动时建的标记, + # 库放在 PostgreSQL、密钥又由 AGENTLAB_SECRET_KEY 给时只有它)。只有 uploads/ 这类普通目录名的不认,免得 chown 错了别人的目录 if [ -n "$(ls -A "$DATA_ABS" 2>/dev/null)" ] && [ ! -e "$DATA_ABS/agentlab.db" ] \ - && [ ! -e "$DATA_ABS/.secret_key" ] && [ ! -d "$DATA_ABS/uploads" ]; then - die "DATA_DIR 指向的 $DATA_ABS 不是空目录,也不像 AgentLab 的数据目录(里面没有 agentlab.db、.secret_key、uploads)。 + && [ ! -e "$DATA_ABS/.secret_key" ] && [ ! -e "$DATA_ABS/.agentlab-data" ]; then + die "DATA_DIR 指向的 $DATA_ABS 不是空目录,也不像 AgentLab 的数据目录(里面没有 agentlab.db、.secret_key、.agentlab-data)。 为免改错别的文件的属主,请指向一个新目录,或者原来的 AgentLab 数据目录" fi say "把数据目录的属主改成容器里的用户(uid ${uid}),容器里的服务才写得进去"