diff --git a/CONTEXT.md b/CONTEXT.md index 63f6bba..e7ab3e2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -189,7 +189,7 @@ _Avoid_: Promotion Audit、Codex 版本门禁、源码验收、状态兼容检 _Avoid_: 自动更新、重复 PATH 配置、系统包管理、`sudo`、源码热加载、可配置保留策略、无限快照历史、状态迁移、每-Run Runner 绑定 **Runner Setup(Runner 环境准备)**: -源码树 `./setup.sh` 提供的、无需预先安装 Python 或 Git 的一次性公开入口;在用户开始使用 Runner 前,经集中检查和统一确认后补齐宿主依赖、委托 Source Runner Installer 完成本体安装并获取手动待办清单,区别于 Runner 本体安装、目标仓库准备和实际执行就绪判定。不在自动补装范围内的依赖转为手动待办,不因发行版缺少自动适配而中断所有可继续步骤,也不把尚缺关键条件的环境称为已就绪;CI、仓库忽略规则、任务标签与 Skills 由用户自行准备。 +源码树 `./setup.sh` 提供的、无需预先安装 Python 或 Git 的一次性公开入口;在用户开始使用 Runner 前,经集中检查和统一确认后补齐宿主依赖、委托 Source Runner Installer 完成本体安装并获取手动待办清单,区别于 Runner 本体安装、目标仓库准备和实际执行就绪判定。不在自动补装范围内的依赖转为手动待办,不因发行版缺少自动适配而中断所有可继续步骤,也不把尚缺关键条件的环境称为已就绪;CI、任务标签与 Skills 由用户自行准备。 _Avoid_: 安装成功即能运行、自动初始化目标仓库、自动配置 CI、自动管理 Skills、非适配发行版一律拒绝 **Source Runner Installer(源码 Runner 安装器)**: @@ -221,7 +221,7 @@ _Avoid_: 等待 Executor、自动 stop、运行中热更新、每-Run Snapshot _Avoid_: 状态迁移、兼容性检查、自动回退、下载历史版本、任意历史选择 **Runner Uninstall(Runner 卸载)**: -用户在任一包含源码安装器的目录显式执行 `./install.sh --uninstall`,并在 Runner Management Quiescence 中删除全部受管 Runner Snapshot、Runner Generation、候选残留、内部 `active` 入口、本机受管 `agent-run` symlink 以及安装器添加的 PATH 受管配置。若存在活跃 Run Executor Session,卸载立即拒绝且不修改受管状态。若用户已把公开入口替换为非受管路径,卸载保留该内容并以有界 operational error 报告清理未完成。它保留固定 Runner Management Lock、GitHub App 持久配置、全局 Run 定位状态和各目标仓库中的 `.agent-run` Delivery Run 数据;v0.1 不提供连带删除用户配置或运行数据的 purge 模式。 +用户在任一包含源码安装器的目录显式执行 `./install.sh --uninstall`,并在 Runner Management Quiescence 中删除全部受管 Runner Snapshot、Runner Generation、候选残留、内部 `active` 入口、本机受管 `agent-run` symlink 以及安装器添加的 PATH 受管配置。若存在活跃 Run Executor Session,卸载立即拒绝且不修改受管状态。若用户已把公开入口替换为非受管路径,卸载保留该内容并以有界 operational error 报告清理未完成。它保留固定 Runner Management Lock、GitHub App 持久配置、Run 定位状态、Runner 独立仓库及 Delivery Run 数据;v0.1 不提供连带删除用户配置或运行数据的 purge 模式。 _Avoid_: 删除 Delivery Run、删除 GitHub App 配置、删除非受管 shell 配置、`--purge` **Run 内部监督(In-Run Supervision)**: @@ -340,25 +340,29 @@ _Avoid_: Codex Worker、内容作者、智能审查者 改变权威 Git 历史、metadata 或 GitHub 外部持久状态的排他权限;临时 checkout 的源码编辑和一次性 Git 数据不属于该权限。该权限只属于 Publisher,不因 Codex Worker 的建议、代码修改或完成声明而转移。 _Avoid_: Agent 自治、结构化输出、候选就绪 +**User Repository(用户仓库)**: +维护者自行管理的本地 Git checkout。Runner 仅用它只读识别目标远端,不修改其文件、索引、引用或 Git 元数据,也不借用其 Git object store 创建 worktree。未推送的本地内容不进入交付;获取交付后的远端代码由维护者自行完成。 +_Avoid_: Runner 工作区、共享 Git 元数据、合并后自动更新用户分支 + **Local Delivery Workspace(本地交付工作区)**: -维护者发起并恢复 Delivery Run 的准确本地 Git 仓库根目录,也是任务唯一性和 Run Executor Session 排他权的本地范围。两个独立本地仓库根目录由维护者自行协调,Controller 不扫描、不关联、不阻止它们对同一远端 Parent 各自启动 Run。Controller 为 Run 创建的 Managed Development Checkout 仍隶属原工作区,不是可重复启动该任务的新工作区。 -_Avoid_: 主机级仓库注册表、跨 clone 锁、磁盘扫描、把 Managed Development Checkout 当作新的操作根 +Runner 在用户数据目录中为准确远端 Repository 管理的独立克隆,以及与其关联的运行状态和受管 worktree。相同本机用户从不同目录或不同用户 clone 启动同一 Repository 时定位到同一工作区;生命周期写入仅发生在 Runner 工作区和获准的远端。Managed Development Checkout 隶属该工作区,不产生新的任务身份。 +_Avoid_: 用户仓库、启动目录、借用用户仓库的 worktree、跨用户或跨主机协调、磁盘扫描 **Delivery Task(交付任务)**: -由准确 Repository 与 Parent Issue 共同标识的稳定远端交付对象。同一 Local Delivery Workspace 内,一个 Delivery Task 同时最多拥有一个未完成 Delivery Run;已完成或已放弃的 Run 保留为不可变历史,维护者可以显式创建一个后继 Run。不同 Repository 或同一 Repository 的不同 Parent Issue 是不同 Delivery Task,必须能够并行推进。 -_Avoid_: 裸 `run_id`、单张 Child Ticket、整个 Repository、主机全局任务、跨独立本地仓库的唯一性承诺 +由准确 Repository 与 Parent Issue 共同标识的稳定远端交付对象。同一用户在本机对一个 Delivery Task 同时最多拥有一个未完成 Delivery Run,不因启动目录或用户 clone 不同而重复创建;已完成或已放弃的 Run 保留为不可变历史,维护者可以显式创建一个后继 Run。不同 Repository 或同一 Repository 的不同 Parent Issue 是不同 Delivery Task,必须能够并行推进。 +_Avoid_: 裸 `run_id`、单张 Child Ticket、整个 Repository、以启动目录区分任务、跨用户或跨主机唯一性承诺 **Delivery Run(交付运行)**: 由一次人工授权在一个 Local Delivery Workspace 中启动、覆盖一个 Delivery Task 的 Parent Issue 及其相关 Ticket、并以最终整体验收结束的交付范围。一个 Run 拥有独立身份和集成边界;同一工作区内同一 Delivery Task 的多个 Run 只能按时间先后存在,不能并行。普通 `run` 本身就是创建或恢复授权:没有未完成 Run 且 Parent 当前可执行时创建后继 Run,旧终态 Run 只保留为历史,不复活、不覆盖。 _Avoid_: 单张 Ticket、单次 Codex 执行、长期后台服务、同一 Parent 的并行 Run、复活或覆盖终态 Run、额外 `--new-run` **Run 定位索引(Run Locator Index)**: -本机维护的最小 Run ID 到仓库根和 state 目录的定位记录。新登记同时保存 Repository 与 Parent number,作为 state 或 checkout 丢失时仍可核验的路由身份,不复制生命周期状态。查询兼容缺少这对字段的旧记录;可读取的 state、checkout 与路由身份必须一致,只有身份足以证明无关的记录才可排除。可能匹配但身份不足、状态失效或身份冲突时要求维护者显式消歧;查询成功和失败均不回填、删除或迁移索引与历史 Run,不扫描磁盘。索引最多保留最近 32 条,且不参与 Agent 编排、GitHub 状态、权限或生命周期 mutation。 +本机维护的最小 Run ID 到 Runner 独立仓库根和 state 目录的定位记录,存放于 Runner 数据目录。新登记同时保存 Repository 与 Parent number,作为 state 或 checkout 丢失时仍可核验的路由身份,不复制生命周期状态。查询兼容缺少这对字段的旧记录;可读取的 state、checkout 与路由身份必须一致,只有身份足以证明无关的记录才可排除。可能匹配但身份不足、状态失效或身份冲突时要求维护者显式消歧;查询成功和失败均不回填、删除或迁移索引与历史 Run,不扫描磁盘。索引最多保留最近 32 条,且不参与 Agent 编排、GitHub 状态、权限或生命周期 mutation。 _Avoid_: 全盘搜索、历史 state 迁移、跨仓库自动 mutation、Agent 执行日志、第二套 Run state **Local Delivery Task Index(本地交付任务索引)**: -一个 Local Delivery Workspace 内维护的最小活动 Delivery Task 到唯一未完成 Run 及其 state 目录的指针。它只保存任务身份、Run 身份和位置,不复制生命周期状态、不参与 Agent 编排,也不跨独立本地仓库目录发现或阻止 Run。Run Executor Session 结束不删除指针中的未完成 Run;下次显式 `run` 继续它。指针若仍指向已完成或已放弃的 Run,新授权可将它替换为后继 Run;指向缺失、损坏或身份不一致的 Run 则 fail closed,不扫描其他目录、不猜测、不创建第二个 Run。 -_Avoid_: 主机级索引、第二套 Run state、完整 Run 历史、磁盘扫描、远端任务注册表、静默覆盖冲突、复活终态 Run +一个 Local Delivery Workspace 内维护的最小活动 Delivery Task 到唯一未完成 Run 及其 state 目录的指针。它只保存任务身份、Run 身份和位置,不复制生命周期状态、不参与 Agent 编排;来自不同用户 clone 的同一任务共用该指针。Run Executor Session 结束不删除指针中的未完成 Run;下次显式 `run` 继续它。指针若仍指向已完成或已放弃的 Run,新授权可将它替换为后继 Run;指向缺失、损坏或身份不一致的 Run 则 fail closed,不扫描其他目录、不猜测、不创建第二个 Run。 +_Avoid_: 第二套 Run state、完整 Run 历史、磁盘扫描、远端任务注册表、静默覆盖冲突、复活终态 Run **Merge 结果对账(Merge Outcome Reconciliation)**: Publisher 发出带精确 head 绑定的 merge intent 后,如网络或 GitHub 响应使结果未知,先在 GitHub 只读事实中对账:已 MERGED 即恢复成功;仍 OPEN 且 head、base、Required Checks 与 mergeability 仍全部匹配时,最多重试同一 intent 三次;任一矛盾状态才停止为人工处理。结果读取使用 GitHub 事件与只读状态的监督预算。 diff --git a/README.md b/README.md index 83c088b..6f1629d 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,14 @@ The `agent-run` command runs Codex to implement the tickets in dependency order. checks the results against the requirements and sends any problems back to the development agent. Implementation, verification, and fixes run in a loop, managed by the program. -Once the requirements, tickets, and environment are ready, run this in the target repository: +Once the requirements, tickets, and environment are ready, run this from any directory: ```bash agent-run run --repo OWNER/REPO ``` +Runner develops in an independent clone of the remote and leaves your local checkout unchanged. You can continue the same repository and Parent task from different directories. + The main workflow: ```mermaid diff --git a/README.zh-CN.md b/README.zh-CN.md index c9b4c26..4a24cbd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -23,12 +23,14 @@ agent-run 把拆好的任务交给 Codex 自动推进,减少人工安排下一 同时,它会单独调用 Codex 做验收,检查开发结果是否符合需求,把发现的问题交回开发 Agent 修复。 开发、验收、修复和复验因此可以自动循环进行。 -确定需求和任务拆分、完成环境配置后,在目标仓库执行: +确定需求和任务拆分、完成环境配置后,在终端执行: ```bash agent-run run --repo OWNER/REPO ``` +Runner 独立克隆远端代码进行开发,用户本地仓库保持不变。相同仓库和 Parent 的任务可从不同目录继续。 + 下面是主要流程: ```mermaid diff --git a/docs/adr/0010-task-scoped-run-executor.md b/docs/adr/0010-task-scoped-run-executor.md index 70dfc77..3613cd9 100644 --- a/docs/adr/0010-task-scoped-run-executor.md +++ b/docs/adr/0010-task-scoped-run-executor.md @@ -4,6 +4,8 @@ status: accepted # 每个 Delivery Task 由操作系统托管一个短生命周期 Executor +后续修订:[ADR 0013](0013-runner-owned-delivery-workspace.md) 将 Local Delivery Workspace 改为 Runner 独立克隆,并将同一 Repository/Parent 的唯一性收敛到本机当前用户。下文以用户仓库根区分任务、要求维护者自行协调不同 clone 的规则由该 ADR 取代;Executor、Action 与任务级短锁合同继续适用。 + 后续修订:[ADR 0011](0011-resume-interrupted-output-step.md) 为 Executor 仍存活时的 Worker 普通异常增加一次同 Thread 自动恢复,并对准确容量错误允许持续恢复,并明确失联状态的耗时展示。Executor 自身消失后不自动重放、等待显式恢复的规则继续适用。 后续修订:[ADR 0012](0012-noncancellable-final-approval.md) 将最终批准定义为执行期间不可取消、失败后显式恢复的完整交付操作。下文 `approve` 在意图应用和握手后返回并释放准入的原规则,以及可在此后停止最终交付的含义,由该 ADR 取代;恢复原最终批准操作的 `resume` 同样适用。其他动作、终端独立性、短锁和执行者所有权合同保持不变。 diff --git a/docs/adr/0013-runner-owned-delivery-workspace.md b/docs/adr/0013-runner-owned-delivery-workspace.md new file mode 100644 index 0000000..9d2c133 --- /dev/null +++ b/docs/adr/0013-runner-owned-delivery-workspace.md @@ -0,0 +1,25 @@ +--- +status: accepted +--- + +# Runner 独立管理交付工作区 + +Runner 曾把命令启动时的用户仓库作为交付工作区,向其中写入状态、分支和 worktree 元数据。合并后仅移动已检出分支的引用,还可能使文件和索引与该引用不一致。用户仓库应由维护者自行管理,交付权限不包含更新它的授权。 + +## 决策 + +Runner 只读识别用户仓库的远端,也支持在任意目录通过 `--repo` 指定目标;实际交付从远端建立独立克隆,不共享用户仓库的 Git 元数据。只使用远端已推送内容,Git 和远端交付仍沿用原有授权规则。 + +同一用户在本机对同一 Repository/Parent 复用唯一未完成 Delivery Run、Executor 槽位和 Action 准入门。不同启动目录和用户 clone 不产生新任务身份,不同 Parent 仍可并行。这取代 [ADR 0010](0010-task-scoped-run-executor.md) 中以用户仓库根区分任务、由维护者协调不同 clone 的约定;不扩展为跨用户或跨主机协调。 + +长期数据统一放在 `$XDG_DATA_HOME/agent-run/`,默认 `~/.local/share/agent-run/`,包括安装快照、独立仓库、运行状态、日志和定位索引;路径规则由一个入口定义。配置与临时运行文件继续按生命周期分开。安装、卸载和任务清理各自只管理其负责的内容,卸载保留任务数据。 + +成功或显式放弃后清理可安全删除的受管 worktree,保留运行记录;失败、暂停和人工等待保留开发现场。丢弃未提交成果仍需额外显式授权,不按时间自动删除。Worker 只获得本轮 checkout 所需的访问,Runner 安装和控制数据继续隔离。 + +## 后果 + +用户自行拉取远端交付结果;Runner 不再修改用户仓库的文件、索引、引用或 Git 元数据。目录按需创建,无法创建时明确失败,不回退到用户仓库。 + +独立克隆增加一份受管 Git 数据,换取清晰的所有权、恢复和清理边界。仅把 worktree 移到仓库外仍会共享用户 `.git`,不足以满足该边界,因此不采用。 + +旧任务及旧位置的数据不纳入此次变更:不迁移、恢复或清理,也不要求升级前先收尾。 diff --git a/docs/agent-guide.en.md b/docs/agent-guide.en.md index aa2f32a..1cb8194 100644 --- a/docs/agent-guide.en.md +++ b/docs/agent-guide.en.md @@ -6,7 +6,7 @@ You are the engineer responsible for installing and running `agent-run` for the Deliver actual results, verification evidence, unmet requirements, and next steps. For installation-only requests, stop after setup. For run requests, follow the task to completion or a boundary requiring human action. Follow the user's instructions and the target repository's `AGENTS.md`. Use current Issue/PR data, the installed version's `--help`, and query results as evidence. -Read documentation matching the selected source version. Record the tool source directory, target repository directory, and `OWNER/REPO`. +Read documentation matching the selected source version. Record the tool source directory, target `OWNER/REPO`, and user checkout directory if used. ## Choose an entry point @@ -27,7 +27,7 @@ Read only the relevant branch and its required references. Detailed operation re When subtasks exist, verify native GitHub sub-issues and `blockedBy` relationships; body lists do not define the task graph. With no subtasks, use Parent-only delivery. Eligible tasks must be open, have `ready-for-agent`, and have none of `needs-triage`, `needs-info`, or `ready-for-human`. Unresolved blockers prevent selection. -2. Locate and start the task from the target repository with its project environment configured. Replace placeholders with actual values: +2. Locate and start the task from a shell with the project environment configured. With `--repo`, commands work from any directory; inside a user checkout, omit it to discover the remote without modifying that checkout. Replace placeholders with actual values: ```bash agent-run runs --repo OWNER/REPO @@ -35,7 +35,9 @@ Read only the relevant branch and its required references. Detailed operation re agent-run status --parent --repo OWNER/REPO --json ``` - `run` creates or attaches to the unfinished task for the same Parent. Follow query instructions when human action is pending. + Runner clones the remote independently and leaves the user checkout unchanged. Only pushed content reaches the Agent; users update their own files, index, and branches. + On first creation, the managed clone inherits only the effective Git `user.name` and `user.email` from the launch environment, without copying other repository-local Git settings. + For the same local user, `run` creates or attaches to one unfinished task for the same Repository/Parent, regardless of the launch directory. Follow query instructions when human action is pending. If multiple candidates exist, resolve repository and Parent identity rather than choosing the most recent. A command returning does not mean delivery is complete. 3. Use `status` for the current work and next action. For process evidence, read @@ -48,7 +50,7 @@ Report status, PR links if any, actual acceptance/required-check results, and ou ## Human action and recovery Before changing run state, read the [command boundary table](agent-run.md#命令边界与状态轮转) and select the action for the current state. -Run mutation commands from the target repository with the actual Parent and `--repo OWNER/REPO`. +Run mutation commands from any directory with the actual Parent and `--repo OWNER/REPO`. | Situation | Action | | --- | --- | @@ -61,4 +63,4 @@ Run mutation commands from the target repository with the actual Parent and `--r | Pause | Use `stop`; Ctrl-C or closing the terminal only ends observation, while the background task continues | | Abandon | Follow `abandon` boundaries; preserve uncommitted work by default and require explicit authorization for forced discard | -For exhausted limits, changed scope, or incompatible state, report the decision needed. Recover through the public CLI; never edit `.agent-run` state to bypass gates. +For exhausted limits, changed scope, or incompatible state, report the decision needed. Recover through the public CLI; never edit persistent run state to bypass gates. See [local state and cleanup](agent-run.md#本地状态与清理) for directory ownership and retention. diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 3e1ee62..4308950 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -7,7 +7,7 @@ 要求运行时,跟进到完成或需要人工处理的边界。 遵循用户要求和目标仓库 `AGENTS.md`,以当前 Issue/PR、安装版本的 `--help` 和查询结果为事实依据。 -使用与所选源码版本对应的文档,记录工具源码目录、目标仓库目录和 `OWNER/REPO`。 +使用与所选源码版本对应的文档,记录工具源码目录、目标 `OWNER/REPO` 和使用的用户仓库目录(如有)。 ## 选择入口 @@ -29,7 +29,8 @@ 无子任务时执行 Parent-only 交付。可领取任务须为 open、有 `ready-for-agent`, 且没有 `needs-triage`、`needs-info` 或 `ready-for-human`;未解除的 blocker 阻止领取。 -2. 在配置好项目环境的目标仓库中定位并启动任务,用实际值替换占位符: +2. 在配置好项目环境的终端定位并启动任务,用实际值替换占位符。带 `--repo` 时可从任意目录运行; + 从用户仓库运行时也可省略该参数,由 Runner 只读识别远端: ```bash agent-run runs --repo OWNER/REPO @@ -37,7 +38,9 @@ agent-run status --parent --repo OWNER/REPO --json ``` - `run` 创建或附着同一 Parent 的未完成任务;已有人工门禁时按查询指引处理。 + Runner 从远端独立克隆代码,不修改用户仓库。仅已推送内容可供 Agent 使用;本地分支、文件和索引由用户自行更新。 + 首次创建受管克隆时,只继承启动环境中生效的 Git `user.name` 和 `user.email`,不复制用户仓库的其他本地 Git 配置。 + 同一用户在本机对同一 Repository/Parent 的 `run` 创建或附着唯一未完成任务,不因启动目录不同而重复执行;已有人工门禁时按查询指引处理。 多个候选时核对仓库与 Parent,不按最近时间猜测。命令返回不代表整个任务完成。 3. 用 `status` 获取当前对象和下一步,需要过程证据时读 @@ -50,7 +53,7 @@ ## 人工处理与恢复 需要改变运行状态时,先读[命令边界表](agent-run.md#命令边界与状态轮转),按当前状态选择动作。 -所有变更命令从目标仓库执行,带实际 Parent 和 `--repo OWNER/REPO`。 +变更命令带实际 Parent 和 `--repo OWNER/REPO`,可从任意目录执行。 | 情况 | 动作 | | --- | --- | @@ -64,4 +67,4 @@ | 放弃 | 按 `abandon` 边界处理;默认保留未提交工作,强制丢弃须有明确授权 | 预算耗尽、范围变化或状态不兼容时,报告当前所需决策;使用公开 CLI 恢复, -不得编辑 `.agent-run` 状态绕过门禁。 +不得编辑持久运行状态绕过门禁。目录与清理边界见[本地状态与清理](agent-run.md#本地状态与清理)。 diff --git a/docs/agent-run.md b/docs/agent-run.md index 79f2282..3f89199 100644 --- a/docs/agent-run.md +++ b/docs/agent-run.md @@ -270,15 +270,16 @@ Required Check 失败时,Controller 读取失败 check 的名称、workflow、 Actions job 的当前 head、状态与逐 step conclusion;只有仓库配置明确声明的 code/test step 被该结构化事实证明失败时,才将原始 CI Evidence 交回同一 Development Thread,其他情况保持监督。 -新版本创建 Run 时,本机 Run 定位索引记录其 Run ID、仓库根、state 目录及 Repository/Parent 路由身份,最多保留 +新版本创建 Run 时,本机 Run 定位索引记录其 Run ID、Runner 独立仓库根、state 目录及 Repository/Parent 路由身份,最多保留 最近 32 条,不回填或迁移历史 Run。因此,`runs` 可以按当前仓库或显式 `--repo` 发现候选; `status`、`history` 可以按当前仓库的唯一进行中 Run、`--parent`,或任意目录的 `--repo + --parent` 选择。可信身份能证明与目标无关的失效记录不会阻塞查询;旧记录若缺少 Parent 且 state 已丢失, -则不能据此认定无关。若没有唯一候选、存在多个 clone、可能匹配的失效记录或身份冲突,命令会列出候选并 +则不能据此认定无关。同一用户的不同启动目录和用户 clone 共用同一 Repository/Parent 的未完成任务。若没有唯一候选、存在可能匹配的失效记录或身份冲突,命令会列出候选并 停止,绝不按最近时间猜测或全盘搜索。查询成功和失败都不清理、补写或迁移索引与 Run state。 普通 mutation 与 Run-scoped `configure` 同样使用 Parent -位置参数并执行零匹配、多匹配和 repository mismatch 检查;完整 Run ID 与显式 state 目录仅是 -自动化和精确排障入口。改变 Run 的命令仍必须从目标仓库运行。 +位置参数并执行零匹配、多匹配和 repository mismatch 检查;完整 Run ID 用于自动化和精确排障。 +查询和变更命令都可从任意目录通过 `--repo OWNER/REPO` 定位目标;省略时只读识别当前用户仓库的远端。 +变更命令始终使用该仓库的统一 state 目录,`--state-dir` 指向其他位置时明确拒绝;`status`、`history` 可用该参数只读排障。数据主目录通过 `XDG_DATA_HOME` 选择。 Lifecycle mutation 默认输出面向维护者的回执,只显示仓库、Parent、操作、是否附着原操作、动作是否 已应用、当前交付状态与下一步;动作应用完成不代表整个交付已经完成。Action ID、Run ID、Executor @@ -468,7 +469,7 @@ Snapshot。manifest 只用于识别和解释,不是 source trust、签名或 激活前由独立 `RunnerProbeBackend` 在空临时目录调用当前 PATH 中的 `codex exec --output-schema`。 它只接受根对象、禁止额外字段且 `status` 为 `ok` 的固定结果,不复用 Worker、GitHub、bubblewrap 或既有 publication handshake,不读取 Codex 版本,也不写长期 audit。Codex、构建或文件系统失败 -会清理候选并保留原 Active/previous、Run locator、用户配置和仓库 `.agent-run`。 +会清理候选并保留原 Active/previous、Run locator、用户配置和运行数据。 Active Runner 由完整 generation symlink 原子选择,generation 内的 `current` 与可选 `previous` 成对保存;系统只保留当前和紧邻上一个 Snapshot。相同 Active identity 的重复安装不会重新 probe @@ -483,7 +484,7 @@ generation/Snapshot 的清理失败只写出有界 warning,并在后续安装 rollback 只交换 `current`/`previous`,不重建、不 probe、不检查或修改 Delivery Run;没有 previous 时失败且 Active 不变。uninstall 删除受管 Snapshot、generation、入口和 `~/.profile` 中唯一的 -受管 PATH 块,保留固定锁、GitHub App profile、Run locator、私钥文件和目标仓库 `.agent-run`。 +受管 PATH 块,保留固定锁、GitHub App profile、Run locator、私钥文件、Runner 独立仓库和运行数据。 同名非受管 `~/.local/bin/agent-run` 永不覆盖;用户替换入口时保留用户内容并报告清理未完成。 安装完成后,稳定入口只是指向 Active Snapshot console entry 的 symlink;安装器不驻留、不启动 @@ -496,8 +497,7 @@ branch、fork、dirty source 或非官方 provenance 被旧 gate 拒绝。目标 ## 开源用户 Quickstart 与 doctor -源码仓库只负责构建 Runner,目标交付仓库负责保存 `.agent-run`、Delivery Run 和 Run Branch;两者 -应当是两个目录。稳定使用先选择 release tag,开发者才选择 branch、fork 或 dirty source: +源码仓库用于构建 Runner;Runner 从目标远端独立克隆代码,并在自己的数据目录保存 Delivery Run、Run Branch 和 worktree。用户仓库仅供只读识别远端,未推送内容不参与交付,合并后由用户自行更新本地代码。稳定使用先选择 release tag,开发者才选择 branch、fork 或 dirty source: ```bash git clone https://github.com/GRD-Chang/grill-engineering.git @@ -507,8 +507,6 @@ git checkout # 重新打开登录 shell 后,可在任意目录执行 agent-run doctor --json -cd /path/to/delivery-repository -agent-run doctor agent-run run --repo OWNER/REPO ``` @@ -516,13 +514,13 @@ agent-run run --repo OWNER/REPO 宿主 `gh` 登录、OpenSSL、Linux `bubblewrap`、Active Runner、PATH 和 Worker read provider;JSON 输出只包含 Python 版本、路径、状态、provider 和布尔值等非敏感信息。缺少依赖只报告问题,不安装软件、不 修复 PATH、不触发生命周期,也不修改 shell、auth profile、Run locator、Delivery Run、Thread、PR、 -branch 或目标仓库 `.agent-run`。 +branch 或运行数据。 后续更新仍从源码目录显式执行 `./install.sh`。如果当前为 A,安装 B 后保留 B/A,再安装 C 后只保留 C/B;相同内容重复安装不会重新 probe 或增加 Snapshot,候选失败会保留旧 Active。一次回退执行 `./install.sh --rollback`,它不重建、不调用 Codex、不检查或修改 Delivery Run;卸载执行 `./install.sh --uninstall`,它清理受管 Runner、入口和 PATH 块但保留固定 `install.lock`、App profile、 -私钥、Run locator 和目标仓库 `.agent-run`。PATH 变化需要重新打开登录 shell;重复卸载安全,用户替换 +私钥、Run locator、Runner 独立仓库和运行数据。PATH 变化需要重新打开登录 shell;重复卸载安全,用户替换 的同名入口会被保留并报告清理未完成。 没有 App profile 时 Worker read provider 默认是 host `gh`;`agent-run auth status`、 @@ -537,9 +535,19 @@ C/B;相同内容重复安装不会重新 probe 或增加 Snapshot,候选失 ## 本地状态与清理 -耐久状态位于 `.agent-run/runs/`。本协议要求 `semantic_attempt_protocol: 1`;旧 Run 不迁移、 -不兼容读取,也不会在拒绝前执行 lifecycle mutation,必须重新创建或明确清理。稳定 Development Checkout 位于 -`.agent-run/worktrees/`:Required Checks pending 或 Worker/Publisher 普通失败、超时、 +长期数据默认位于 `~/.local/share/agent-run/`,设置 `XDG_DATA_HOME` 时使用其下的 `agent-run/`;该数据目录必须位于用户仓库之外。目录按需创建,失败时明确报错,不向用户仓库回退。 + +| 位置(相对于数据主目录) | 内容 | +| --- | --- | +| `repositories///repository/` | 从远端独立克隆的 Git 仓库;owner/repo 统一小写 | +| `repositories///state/` | Run、任务控制、profiles、通知记录;受管工作区位于其中的 `worktrees/` | +| `run-locator.json` | 有界 Run 定位索引 | +| `snapshots/`、`generations/` 等安装目录 | 安装快照和当前版本选择,与任务数据分别管理 | + +配置继续位于 `$XDG_CONFIG_HOME/agent-run/`(默认 `~/.config/agent-run/`),临时环境交接文件使用运行时目录。卸载仅清理安装内容,保留任务数据。用户仓库的文件、索引、引用和 Git 元数据均由用户自行管理。 + +旧任务及旧位置的数据不在本次变更范围内,不迁移、恢复或清理,也无需用户先完成旧任务。 +本协议要求 `semantic_attempt_protocol: 1`。稳定 Development Checkout 在 Required Checks pending 或 Worker/Publisher 普通失败、超时、 进程异常或 Ctrl-C 时保留,以恢复未提交成果;Development/Repair Codex 报告 Human Blocker 时也保留,供同一 Thread 在 `resume` 后重新核验并继续。普通 cleanup 只有在 Git 元数据证明 checkout 属于本 Run 且 `git status --porcelain` 为空时才删除。tracked/untracked 修改、元数据缺失或归属不一致都 fail closed,并在 `status`、`run`、`resume` JSON 中给出路径、原因和恢复命令。 `abandon` 在任何 GitHub mutation 之前执行同样的全 Run preflight;若维护者确认不再需要本地成果,必须显式使用 `abandon --discard-worktree`。Ticket 完成和明确 abandonment 才进入清理。 Checkout 尚未准备完成时产生的部分目录也会清理。每轮独立 Validation Checkout 在验收 diff --git a/docs/install.en.md b/docs/install.en.md index a931c31..af6e61a 100644 --- a/docs/install.en.md +++ b/docs/install.en.md @@ -3,7 +3,7 @@ English | [简体中文](install.md) Complete installation and the target repository checks on this page. To run a task, follow the [agent guide](agent-guide.en.md#run-a-task). -The tool's source directory is used for installation; the target repository is where delivery runs. They may be different directories. +The tool's source directory is used for installation. Delivery uses Runner's independent clone of the target remote; the user checkout stays unchanged. ## First installation @@ -58,14 +58,13 @@ Setup's short-lived systemd probe checks actual execution capability. Preserve h ## Target repository setup -Enter the target repository and read its `AGENTS.md`. Setup does not configure the items below. Resolve them within the user's authorization and verify each one: +Read the target repository's `AGENTS.md` and confirm that the required content has been pushed. Inspect an existing user checkout or read the remote contents. Setup does not configure the items below. Resolve them within the user's authorization and verify each one: | Check | Completion criterion | | --- | --- | -| Authentication and repository identity | `codex login status` and `gh auth status` succeed; Git identity and remotes are correct; host gh has the write permissions needed for publication | +| Authentication and repository identity | `codex login status` and `gh auth status` succeed; Git commit identity and target `OWNER/REPO` are correct; host gh has the write permissions needed for publication | | Skills | The user running Codex can discover and read `implement`, `code-review`, their referenced Skills, and any other Skills required by the project. Runner does not bundle them. Follow the current [Matt Skills instructions](https://github.com/mattpocock/skills) when missing; inspect and reuse existing Skills with the same name | | Project environment | Build, test, and runtime dependencies are ready. Start from a shell with the project's PATH/virtual environment configured; Runner's Python environment does not replace it | -| Ignore rules | Root `.gitignore` includes `/.agent-run/`, and `git check-ignore .agent-run/runs/probe` succeeds. Handle any already-tracked runtime files first | | CI | If PR workflows exist, verify that their base-branch filters cover the default branch and `agent-run/**`; check required-check rules for both. Runs may proceed with no required checks, but do not describe that as CI passing | ### GitHub authentication: no App required by default @@ -126,5 +125,5 @@ Run these commands from the selected tool source root. Rerun `./setup.sh` first Installation retains only the current and previous versions. Reinstalling still builds a candidate; if its content matches the current version, that version is reused and the compatibility call is skipped. Build or activation failure preserves the previous active version. -Installation, rollback, and uninstall do not alter existing delivery tasks. Uninstall preserves the installation lock, Run locator records, App configuration, private keys, and each target repository's `.agent-run` directory. +Installation, rollback, and uninstall do not alter existing delivery tasks. Uninstall preserves the installation lock, Run locator records, App configuration, private keys, Runner-owned clones, and run data. A user-replaced command entry point is preserved and reported as incomplete cleanup. If task state is incompatible, follow the reported error rather than manually migrating it. diff --git a/docs/install.md b/docs/install.md index 9e40e5d..2ef8f83 100644 --- a/docs/install.md +++ b/docs/install.md @@ -3,7 +3,7 @@ [English](install.en.md) | 简体中文 按本页完成安装和目标仓库检查;运行任务见 [Agent 操作指南](agent-guide.md#运行任务)。 -工具源码目录用于安装,目标仓库目录用于交付,两者可以不同。 +工具源码目录用于安装;交付使用 Runner 从目标远端建立的独立克隆,用户仓库保持不变。 ## 首次安装 @@ -59,14 +59,13 @@ Setup 成功后的短命 systemd 探针用于检查真实执行能力。不要 ## 目标仓库接入 -进入目标仓库并读取其 `AGENTS.md`。Setup 不配置以下内容,按用户授权补齐并逐项验证: +读取目标仓库的 `AGENTS.md`,确认交付所需内容已推送;可检查现有用户仓库或读取远端内容。Setup 不配置以下内容,按用户授权补齐并逐项验证: | 检查项 | 完成条件 | | --- | --- | -| 认证与仓库身份 | `codex login status`、`gh auth status` 有效;Git 身份和 remote 正确,宿主 gh 有目标仓库发布所需的写权限 | +| 认证与仓库身份 | `codex login status`、`gh auth status` 有效;Git 提交身份与目标 `OWNER/REPO` 正确,宿主 gh 有目标仓库发布所需的写权限 | | Skills | 实际运行 Codex 的用户可发现并读取 `implement`、`code-review`、它们引用的 Skills 及项目要求的其他 Skills。Runner 不附带它们;缺失时按 [Matt Skills](https://github.com/mattpocock/skills) 的当前说明准备,已有同名内容先检查并复用 | | 项目环境 | 编译、测试和运行依赖已就绪;从配置好项目 PATH/虚拟环境的终端启动,Runner 的 Python 环境不能替代它 | -| 忽略规则 | 根 `.gitignore` 包含 `/.agent-run/`,`git check-ignore .agent-run/runs/probe` 成功;若已有跟踪文件,先妥善处理 | | CI | 已有 PR workflow 时,核对其 base 分支覆盖默认分支和 `agent-run/**`,并检查两类分支的必需检查规则;确认无必需检查也可运行,但不报告为 CI 通过 | ### GitHub 认证:默认无需配置 App @@ -124,5 +123,5 @@ code-failure-steps = [ 安装只保留当前和上一个版本。重复安装仍构建候选;与当前版本内容相同时复用该版本,跳过兼容性调用。 构建或激活失败保留原版本。 -安装、回滚和卸载均不修改已有交付任务。卸载保留安装锁、Run 定位记录、App 配置、私钥和各目标仓库的 `.agent-run`; +安装、回滚和卸载均不修改已有交付任务。卸载保留安装锁、Run 定位记录、App 配置、私钥、Runner 独立仓库和运行数据; 用户替换过的命令入口会保留并报告清理未完成。若任务状态不兼容,按错误处理,不手工迁移状态。 diff --git a/src/agent_run/cli.py b/src/agent_run/cli.py index 7384b5c..c60fed2 100644 --- a/src/agent_run/cli.py +++ b/src/agent_run/cli.py @@ -12,6 +12,7 @@ from agent_run import cli_presentation, cli_surface from agent_run import doctor +from agent_run.models import same_repository from agent_run.user_defaults import UserDefaultsStore, notification_snapshot from agent_run import settings_cli from agent_run.agent_fixture import FixtureAgentBackend @@ -34,6 +35,8 @@ policy_snapshot_for_state, resolve_delivery_policy, ) +from agent_run.managed_workspace import workspace_for_root, workspace_state_root, validate_data_root +from agent_run.workspace_cli import open_workspace, selected_repository_root from agent_run.git import DirtyManagedCheckoutError, GitError, GitRepository from agent_run.github import GhGitHubReader, GitHubReadError from agent_run.github_auth_profile import ( @@ -369,7 +372,11 @@ def _main_with_parser_resources( control_failure: str | None = None command_dispatched = False executor_binding: tuple[str, int] | None = None + bootstrap_host: SystemdUserExecutorHost | None = None + runner_lease_fd: int | None = None try: + if getattr(parsed, "repo", None): + parsed.repo = parsed.repo.lower() _validate_explicit_policy_options(parsed) delivery_policy_provider = ( (lambda: _resolve_delivery_policy(parsed)) @@ -409,12 +416,38 @@ def _main_with_parser_resources( details=parsed.details, ) return 0 - git = GitRepository.discover(Path.cwd()) - state_root = ( - Path(parsed.state_dir).resolve() - if parsed.state_dir - else git.root / ".agent-run" + target_root = selected_repository_root(parsed.repo, parsed.github_fixture) + if ( + parsed.command == "run" and parsed.github_fixture is None + and target_root is not None and not target_root.exists() + ): + validate_data_root() + if not _running_active_runner(): + raise ExecutionReadinessError( + "self-hosting lifecycle commands require an installed Active Runner" + ) + usage_lease = resources.enter_context( + runner_usage_lease(default_runner_lock_path()) + ) + runner_lease_fd = usage_lease.fileno() + bootstrap_host = SystemdUserExecutorHost( + runtime_directory=_executor_runtime_directory(), + environment=dict(os.environ), + executor_python=Path(sys.executable), + runner_lease_fd=runner_lease_fd, + ) + try: + bootstrap_host.check_readiness() + except SystemdExecutionReadinessError as error: + raise ExecutionReadinessError(str(error)) from error + git = open_workspace( + parsed.repo, parsed.github_fixture, create=parsed.command == "run" ) + state_root = workspace_state_root(git.root) + if parsed.state_dir and Path(parsed.state_dir).resolve() != state_root.resolve(): + raise ValueError( + "变更命令不能覆盖 Runner 的统一状态目录;请通过 XDG_DATA_HOME 设置数据根目录。" + ) fixture_path = Path(parsed.github_fixture) if parsed.github_fixture else None github = ( FixtureGitHubReader(fixture_path) @@ -475,7 +508,7 @@ def _main_with_parser_resources( control_record: dict[str, Any] | None = None if parsed.command == "run" and executor_binding is None: task = _task_for_parent(parsed, github, git) - control = TaskControlStore(git.root / ".agent-run") + control = TaskControlStore(workspace_state_root(git.root)) try: control_record = control.load(task) except TaskControlError: @@ -518,11 +551,11 @@ def _main_with_parser_resources( ): _reject_conflicting_stop_action(parsed, states, git) execution_required = executor_backed and read_only_stop_state is None - runner_lease_fd: int | None = None if ( execution_required and fixture_path is None and executor_binding is None + and runner_lease_fd is None ): usage_lease = resources.enter_context( runner_usage_lease(default_runner_lock_path()) @@ -541,14 +574,15 @@ def _main_with_parser_resources( action_id=executor_binding[0], generation=executor_binding[1] ) elif execution_required: - systemd_host = SystemdUserExecutorHost( + systemd_host = bootstrap_host or SystemdUserExecutorHost( runtime_directory=_executor_runtime_directory(), environment=dict(os.environ), executor_python=Path(sys.executable), runner_lease_fd=runner_lease_fd, ) try: - systemd_host.check_readiness() + if bootstrap_host is None: + systemd_host.check_readiness() except SystemdExecutionReadinessError as error: raise ExecutionReadinessError(str(error)) from error executor_host = systemd_host @@ -813,22 +847,8 @@ def _main_with_parser_resources( ): try: task = _task_for_parent(parsed, github, git) - control_record = TaskControlStore(git.root / ".agent-run").load(task) - bound_root = _control_state_root(control_record) - if bound_root is not None and bound_root != states.root.resolve(): - interrupt_states = StateStore(bound_root) - interrupt_controller = Controller( - github, - git, - interrupt_states, - locator=RunLocatorIndex.default(), - profiles=AgentProfileStore(bound_root), - delivery_policy_provider=( - controller.delivery_policy_provider - if controller is not None - else None - ), - ) + control_record = TaskControlStore(workspace_state_root(git.root)).load(task) + _require_managed_state(states, git, control_record) if isinstance(control_record, Mapping): control_run_id = control_record.get("run_id") if not isinstance(control_run_id, str): @@ -1320,7 +1340,7 @@ def _read_only_stop_result( if not isinstance(repository, str) or type(parent_number) is not int: raise TaskControlError("Delivery Run 缺少准确 Delivery Task identity") task = TaskKey(git.root, repository, parent_number) - control = TaskControlStore(git.root / ".agent-run") + control = TaskControlStore(workspace_state_root(git.root)) try: if not control.proves_read_only_stop(task, state): return None @@ -1347,7 +1367,7 @@ def _reject_conflicting_stop_action( raise TaskControlError("Delivery Run 缺少准确 Delivery Task identity") task = TaskKey(git.root, repository, parent_number) try: - record = TaskControlStore(git.root / ".agent-run").load(task) + record = TaskControlStore(workspace_state_root(git.root)).load(task) except TaskControlError: return action = record.get("action") if isinstance(record, Mapping) else None @@ -1398,33 +1418,9 @@ def _read_only_run_preflight( ) -> dict[str, Any] | None: """Locate one existing Run without readiness probes or reconciliation writes.""" - requested_root = states.root.resolve() - canonical_root = (git.root / ".agent-run").resolve() - requested_current = _preflight_run(task, states) - bound_root = _control_state_root(control_record) - located_root = _located_unfinished_state_root(task, requested_root) - if bound_root is not None and located_root is not None: - if bound_root != located_root: - raise ValueError( - "Task Control 与 Run 定位索引指向多个未完成 state directory" - ) - elif bound_root is None and located_root is not None: - if requested_root != canonical_root: - raise TaskControlError( - "同一 Delivery Task 已在另一个 state directory 拥有未完成 Run;" - "不会创建第二个 Run" - ) - bound_root = located_root - if bound_root is None or bound_root == requested_root: - return requested_current - - current = _preflight_run(task, _state_store_for_run(parsed, bound_root)) - if requested_current is not None and ( - current is None - or requested_current.get("run_id") != current.get("run_id") - ): - raise ValueError("同一 Delivery Task 在多个 state directory 存在未完成 Run") - return current + _require_managed_state(states, git, control_record) + _require_managed_locator(task, states.root.resolve()) + return _preflight_run(task, states) def _lifecycle_result_succeeded( @@ -1439,26 +1435,16 @@ def _lifecycle_result_succeeded( ) -def _located_unfinished_state_root(task: TaskKey, requested_root: Path) -> Path | None: - """Find an unfinished Run registered for this checkout in another root.""" +def _require_managed_locator(task: TaskKey, requested_root: Path) -> None: + """Reject historical bindings that would escape the managed state directory.""" - candidate_roots: set[Path] = set() for entry in RunLocatorIndex.default().entries(): if Path(entry["repository_root"]).resolve() != task.workspace: continue - candidate_root = Path(entry["state_dir"]).resolve() - if candidate_root != requested_root: - candidate_roots.add(candidate_root) - roots: set[Path] = set() - for candidate_root in candidate_roots: - if _preflight_run(task, StateStore(candidate_root)) is not None: - roots.add(candidate_root) - if len(roots) > 1: - rendered = ", ".join(str(root) for root in sorted(roots)) - raise ValueError( - "同一 Delivery Task 在多个 state directory 存在未完成 Run: " f"{rendered}" - ) - return next(iter(roots), None) + if entry.get("parent_number") not in (None, task.parent_number): + continue + if Path(entry["state_dir"]).resolve() != requested_root: + raise TaskControlError("任务索引指向非统一状态目录;不会恢复或写入旧任务目录") def _run_action_payload( @@ -1610,14 +1596,15 @@ def _run_control_action( if not isinstance(run_id, str) or run_id != parsed.run_id: raise TaskControlError(f"{kind} selector 与当前 Delivery Run 不匹配") task = _task_for_parent(parsed, github, git) - control = TaskControlStore(git.root / ".agent-run") + control = TaskControlStore(workspace_state_root(git.root)) + try: + existing = control.load(task) + except TaskControlError: + existing = None + _require_managed_state(states, git, existing) payload: dict[str, Any] = {"parent": parsed.parent, "run_id": run_id} if kind == "abandon": payload["discard_worktree"] = bool(parsed.discard_worktree) - try: - existing = control.load(task) - except TaskControlError: - existing = None existing_action = ( existing.get("action") if isinstance(existing, Mapping) else None ) @@ -1656,10 +1643,8 @@ def execute_control( and isinstance(action.get("target_executor"), Mapping) else None ) + _require_managed_state(states, git, record) bound_states = states - bound_root = _control_state_root(record) - if bound_root is not None and bound_root != states.root.resolve(): - bound_states = _state_store_for_run(parsed, bound_root) def assert_current() -> None: control.assert_executor_current( @@ -1678,11 +1663,7 @@ def assert_current() -> None: run_id=bound_run_id, ), ) - bound_profiles = ( - profiles - if bound_states.root.resolve() == states.root.resolve() - else AgentProfileStore(bound_states.root) - ) + bound_profiles = profiles bound_controller = Controller( github, git, @@ -1852,10 +1833,8 @@ def _run_lifecycle( if action_kind not in {"run", "resume", "approve", "revise", "requeue"}: raise ValueError("unsupported Executor-backed lifecycle action") task = _task_for_parent(parsed, github, git) - # Task Control belongs to the canonical Local Delivery Workspace, not to - # an optional state-dir selected for a particular Run record. This keeps - # two clients in the same checkout inside one admission domain. - control = TaskControlStore(git.root / ".agent-run") + # Every user checkout for this remote shares one managed admission domain. + control = TaskControlStore(workspace_state_root(git.root)) current = ( _preflight_run(task, states) if action_kind == "run" @@ -1871,57 +1850,13 @@ def _run_lifecycle( # Reconciliation below may replace a corrupt record from an exact Run # receipt. Do not let this read-only inspection mask that path. existing_control = None + _require_managed_state(states, git, existing_control) + if action_kind == "run": + _require_managed_locator(task, states.root.resolve()) if existing_control is not None: existing_control = _reconcile_existing_control( control, task, current, state_dir=states.root ) - requested_state_root = states.root.resolve() - canonical_state_root = (git.root / ".agent-run").resolve() - bound_state_root = _control_state_root(existing_control) - located_state_root = ( - _located_unfinished_state_root(task, requested_state_root) - if action_kind == "run" - else None - ) - if bound_state_root is not None and located_state_root is not None: - if bound_state_root != located_state_root: - raise ValueError( - "Task Control 与 Run 定位索引指向多个未完成 state directory" - ) - elif bound_state_root is None and located_state_root is not None: - if requested_state_root == canonical_state_root: - bound_state_root = located_state_root - else: - raise TaskControlError( - "同一 Delivery Task 已在另一个 state directory 拥有未完成 Run;" - "不会创建第二个 Run" - ) - if bound_state_root is not None and bound_state_root != requested_state_root: - requested_current = current - states = _state_store_for_run(parsed, bound_state_root) - profiles = AgentProfileStore(bound_state_root) - controller = Controller( - github, - git, - states, - locator=RunLocatorIndex.default(), - profiles=profiles, - delivery_policy_provider=controller.delivery_policy_provider, - ) - current = ( - _preflight_run(task, states) - if action_kind == "run" - else states.load_current_run(parsed.run_id) - ) - if requested_current is not None: - if current is None or requested_current.get("run_id") != current.get( - "run_id" - ): - raise ValueError( - "同一 Delivery Task 在多个 state directory 存在未完成 Run" - ) - if current is not None: - controller._require_current_checkout(current) if current is not None and current.get("action_application_receipt") is None: existing_control = _reconcile_existing_control( control, task, current, state_dir=states.root @@ -1934,29 +1869,6 @@ def _run_lifecycle( if isinstance(existing_control, dict) else None ) - if action_kind == "run" and ( - requested_state_root != canonical_state_root - and bound_state_root != requested_state_root - ): - # An explicitly selected alternate directory must not fork a Run that - # the canonical checkout already owns. The reverse direction is - # handled above by routing the canonical command to the recorded Run. - canonical_current = _preflight_run(task, StateStore(canonical_state_root)) - if canonical_current is not None: - raise TaskControlError( - "当前 Local Delivery Workspace 已有未完成 Run;" - "不会在另一个 state directory 创建第二个 Run" - ) - if ( - bound_state_root is None - and isinstance(existing_action, dict) - and existing_action.get("status") in {"accepted", "applying"} - and existing_action.get("run_id") is None - and current is None - ): - raise TaskControlError( - "当前 Lifecycle Action 尚未绑定 Run;不会在另一个 state directory 创建 Run" - ) preflight_override = current if ( action_kind == "run" @@ -2043,7 +1955,7 @@ def _run_lifecycle( # admitting a successor Action or preparing a new Executor session, # even when run carries explicit Profile options. repository = github.repository() - if repository.name_with_owner != task.repository: + if not same_repository(repository.name_with_owner, task.repository): raise TaskControlError( "configured GitHub repository does not match the Delivery Run" ) @@ -2116,7 +2028,6 @@ def _run_lifecycle( raise TaskControlError(f"{action_kind} 找不到 Delivery Run") payload = _mutation_action_payload(parsed, current) - base_state_root = states.root.resolve() def current_executor_binding() -> tuple[str, int, str | None]: record = control.load(task) @@ -2205,10 +2116,8 @@ def lifecycle_state_store( if binding is not None else control.load(task) ) - bound_root = _control_state_root(current_control) - if bound_root is None or bound_root == base_state_root: - return states - return _state_store_for_run(parsed, bound_root) + _require_managed_state(states, git, current_control) + return states def lifecycle_components( run_states: StateStore, @@ -2218,18 +2127,7 @@ def lifecycle_components( run_states._set_write_guard( fence, transaction=transaction ) - run_root = run_states.root.resolve() - if run_root == base_state_root: - return profiles, controller - run_profiles = AgentProfileStore(run_root) - return run_profiles, Controller( - github, - git, - run_states, - locator=RunLocatorIndex.default(), - profiles=run_profiles, - delivery_policy_provider=controller.delivery_policy_provider, - ) + return profiles, controller def lifecycle_driver_factory( run_states: StateStore, @@ -2595,13 +2493,17 @@ def _reconcile_existing_control( return control.reconcile_from_run(task, current, state_dir=state_dir) -def _state_store_for_run( - parsed: argparse.Namespace, state_root: Path -) -> StateStore | FaultInjectingStateStore: - crash_after_save = getattr(parsed, "crash_after_save", None) - if isinstance(crash_after_save, int): - return FaultInjectingStateStore(state_root, crash_after_save=crash_after_save) - return StateStore(state_root) +def _require_managed_state( + states: StateStore, + git: GitRepository, + control_record: Mapping[str, Any] | None, +) -> None: + canonical_root = workspace_state_root(git.root).resolve() + bound_root = _control_state_root(control_record) + if states.root.resolve() != canonical_root or ( + bound_root is not None and bound_root != canonical_root + ): + raise TaskControlError("任务指向非统一状态目录;不会恢复或写入旧任务目录") def _control_state_root(control_record: Mapping[str, Any] | None) -> Path | None: @@ -2691,7 +2593,7 @@ def _reconcile_resume_exit( if host is None or current.get("status") == "abandoned": return current task = _task_for_parent(parsed, github, git) - control = TaskControlStore(git.root / ".agent-run") + control = TaskControlStore(workspace_state_root(git.root)) try: record = control.inspect_run(task, current) except TaskControlError: @@ -2787,7 +2689,7 @@ def _resume_action_is_attachable( task = _task_for_parent(parsed, github, git) try: - record = TaskControlStore(git.root / ".agent-run").load(task) + record = TaskControlStore(workspace_state_root(git.root)).load(task) except TaskControlError: return False action = record.get("action") if isinstance(record, Mapping) else None @@ -2828,7 +2730,7 @@ def _lifecycle_action_is_attachable( task = _task_for_parent(parsed, github, git) try: - record = TaskControlStore(git.root / ".agent-run").load(task) + record = TaskControlStore(workspace_state_root(git.root)).load(task) except TaskControlError: record = None if record is None: @@ -2907,7 +2809,7 @@ def _reject_if_task_action_pending( if not isinstance(expected, str) or git.checkout_identity() != expected: return None task = TaskKey(git.root, repository, number) - control = TaskControlStore(git.root / ".agent-run") + control = TaskControlStore(workspace_state_root(git.root)) receipt = ( run_state.get("action_application_receipt") if run_state is not None @@ -3010,12 +2912,7 @@ def _load_read_only_run(parsed: argparse.Namespace) -> dict[str, object]: "否则请从目标仓库运行无参数命令。", [], ) - selector_root: Path | None = None - if parsed.repo is None: - try: - selector_root = GitRepository.discover(Path.cwd()).root - except GitError: - pass + selector_root = selected_repository_root(parsed.repo, parsed.github_fixture) records = _selector_records(parsed, read_only=True) public, state = _select_one_record( records, @@ -3134,13 +3031,13 @@ def _reject_local_repository_mismatch( if repository is None: return _validate_repository_name(repository) - local_records = _read_state_directory(git.root / ".agent-run", git.root) + local_records = _read_state_directory(workspace_state_root(git.root), git.root) mismatches = [ public for public, state in local_records if state is not None and public.get("parent") == parent_number - and public.get("repository") != repository + and not same_repository(public.get("repository"), repository) ] if mismatches: raise _selector_error( @@ -3178,7 +3075,7 @@ def _select_attached_action_run( if len(repositories) != 1: raise selection_error repository = next(iter(repositories)) - record = TaskControlStore(git.root / ".agent-run").load( + record = TaskControlStore(workspace_state_root(git.root)).load( TaskKey(git.root, repository, parent_number) ) action = record.get("action") if isinstance(record, Mapping) else None @@ -3271,7 +3168,7 @@ def _resolve_resume_selection( if not isinstance(repository, str) or parent_value != parent_number: raise selection_error task = TaskKey(git.root, repository, parent_number) - record = TaskControlStore(git.root / ".agent-run").load(task) + record = TaskControlStore(workspace_state_root(git.root)).load(task) action = record.get("action") if isinstance(record, Mapping) else None executor = record.get("executor") if isinstance(record, Mapping) else None executor_active = ( @@ -3309,15 +3206,15 @@ def _selected_local_run_id( ): raise _selector_error( "run_selector_requires_checkout", - f"{command} 选择到的 Run 不属于当前 checkout;请切换到候选工作目录," - "或使用完整 Run ID 与 --state-dir 走精确操作路径。", + f"{command} 选择到的 Run 不属于此仓库的 Runner 工作区;" + "请使用 --repo 指定正确的仓库。", [selected], ) selected_state_dir = selected.get("state_dir") expected_state_dir = ( Path(parsed.state_dir).resolve() if parsed.state_dir - else (git.root / ".agent-run").resolve() + else (workspace_state_root(git.root)).resolve() ) if ( not isinstance(selected_state_dir, str) @@ -3325,8 +3222,8 @@ def _selected_local_run_id( ): raise _selector_error( "run_selector_requires_state_dir", - f"{command} 选择到的 Run 不在当前使用的 state directory;" - "请显式提供候选的 --state-dir 与当前 checkout。", + f"{command} 选择到的 Run 不在 Runner 的统一状态目录中;" + "不支持继续执行旧目录中的任务。", [selected], ) return selected_run_id @@ -3347,17 +3244,17 @@ def _load_exact_read_only_run( state = None else: try: - git = GitRepository.discover(Path.cwd()) - except GitError: + git = open_workspace(parsed.repo, parsed.github_fixture, create=False) + except (GitError, RunLocatorError): git = None state = None if git is not None: - state = _load_read_only_state(StateStore(git.root / ".agent-run"), run_id) + state = _load_read_only_state(StateStore(workspace_state_root(git.root)), run_id) if state is not None and state.get("run_id") != run_id: state = None elif state is not None: repository_root = git.root - state_root = git.root / ".agent-run" + state_root = workspace_state_root(git.root) if state is None: locator = RunLocatorIndex.default() state_dir = locator.resolve_state_dir(run_id) @@ -3418,10 +3315,10 @@ def _with_executor_control( return projected task = TaskKey(repository_root, repository, parent_number) try: - record = TaskControlStore(repository_root / ".agent-run").inspect_run( + record = TaskControlStore(workspace_state_root(repository_root)).inspect_run( task, state ) - except (OSError, TaskControlError): + except (GitError, OSError, TaskControlError): projected["_executor_control"] = { "activity": "unknown", "reason": "task_control_invalid", @@ -3490,7 +3387,7 @@ def _with_executor_control( try: observation = observe_systemd_executor( spec, - TaskControlStore(repository_root / ".agent-run"), + TaskControlStore(workspace_state_root(repository_root)), runtime_directory=_executor_runtime_directory(), executor_python=Path(sys.executable), ) @@ -3602,21 +3499,19 @@ def _selector_records( records = [ record for record in records - if record[0].get("repository") == repository + if same_repository(record[0].get("repository"), repository) or record[0].get("repository") is None ] return records - if current_root is None and repository is None: - try: - current_root = GitRepository.discover(Path.cwd()).root - except GitError as error: + if current_root is None: + current_root = selected_repository_root(repository, parsed.github_fixture) + if current_root is None and repository is None: raise _selector_error( "run_selector_context", - "无法从当前目录确定仓库;请使用 --repo --parent ," - "或显式提供 --state-dir。", + "无法从当前目录确定仓库;请使用 --repo --parent 。", [], - ) from error + ) locator = RunLocatorIndex.default() entries = locator.entries() @@ -3632,8 +3527,8 @@ def _selector_records( # A current checkout is an already-known, bounded location. It remains a # useful fallback when an older/newly interrupted Run has not completed its # locator registration, but it never replaces an indexed entry. - if current_root is not None: - local_root = current_root.resolve() / ".agent-run" + if current_root is not None and current_root.is_dir(): + local_root = workspace_state_root(current_root.resolve()) indexed_runs = { (entry["run_id"], Path(entry["state_dir"]).resolve()) for entry in entries @@ -3653,12 +3548,12 @@ def _selector_records( # Resolve local identity only after the bounded fallback is included. # Otherwise an unregistered local Run can silently lose other clones. if repository is None: - repository = _current_repository_name(records, str(current_root.resolve())) + repository = workspace_for_root(current_root.resolve()).repository if repository is not None: records = [ record for record in records - if record[0].get("repository") in (None, repository) + if (record[0].get("repository") is None or same_repository(record[0].get("repository"), repository)) ] elif current_root is not None: # With no Repository identity, only this checkout is a known target. @@ -3674,13 +3569,13 @@ def _selector_records( def _repository_root_for_state_dir(state_dir: Path) -> Path | None: """Resolve a state directory to a verified checkout when possible.""" - if state_dir.name == ".agent-run": + if state_dir.name == "state": + root = state_dir.parent / "repository" try: - root = GitRepository.discover(state_dir.parent).root + if workspace_state_root(root) == state_dir: + return root except (GitError, OSError): - root = None - if root is not None and state_dir == root / ".agent-run": - return root + pass try: entries = RunLocatorIndex.default().entries() @@ -3701,40 +3596,11 @@ def _repository_root_for_state_dir(state_dir: Path) -> Path | None: return root if root == recorded_root else None -def _current_repository_name( - records: list[tuple[dict[str, object], dict[str, Any] | None]], - repository_root: str, -) -> str | None: - repository_hint = GhGitHubReader( - working_directory=Path(repository_root) - ).repository_hint() - if repository_hint is not None: - return repository_hint - repositories = { - public["repository"] - for public, state in records - if public.get("repository_root") == repository_root - and state is not None - and isinstance(public.get("repository"), str) - } - if len(repositories) == 1: - repository = next(iter(repositories)) - assert isinstance(repository, str) - return repository - return None - - def _verified_locator_checkout( entry: LocatorEntry, ) -> tuple[GitRepository, str | None]: - recorded_root = Path(entry["repository_root"]).resolve() - checkout = GitRepository.discover(recorded_root) - if checkout.root != recorded_root: - raise GitError("定位索引记录的 checkout 根目录不一致") - repository = GhGitHubReader( - working_directory=checkout.root - ).repository_hint() - return checkout, repository + workspace = workspace_for_root(Path(entry["repository_root"]).resolve()) + return workspace.open(), workspace.repository def _read_state_directory( @@ -3833,7 +3699,7 @@ def _read_locator_entry( if state is not None and "repository" in entry: parent = state.get("parent") if ( - state.get("repository") != entry["repository"] + not same_repository(state.get("repository"), entry["repository"]) or not isinstance(parent, dict) or parent.get("number") != entry["parent_number"] ): @@ -3865,7 +3731,7 @@ def _read_locator_entry( ) candidate = _candidate(entry, error=state_error) if checkout_repository is not None: - if "repository" in entry and entry["repository"] != checkout_repository: + if "repository" in entry and not same_repository(entry["repository"], checkout_repository): return _candidate( entry, error="checkout repository 与定位索引不一致", @@ -3890,7 +3756,7 @@ def _read_locator_entry( None, ) state_repository = state.get("repository") - if checkout_repository is not None and state_repository != checkout_repository: + if checkout_repository is not None and not same_repository(state_repository, checkout_repository): return _candidate( entry, state=state, @@ -4091,7 +3957,7 @@ def _validate_repository_selector( repository = parsed.repo if repository is not None: _validate_repository_name(repository) - if state.get("repository") != repository: + if not same_repository(state.get("repository"), repository): parent = state.get("parent") candidate = { "parent": ( @@ -4103,7 +3969,7 @@ def _validate_repository_selector( "repository_root": "当前 checkout", "run_id": run_id, "started_at": state.get("created_at"), - "state_dir": parsed.state_dir or "当前 checkout/.agent-run", + "state_dir": parsed.state_dir or "Runner 统一状态目录", "status": state.get("status"), } raise _selector_error( @@ -4187,7 +4053,7 @@ def _add_common_options(parser: argparse.ArgumentParser) -> None: ) parser.add_argument( "--state-dir", - help="运行状态目录;默认是仓库根目录下的 .agent-run", + help="显式读取状态目录;变更命令仅允许使用 Runner 的统一状态目录", ) parser.add_argument( "--github-fixture", @@ -4495,11 +4361,11 @@ def _profile_state_root(parsed: argparse.Namespace) -> Path: if parsed.state_dir: return Path(parsed.state_dir).resolve() try: - git = GitRepository.discover(Path.cwd()) - except GitError: + git = open_workspace(parsed.repo, parsed.github_fixture, create=False) + except (GitError, RunLocatorError): git = None if git is not None: - local_root = git.root / ".agent-run" + local_root = workspace_state_root(git.root) if StateStore(local_root).load_run(parsed.run_id) is not None: return local_root return RunLocatorIndex.default().resolve_state_dir(parsed.run_id) diff --git a/src/agent_run/codex.py b/src/agent_run/codex.py index 71766b0..96800a5 100644 --- a/src/agent_run/codex.py +++ b/src/agent_run/codex.py @@ -45,7 +45,7 @@ from agent_run.error_safety import bounded_error from agent_run.execution_binding import emit_execution_binding from agent_run.executor_environment import ensure_executor_runtime_directory -from agent_run.run_locator import RunLocatorIndex +from agent_run.paths import app_data_root from agent_run.worker_sandbox import ( WorkerDeadlineExceeded, WorkerSandboxError, @@ -1102,11 +1102,7 @@ def _worker_hidden_paths( private_key_path = getattr(profile, "private_key_path", None) if isinstance(private_key_path, Path): paths.append(private_key_path) - data_home = Path( - os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share") - ).expanduser() - paths.append(data_home / "agent-run") - paths.append(RunLocatorIndex.default().path.parent) + paths.append(app_data_root()) paths.append(ensure_executor_runtime_directory()) runtime_home = Path( os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.geteuid()}") diff --git a/src/agent_run/controller.py b/src/agent_run/controller.py index d4ce2b0..4f99c85 100644 --- a/src/agent_run/controller.py +++ b/src/agent_run/controller.py @@ -41,7 +41,7 @@ wait_for_github_convergence, wait_for_github_refresh, ) -from agent_run.models import DeliveryGraph, Repository +from agent_run.models import DeliveryGraph, Repository, same_repository from agent_run.operator_gate import ( has_mechanical_revision_restart, has_non_invocation_execution_failure, @@ -762,7 +762,7 @@ def record_execution_failure(self, run_id: str, message: str) -> bool: if ( isinstance(repository_hint, str) and repository_hint - and state.get("repository") != repository_hint + and not same_repository(state.get("repository"), repository_hint) ): return False try: @@ -771,7 +771,7 @@ def record_execution_failure(self, run_id: str, message: str) -> bool: repository = None if ( repository is not None - and state.get("repository") != repository.name_with_owner + and not same_repository(state.get("repository"), repository.name_with_owner) ): return False active = state.get("active_agent_invocation") @@ -845,7 +845,7 @@ def record_deterministic_contradiction( if ( isinstance(repository_hint, str) and repository_hint - and state.get("repository") != repository_hint + and not same_repository(state.get("repository"), repository_hint) ): return False state.pop("supervision_window", None) @@ -877,7 +877,7 @@ def _load_bound_run( state = self._load_run(run_id) if state is None else state self._require_current_checkout(state) repository = self.github.repository() - if state.get("repository") != repository.name_with_owner: + if not same_repository(state.get("repository"), repository.name_with_owner): raise ValueError( "configured GitHub repository does not match the Delivery Run" ) diff --git a/src/agent_run/executor_environment.py b/src/agent_run/executor_environment.py index b0b5ce6..0fddcf4 100644 --- a/src/agent_run/executor_environment.py +++ b/src/agent_run/executor_environment.py @@ -11,6 +11,7 @@ from typing import Mapping, Sequence from agent_run.executor_host import ExecutorSpec +from agent_run.paths import app_runtime_root DEFAULT_MAX_ENVIRONMENT_BYTES = 1024 * 1024 DEFAULT_CARRIER_TTL_SECONDS = 30.0 @@ -31,11 +32,7 @@ class CapturedExecutorEnvironment: def default_executor_runtime_directory( environment: Mapping[str, str] | None = None, ) -> Path: - selected = os.environ if environment is None else environment - runtime = selected.get("XDG_RUNTIME_DIR") - if runtime: - return Path(runtime).expanduser() / "agent-run" / "executor" - return Path("/tmp") / f"agent-run-{os.geteuid()}" / "executor" + return app_runtime_root(environment) / "executor" def ensure_executor_runtime_directory( diff --git a/src/agent_run/final_approval_operation.py b/src/agent_run/final_approval_operation.py index e4bdaf3..ff5b0bd 100644 --- a/src/agent_run/final_approval_operation.py +++ b/src/agent_run/final_approval_operation.py @@ -7,6 +7,7 @@ from typing import Any from agent_run.task_control import TaskControlStore, TaskKey, action_receipt_matches +from agent_run.managed_workspace import workspace_state_root def has_final_approval(state: Mapping[str, Any]) -> bool: @@ -81,7 +82,7 @@ def has_unfinished_final_receipt(state: Mapping[str, Any], workspace: Path) -> b repository = state.get("repository") if type(number) is not int or not isinstance(repository, str): return False - control = TaskControlStore(workspace / ".agent-run") + control = TaskControlStore(workspace_state_root(workspace)) task = TaskKey(workspace, repository, number) record = control.load(task) action = record.get("action") if isinstance(record, Mapping) else None diff --git a/src/agent_run/git_output.py b/src/agent_run/git_output.py index b047e34..ef6f273 100644 --- a/src/agent_run/git_output.py +++ b/src/agent_run/git_output.py @@ -15,6 +15,21 @@ _TRUNCATED = "[Git output truncated]\n" +def git_environment() -> dict[str, str]: + """Keep authentication, but prevent inherited repository redirection.""" + locations = { + "GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_NAMESPACE", "GIT_TEMPLATE_DIR", "GIT_CONFIG_COUNT", + "GIT_CONFIG_PARAMETERS", + } + return { + key: value for key, value in os.environ.items() + if key not in locations + and not key.startswith(("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")) + } | {"GIT_OPTIONAL_LOCKS": "0"} + + def run_git( arguments: list[str], *, cwd: Path, input: str | None = None ) -> subprocess.CompletedProcess[str]: @@ -27,6 +42,7 @@ def run_git( process = subprocess.Popen( arguments, cwd=cwd, stdin=subprocess.PIPE if input is not None else None, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True, + env=git_environment(), ) stdout, stderr = bytearray(), bytearray() overflow = {"stdout": False, "stderr": False} diff --git a/src/agent_run/github.py b/src/agent_run/github.py index 449b140..9eec15b 100644 --- a/src/agent_run/github.py +++ b/src/agent_run/github.py @@ -7,6 +7,7 @@ from urllib.parse import urlparse from agent_run.github_retry import run_read_command +from agent_run.git_output import git_environment from agent_run.models import Blocker, DeliveryGraph, Issue, ParentIssue, Repository @@ -59,9 +60,10 @@ def __init__( self._repository: Repository | None = None def repository_hint(self) -> str | None: - return self.repository_override or _repository_hint_from_origin( + identity = self.repository_override or _repository_hint_from_origin( self.working_directory ) + return identity.lower() if identity else None def repository(self) -> Repository: arguments = ["repo", "view"] @@ -301,6 +303,7 @@ def _repository_hint_from_origin(working_directory: Path | None) -> str | None: text=True, capture_output=True, check=False, + env=git_environment(), ) if result.returncode != 0: return None diff --git a/src/agent_run/github_auth_profile.py b/src/agent_run/github_auth_profile.py index 9850026..b96211e 100644 --- a/src/agent_run/github_auth_profile.py +++ b/src/agent_run/github_auth_profile.py @@ -11,6 +11,8 @@ from dataclasses import dataclass from pathlib import Path +from agent_run.paths import app_config_root + class GitHubAuthProfileError(ValueError): """The local GitHub App profile is missing, invalid, or unsafe to use.""" @@ -48,14 +50,10 @@ def __init__(self, path: Path | None = None) -> None: @classmethod def default_path(cls) -> Path: - config_home = os.environ.get("XDG_CONFIG_HOME") - if config_home: - root = Path(config_home).expanduser() - if not root.is_absolute(): - raise GitHubAuthProfileError("XDG_CONFIG_HOME 必须是绝对路径") - else: - root = Path.home() / ".config" - return root / cls._DIRECTORY_NAME / cls._FILE_NAME + try: + return app_config_root() / cls._FILE_NAME + except ValueError as error: + raise GitHubAuthProfileError(str(error)) from error def load(self) -> GitHubAppProfile | None: if self._profile_directory_metadata() is None: diff --git a/src/agent_run/github_fixture.py b/src/agent_run/github_fixture.py index a7c037c..8e160f1 100644 --- a/src/agent_run/github_fixture.py +++ b/src/agent_run/github_fixture.py @@ -89,7 +89,7 @@ def repository(self) -> Repository: def repository_hint(self) -> str | None: self.data = self._load() repository = self.data.get("repository") - return repository if isinstance(repository, str) else None + return repository.lower() if isinstance(repository, str) else None def delivery_graph(self, parent_number: int) -> DeliveryGraph: return self._delivery_graph_once(parent_number) @@ -1093,7 +1093,7 @@ def required_check_evidence( ] evidence["checks"] = annotate_configured_code_failures( normalized_checks, - self.path.parent, + self.git.root, expected_head_sha=expected_head_sha, ) return evidence diff --git a/src/agent_run/github_retry.py b/src/agent_run/github_retry.py index 474e247..95602bb 100644 --- a/src/agent_run/github_retry.py +++ b/src/agent_run/github_retry.py @@ -9,6 +9,8 @@ from collections.abc import Callable from pathlib import Path +from agent_run.git_output import git_environment + MAX_READ_ATTEMPTS = 3 READ_TIMEOUT_SECONDS = 30 @@ -79,6 +81,7 @@ def _run_bounded_command( stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True, + env=git_environment(), ) stdout = bytearray() stderr = bytearray() diff --git a/src/agent_run/managed_workspace.py b/src/agent_run/managed_workspace.py new file mode 100644 index 0000000..4a73389 --- /dev/null +++ b/src/agent_run/managed_workspace.py @@ -0,0 +1,273 @@ +"""Independent, repository-scoped clones owned exclusively by the Runner.""" + +from __future__ import annotations + +import fcntl +import json +import os +import re +import selectors +import shutil +import subprocess +import tempfile +import time +from dataclasses import dataclass +from pathlib import Path + +from agent_run.git import GitRepository +from agent_run.git_errors import GitError +from agent_run.git_output import git_environment, run_git +from agent_run.paths import app_data_root +from agent_run.process_cleanup import terminate_process_group + +_MARKER = ".agent-run-workspace.json" +_CLONE_TIMEOUT_SECONDS = 15 * 60 + + +class ManagedWorkspaceError(GitError): + """The Runner cannot safely open or create its independent clone.""" + + +def normalize_repository(repository: str) -> str: + parts = repository.split("/") + if ( + len(parts) != 2 + or not re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_-]*", parts[0]) + or not re.fullmatch(r"[A-Za-z0-9_.-]+", parts[1]) + or parts[1] in {".", ".."} + ): + raise ManagedWorkspaceError("仓库必须使用 owner/name 格式") + return repository.lower() + + +@dataclass(frozen=True) +class ManagedWorkspace: + repository: str + root: Path + + @classmethod + def for_repository(cls, repository: str) -> ManagedWorkspace: + canonical = normalize_repository(repository) + return cls(canonical, app_data_root() / "repositories" / canonical) + + @property + def repository_root(self) -> Path: + return self.root / "repository" + + @property + def state_root(self) -> Path: + return self.root / "state" + + def open(self) -> GitRepository: + """Validate an existing workspace without creating or updating anything.""" + validate_data_root() + document = self._owned_document() + git_directory = self.repository_root / ".git" + roots = _command( + ["git", "rev-parse", "--path-format=absolute", "--git-common-dir", "--show-toplevel"], + self.repository_root, + ).splitlines() + origin = _command( + ["git", "remote", "get-url", "origin"], self.repository_root + ).strip() + if ( + roots != [str(git_directory), str(self.repository_root)] + or origin != document["remote_url"] + ): + raise ManagedWorkspaceError("Runner 仓库的 Git 数据或远端被替换") + return GitRepository(self.repository_root) + + def _owned_document(self) -> dict[str, str]: + _check_owned_path(self.root) + _check_owned_path(self.repository_root / ".git" / "objects") + _check_owned_path(self.state_root) + marker = self.root / _MARKER + try: + if marker.is_symlink() or not marker.is_file() or marker.stat().st_size > 16384: + raise ManagedWorkspaceError("已有目录不属于 Runner 工作区") + document = json.loads(marker.read_text(encoding="utf-8")) + if ( + not isinstance(document, dict) + or set(document) != {"repository", "remote_url"} + or document["repository"] != self.repository + or not isinstance(document["remote_url"], str) + ): + raise ManagedWorkspaceError("Runner 工作区标记与仓库身份不一致") + git_directory = self.repository_root / ".git" + if not git_directory.is_dir() or not self.state_root.is_dir(): + raise ManagedWorkspaceError("Runner 工作区不完整") + if (git_directory / "objects" / "info" / "alternates").exists(): + raise ManagedWorkspaceError("Runner 仓库不能共享外部 Git objects") + return {"repository": self.repository, "remote_url": document["remote_url"]} + except (OSError, ValueError) as error: + raise ManagedWorkspaceError("无法读取 Runner 工作区") from error + + def ensure( + self, remote_url: str | None = None, *, identity: dict[str, str] | None = None, + ) -> GitRepository: + """Create once; never reset, fetch or modify an existing checkout.""" + _check_owned_path(self.root) + if self.root.exists(): + return self.open() + validate_data_root() + _make_owned_directory(self.root.parent) + lock = app_data_root() / "repositories" / ".locks" / (self.repository + ".lock") + _make_owned_directory(lock.parent) + descriptor = os.open(lock, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, "a+") as lock_file: + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as error: + raise ManagedWorkspaceError("正在创建该仓库的 Runner 工作区,请稍后重试") from error + _check_owned_path(self.root) + if self.root.exists(): + return self.open() + staging = Path(tempfile.mkdtemp(prefix=f".{self.root.name}-", dir=self.root.parent)) + try: + checkout = staging / "repository" + command = ( + ["git", "clone", "--no-local", "--quiet", "--", remote_url, str(checkout)] + if remote_url is not None else + ["gh", "repo", "clone", self.repository, str(checkout), "--", "--no-local", "--quiet"] + ) + _command(command, staging, timeout=_CLONE_TIMEOUT_SECONDS) + for key, value in (identity or {}).items(): + if key not in {"user.name", "user.email"}: + raise ManagedWorkspaceError("不支持复制此 Git 配置") + _command(["git", "config", "--local", key, value], checkout) + origin = _command(["git", "remote", "get-url", "origin"], checkout).strip() + (staging / "state").mkdir(mode=0o700) + (staging / _MARKER).write_text( + json.dumps({"repository": self.repository, "remote_url": origin}) + "\n", + encoding="utf-8", + ) + _check_owned_path(self.root) + if self.root.exists(): + raise ManagedWorkspaceError("Runner 工作区目标已被占用") + staging.rename(self.root) + finally: + if staging.exists(): + shutil.rmtree(staging) + return self.open() + + +def workspace_state_root(repository_root: Path) -> Path: + return workspace_for_root(repository_root).state_root + + +def read_git_identity(directory: Path) -> dict[str, str]: + """Read only author identity; never copy hooks, credentials or Git paths.""" + identity: dict[str, str] = {} + for key in ("user.name", "user.email"): + result = run_git(["git", "config", "--get", key], cwd=directory) + if result.returncode == 0 and result.stdout.strip(): + identity[key] = result.stdout.strip() + return identity + + +def workspace_for_root(repository_root: Path) -> ManagedWorkspace: + """Accept only a canonical, owned clone; never fall back to a user checkout.""" + base = app_data_root() / "repositories" + try: + parts = repository_root.relative_to(base).parts + except ValueError as error: + raise ManagedWorkspaceError("该仓库不是 Runner 受管仓库") from error + if len(parts) != 3 or parts[-1] != "repository": + raise ManagedWorkspaceError("该仓库不是 Runner 受管仓库") + workspace = ManagedWorkspace.for_repository("/".join(parts[:2])) + if workspace.repository_root != repository_root: + raise ManagedWorkspaceError("Runner 仓库路径不规范") + workspace._owned_document() + return workspace + + +def _check_owned_path(path: Path) -> None: + base = app_data_root() + try: + relative = path.relative_to(base) + except ValueError as error: + raise ManagedWorkspaceError("Runner 工作区超出数据目录") from error + current = base + for component in ("", *relative.parts): + current = current / component + if current.is_symlink() or (current.exists() and not current.is_dir()): + raise ManagedWorkspaceError(f"Runner 工作区路径不是独立目录:{current}") + + +def _make_owned_directory(path: Path) -> None: + _check_owned_path(path) + base = app_data_root() + base.mkdir(mode=0o700, parents=True, exist_ok=True) + current = base + for component in path.relative_to(base).parts: + current = current / component + current.mkdir(mode=0o700, exist_ok=True) + + +def validate_data_root() -> None: + """Reject application data inside a user checkout, without creating paths.""" + ancestor = app_data_root() + while not ancestor.exists(): + ancestor = ancestor.parent + result = run_git(["git", "rev-parse", "--absolute-git-dir"], cwd=ancestor) + if result.returncode != 0: + return + raise ManagedWorkspaceError("Runner 数据目录必须位于用户仓库之外,请调整 XDG_DATA_HOME") + + +def _command(arguments: list[str], cwd: Path, *, timeout: float = 30) -> str: + environment = git_environment() + environment["GIT_TERMINAL_PROMPT"] = "0" + environment["GH_PROMPT_DISABLED"] = "1" + try: + process = subprocess.Popen( + arguments, cwd=cwd, env=environment, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True, + ) + except OSError as error: + raise ManagedWorkspaceError("无法启动 Runner 仓库操作,请检查 git/gh 安装") from error + output = {"stdout": bytearray(), "stderr": bytearray()} + deadline = time.monotonic() + timeout + assert process.stdout is not None and process.stderr is not None + try: + with selectors.DefaultSelector() as selector: + for stream, name in ((process.stdout, "stdout"), (process.stderr, "stderr")): + os.set_blocking(stream.fileno(), False) + selector.register(stream, selectors.EVENT_READ, name) + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ManagedWorkspaceError("Runner 独立仓库操作超时;可重新运行") + for key, _ in selector.select(timeout=min(remaining, 0.1)): + chunk = os.read(key.fd, 16384) + if not chunk: + selector.unregister(key.fileobj) + continue + output[key.data].extend(chunk) + del output[key.data][:-16384] + if selector.get_map() and process.poll() is not None: + terminate_process_group(process) + process.wait(timeout=max(0.001, deadline - time.monotonic())) + except subprocess.TimeoutExpired as error: + raise ManagedWorkspaceError("Runner 独立仓库操作超时;可重新运行") from error + finally: + terminate_process_group(process) + process.stdout.close() + process.stderr.close() + if process.returncode != 0: + diagnostic = output["stderr"].decode("utf-8", errors="replace").lower() + reason = "请检查远端访问权限及 git/gh 认证" + for needle, message in ( + ("repository not found", "仓库不存在或当前身份无权访问"), + ("does not exist", "远端仓库不存在"), + ("permission denied", "远端拒绝访问,请检查认证"), + ("authentication failed", "远端认证失败"), + ("could not resolve host", "无法解析远端主机"), + ("failed to connect", "无法连接远端主机"), + ("no space left", "数据目录磁盘空间不足"), + ): + if needle in diagnostic: + reason = message + break + raise ManagedWorkspaceError(f"Runner 独立仓库操作失败(退出码 {process.returncode}):{reason}") + return output["stdout"].decode("utf-8") diff --git a/src/agent_run/models.py b/src/agent_run/models.py index 42f900f..5d1e373 100644 --- a/src/agent_run/models.py +++ b/src/agent_run/models.py @@ -35,6 +35,11 @@ class Repository: default_head_sha: str | None +def same_repository(left: object, right: object) -> bool: + """GitHub owner/name is case-insensitive; retain its display spelling.""" + return isinstance(left, str) and isinstance(right, str) and left.lower() == right.lower() + + @dataclass(frozen=True) class DeliveryGraph: parent: ParentIssue diff --git a/src/agent_run/paths.py b/src/agent_run/paths.py new file mode 100644 index 0000000..baf894f --- /dev/null +++ b/src/agent_run/paths.py @@ -0,0 +1,35 @@ +"""Shared locations for Runner-owned data, configuration and temporary files.""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from pathlib import Path + + +def _directory( + variable: str, fallback: Path, environment: Mapping[str, str] | None +) -> Path: + selected = os.environ if environment is None else environment + value = selected.get(variable) + root = Path(value).expanduser() if value else fallback + if not root.is_absolute(): + raise ValueError(f"{variable} 必须是绝对路径") + return Path(os.path.normpath(root)) + + +def app_data_root(environment: Mapping[str, str] | None = None) -> Path: + return _directory( + "XDG_DATA_HOME", Path.home() / ".local" / "share", environment + ).resolve() / "agent-run" + + +def app_config_root(environment: Mapping[str, str] | None = None) -> Path: + return _directory("XDG_CONFIG_HOME", Path.home() / ".config", environment) / "agent-run" + + +def app_runtime_root(environment: Mapping[str, str] | None = None) -> Path: + selected = os.environ if environment is None else environment + if selected.get("XDG_RUNTIME_DIR"): + return _directory("XDG_RUNTIME_DIR", Path("/tmp"), selected) / "agent-run" + return Path("/tmp") / f"agent-run-{os.geteuid()}" diff --git a/src/agent_run/process_cleanup.py b/src/agent_run/process_cleanup.py index 21391ef..84de981 100644 --- a/src/agent_run/process_cleanup.py +++ b/src/agent_run/process_cleanup.py @@ -48,7 +48,11 @@ def terminate_process_group( ) -> None: """Kill a process group and descendants that deliberately changed session.""" - descendants = set(descendant_pids(process.pid)) + # Once reaped, this child cannot retain descendants; any survivors were + # reparented. Its process group must still be killed below. + descendants = ( + set(descendant_pids(process.pid)) if process.poll() is None else set() + ) if adopted_baseline is not None: descendants.update( pid @@ -77,12 +81,12 @@ def terminate_process_group( process.wait(timeout=timeout) except (OSError, subprocess.SubprocessError): pass + if adopted_baseline is None: + _reap_children(descendants) + return for _ in range(100): adopted = set(descendant_pids(os.getpid())) - if adopted_baseline is not None: - adopted.difference_update(adopted_baseline) - else: - adopted.clear() + adopted.difference_update(adopted_baseline) if not adopted: _reap_children(descendants) return diff --git a/src/agent_run/required_checks.py b/src/agent_run/required_checks.py index 41c7b68..2ae045c 100644 --- a/src/agent_run/required_checks.py +++ b/src/agent_run/required_checks.py @@ -2,6 +2,7 @@ """Fail-closed classification for Required Check failure evidence.""" +import re import tomllib from pathlib import Path from typing import Any @@ -10,6 +11,7 @@ ensure_supervision_window, wait_for_github_convergence, ) +from agent_run.git_output import run_git def annotate_configured_code_failures( @@ -20,7 +22,7 @@ def annotate_configured_code_failures( ) -> list[dict[str, Any]]: """Bind GitHub job-step facts to repository-owned repairability policy.""" - configured = _configured_code_steps(repository_root) + configured = _configured_code_steps(repository_root, expected_head_sha) annotated: list[dict[str, Any]] = [] for raw in checks: check = dict(raw) @@ -32,12 +34,21 @@ def annotate_configured_code_failures( def _configured_code_steps( - repository_root: Path, + repository_root: Path, expected_head_sha: str | None, ) -> frozenset[tuple[str, str, str]]: - path = repository_root / "pyproject.toml" - if not path.is_file(): + # The persistent clone's checkout can lag behind fetched or merged heads, + # and concurrent tasks may use different versions of this policy. + if expected_head_sha is None or not re.fullmatch( + r"[0-9a-fA-F]{40}|[0-9a-fA-F]{64}", expected_head_sha + ): + return frozenset() + result = run_git( + ["git", "show", "--no-ext-diff", "--no-textconv", f"{expected_head_sha}:pyproject.toml"], + cwd=repository_root, + ) + if result.returncode != 0: return frozenset() - document = tomllib.loads(path.read_text(encoding="utf-8")) + document = tomllib.loads(result.stdout) tool = document.get("tool") agent_run = tool.get("agent-run") if isinstance(tool, dict) else None required = ( diff --git a/src/agent_run/run_lifecycle.py b/src/agent_run/run_lifecycle.py index baa8ba1..e6933da 100644 --- a/src/agent_run/run_lifecycle.py +++ b/src/agent_run/run_lifecycle.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any +from agent_run.models import same_repository from agent_run.agent_invocation import ( record_session_interruption, session_interruption_is_persisted, @@ -750,7 +751,7 @@ def _reload_no_active_run( ) parent = current.get("parent") if ( - current.get("repository") != self.task.repository + not same_repository(current.get("repository"), self.task.repository) or not isinstance(parent, Mapping) or parent.get("number") != self.task.parent_number ): diff --git a/src/agent_run/run_locator.py b/src/agent_run/run_locator.py index 714c240..ecac017 100644 --- a/src/agent_run/run_locator.py +++ b/src/agent_run/run_locator.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import NotRequired, TypedDict +from agent_run.paths import app_data_root MAX_LOCATOR_ENTRIES = 32 @@ -47,10 +48,7 @@ def __init__( @classmethod def default(cls) -> RunLocatorIndex: - state_home = Path( - os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state") - ).expanduser().resolve() - return cls(state_home / "agent-run" / "run-locator.json") + return cls(app_data_root() / "run-locator.json") def register( self, diff --git a/src/agent_run/runner_installer.py b/src/agent_run/runner_installer.py index af266dc..50c9251 100644 --- a/src/agent_run/runner_installer.py +++ b/src/agent_run/runner_installer.py @@ -28,6 +28,7 @@ sys.dont_write_bytecode = True try: + from agent_run.paths import app_data_root from agent_run.process_cleanup import ( capture_process_scope, child_subreaper, @@ -40,6 +41,7 @@ runner_management_lease, ) except ModuleNotFoundError: # pragma: no cover - used by the source-tree script + from paths import app_data_root # type: ignore[import-not-found, no-redef] from process_cleanup import ( # type: ignore[import-not-found, no-redef] capture_process_scope, child_subreaper, @@ -108,10 +110,7 @@ class InstallPaths: @classmethod def from_environment(cls) -> "InstallPaths": - data_home = os.environ.get("XDG_DATA_HOME") - if not data_home: - data_home = str(Path.home() / ".local" / "share") - data_root = (Path(data_home).expanduser() / "agent-run").resolve() + data_root = app_data_root() user_bin = (Path.home() / ".local" / "bin").resolve() return cls( data_root=data_root, diff --git a/src/agent_run/runner_lease.py b/src/agent_run/runner_lease.py index 5167d4a..db6a7d2 100644 --- a/src/agent_run/runner_lease.py +++ b/src/agent_run/runner_lease.py @@ -12,6 +12,11 @@ from pathlib import Path from typing import Iterator +try: + from agent_run.paths import app_data_root +except ModuleNotFoundError: # pragma: no cover - source-tree installer + from paths import app_data_root # type: ignore[import-not-found, no-redef] + class RunnerLeaseError(RuntimeError): """A Runner lease could not be established safely.""" @@ -30,11 +35,7 @@ def fileno(self) -> int: def default_runner_lock_path(environment: dict[str, str] | None = None) -> Path: - selected = os.environ if environment is None else environment - data_home = selected.get("XDG_DATA_HOME") - if not data_home: - data_home = str(Path.home() / ".local" / "share") - return Path(data_home).expanduser().resolve() / "agent-run" / "install.lock" + return app_data_root(environment) / "install.lock" @contextmanager diff --git a/src/agent_run/state.py b/src/agent_run/state.py index c6d82c1..fd65362 100644 --- a/src/agent_run/state.py +++ b/src/agent_run/state.py @@ -13,6 +13,7 @@ from pathlib import Path from typing import Any, TypeVar, cast +from agent_run.models import same_repository from agent_run.error_safety import bounded_error, redact_credentials from agent_run.semantic_attempt import semantic_attempt_subjects from agent_run.history_check_facts import CHECKS_HISTORY_FIELDS, checks_timeline_facts @@ -286,7 +287,7 @@ def find_run(self, repository: str, parent_number: int) -> dict[str, Any] | None continue parent = loaded.get("parent") if ( - loaded.get("repository") == repository + same_repository(loaded.get("repository"), repository) and isinstance(parent, dict) and parent.get("number") == parent_number ): @@ -307,7 +308,7 @@ def find_unfinished_runs( continue parent = loaded.get("parent") if ( - loaded.get("repository") == repository + same_repository(loaded.get("repository"), repository) and isinstance(parent, dict) and parent.get("number") == parent_number and loaded.get("status") not in {"completed", "abandoned"} diff --git a/src/agent_run/task_control.py b/src/agent_run/task_control.py index a0d633f..14553f6 100644 --- a/src/agent_run/task_control.py +++ b/src/agent_run/task_control.py @@ -72,7 +72,7 @@ def __post_init__(self) -> None: if type(self.parent_number) is not int or self.parent_number <= 0: raise TaskControlError("Delivery Task Parent Issue must be positive") object.__setattr__(self, "workspace", workspace) - object.__setattr__(self, "repository", self.repository.strip()) + object.__setattr__(self, "repository", self.repository.strip().lower()) @property def identity(self) -> dict[str, object]: diff --git a/src/agent_run/user_defaults.py b/src/agent_run/user_defaults.py index d520ceb..f02a886 100644 --- a/src/agent_run/user_defaults.py +++ b/src/agent_run/user_defaults.py @@ -21,6 +21,7 @@ normalize_policy_overrides, resolve_delivery_policy, ) +from agent_run.paths import app_config_root SCOPE_NOTICE = "仅影响之后创建的新 Run,已有 Run 保持原设置" _PROFILE_KEYS = frozenset( @@ -129,11 +130,10 @@ def __init__(self, path: Path | None = None) -> None: @staticmethod def default_path() -> Path: - config_home = os.environ.get("XDG_CONFIG_HOME") - root = Path(config_home).expanduser() if config_home else Path.home() / ".config" - if not root.is_absolute(): - raise UserDefaultsError("XDG_CONFIG_HOME 必须是绝对路径") - return root / "agent-run" / "user-defaults.json" + try: + return app_config_root() / "user-defaults.json" + except ValueError as error: + raise UserDefaultsError(str(error)) from error def _read(self) -> tuple[dict[str, Any], str]: path = self.path if self.path.exists() else self.legacy_path diff --git a/src/agent_run/worker_sandbox.py b/src/agent_run/worker_sandbox.py index 6cfc6f9..3dd6638 100644 --- a/src/agent_run/worker_sandbox.py +++ b/src/agent_run/worker_sandbox.py @@ -14,6 +14,7 @@ from urllib.parse import urlsplit from agent_run.worker_credentials import WORKER_GH_RESPONSE_TIMEOUT_SECONDS +from agent_run.git_output import git_environment class WorkerSandboxError(RuntimeError): @@ -148,7 +149,7 @@ def worker_environment( _validate_inherited_github_host() environment = { key: value - for key, value in os.environ.items() + for key, value in git_environment().items() if not key.startswith(("AGENT_RUN_EXECUTOR_", "AGENT_RUN_INTERNAL_")) and key not in { @@ -369,7 +370,8 @@ def bubblewrap_command( arguments.extend(["--bind", str(checkout), str(checkout)]) else: arguments.extend(["--ro-bind", str(checkout), str(checkout)]) - for git_metadata in _git_metadata_paths(checkout): + git_metadata_paths = _git_metadata_paths(checkout) + for git_metadata in git_metadata_paths: arguments.extend( ["--ro-bind", str(git_metadata), str(git_metadata)] ) @@ -385,12 +387,31 @@ def bubblewrap_command( git_credentials = home / ".git-credentials" if git_credentials.exists(): arguments.extend(["--ro-bind", "/dev/null", str(git_credentials)]) - _mask_worker_paths( + hidden = _mask_worker_paths( arguments, hidden_paths, checkout=checkout, temporary=temporary, ) + # Managed checkouts live below the hidden Runner data root. Reopen only + # this invocation's paths, then restore any narrower credential/state masks. + reopened: list[Path] = [] + for path, binding in ( + (temporary, "--bind"), + (checkout, "--bind" if writable_checkout else "--ro-bind"), + *((path, "--ro-bind") for path in git_metadata_paths), + ): + resolved = path.resolve() + if any(parent in hidden for parent in resolved.parents): + arguments.extend([binding, str(resolved), str(resolved)]) + reopened.append(resolved) + if reopened: + _mask_worker_paths( + arguments, + [path for path in hidden if any(root in path.parents for root in reopened)], + checkout=checkout, + temporary=temporary, + ) if gh_targets: if gh_adapter is None: raise WorkerSandboxError( @@ -408,7 +429,7 @@ def _mask_worker_paths( *, checkout: Path, temporary: Path, -) -> None: +) -> set[Path]: checkout_root = checkout.resolve() temporary_root = temporary.resolve() paths: set[Path] = set() @@ -436,6 +457,7 @@ def _mask_worker_paths( if any(directory in path.parents for directory in mounted_directories): continue arguments.extend(["--ro-bind", "/dev/null", str(path)]) + return paths def _reject_credentialed_http_remotes( diff --git a/src/agent_run/workspace_cli.py b/src/agent_run/workspace_cli.py new file mode 100644 index 0000000..a733922 --- /dev/null +++ b/src/agent_run/workspace_cli.py @@ -0,0 +1,63 @@ +"""Resolve a CLI repository selector without writing its source checkout.""" +from __future__ import annotations + +import os +from pathlib import Path + +from agent_run.git import GitError, GitRepository +from agent_run.github import GhGitHubReader +from agent_run.github_fixture import FixtureGitHubReader +from agent_run.managed_workspace import ManagedWorkspace, read_git_identity +from agent_run.run_locator import RunLocatorError + + +def selected_workspace( + repository: str | None, + fixture: str | None = None, +) -> ManagedWorkspace: + if os.environ.get("GH_HOST", "github.com").lower() not in {"", "github.com"}: + raise GitError("Runner 当前仅支持 github.com 仓库") + reader = ( + FixtureGitHubReader(Path(fixture)) + if fixture else GhGitHubReader(working_directory=Path.cwd()) + ) + identity = repository or reader.repository_hint() + if not identity: + raise RunLocatorError( + "run_selector_context", + "无法从当前目录识别 GitHub 仓库;请使用 --repo 。", + ) + return ManagedWorkspace.for_repository(identity) + + +def open_workspace( + repository: str | None, + fixture: str | None, + *, + create: bool, +) -> GitRepository: + workspace = selected_workspace(repository, fixture) + if create: + # Fixture mode substitutes only the remote transport. It still creates + # an independent clone and exercises the same ownership boundary. + remote = None + if fixture and not workspace.repository_root.exists(): + source = GitRepository.discover(Path.cwd()) + remote = str(source.root) + identity = read_git_identity(Path.cwd()) if not workspace.root.exists() else None + return workspace.ensure(remote_url=remote, identity=identity) + if not workspace.root.exists(): + raise RunLocatorError( + "run_selector_not_found", "此仓库尚无 Runner 工作区;请先运行 run。" + ) + return workspace.open() + + +def selected_repository_root( + repository: str | None, fixture: str | None = None, +) -> Path | None: + """Return a deterministic location; reads never create a workspace.""" + try: + return selected_workspace(repository, fixture).repository_root + except (GitError, RunLocatorError): + return None diff --git a/tests/cli_run_supervision_support.py b/tests/cli_run_supervision_support.py index 0ea5148..ab91b93 100644 --- a/tests/cli_run_supervision_support.py +++ b/tests/cli_run_supervision_support.py @@ -14,6 +14,7 @@ from agent_run.executor_host import _process_start_token from cli_fixtures import run_agents from support.inprocess_cli import invoke_cli_inprocess +from support.workspace import managed_state from test_cli import PROJECT_ROOT, load_only_run_state, stdout_json from test_cli_delivery import parent_publication, passing_acceptance, publication @@ -119,7 +120,6 @@ def _run_until_pending_window( if not environment.get("PYTHONPATH") else f"{source_path}{os.pathsep}{environment['PYTHONPATH']}" ) - environment.setdefault("XDG_STATE_HOME", str(repo / ".agent-run-test-state")) process = subprocess.Popen( [ sys.executable, @@ -184,7 +184,7 @@ def _interrupt_run(process: subprocess.Popen[str], repo: Path) -> None: process.communicate(timeout=3) assert process.returncode is not None executors: list[tuple[int, str]] = [] - for path in (repo / ".agent-run" / "task-control").glob("*.json"): + for path in (managed_state(repo) / "task-control").glob("*.json"): try: record = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): diff --git a/tests/conftest.py b/tests/conftest.py index 11baaba..62889f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ import sys from pathlib import Path from typing import Any +from unittest.mock import patch import pytest @@ -22,6 +23,7 @@ from agent_run.run_locator import RunLocatorIndex from agent_run.state import StateStore from agent_run.task_control import TASK_CONTROL_PROTOCOL, TaskControlStore, TaskKey +from support.workspace import prepare_workspace _GIT_LOCATION_VARIABLES = { @@ -60,6 +62,10 @@ def _user_environment(root: Path) -> dict[str, str]: path = root / directory path.mkdir(parents=True, mode=0o700) environment[variable] = str(path) + (Path(environment["HOME"]) / ".gitconfig").write_text( + "[user]\n\tname = Agent Run Tests\n\temail = agent-run-tests@example.invalid\n", + encoding="utf-8", + ) return environment @@ -157,6 +163,21 @@ def seed_run( extra_env: dict[str, str] | None = None, reuse_existing: bool = True, idle_control: bool = False, +) -> subprocess.CompletedProcess[str]: + with patch.dict(os.environ, extra_env or {}): + return _seed_run( + repo, fixture, parent, *options, + reuse_existing=reuse_existing, idle_control=idle_control, + ) + + +def _seed_run( + repo: Path, + fixture: Path, + parent: str = "1", + *options: str, + reuse_existing: bool = True, + idle_control: bool = False, ) -> subprocess.CompletedProcess[str]: """Create test state through Controller.start without a public CLI command. @@ -216,23 +237,16 @@ def take_option(name: str) -> str | None: if values: raise AssertionError(f"unsupported seed_run options: {values!r}") + reader = FixtureGitHubReader(fixture) + workspace = prepare_workspace(repo, repository=reader.repository_hint()) + managed_git = GitRepository(workspace.repository_root) state_root = ( Path(state_dir_value).resolve() if state_dir_value is not None - else repo / ".agent-run" + else workspace.state_root ) - environment = extra_env or {} - state_home = Path( - environment.get( - "XDG_STATE_HOME", - os.environ.get("XDG_STATE_HOME", repo / ".agent-run-test-state"), - ) - ).expanduser().resolve() config_home = Path( - environment.get( - "XDG_CONFIG_HOME", - os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"), - ) + os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") ).expanduser().resolve() policy_store = DeliveryPolicyStore( config_home / "agent-run" / "delivery-policy.json" @@ -243,10 +257,10 @@ def take_option(name: str) -> str | None: ) profiles = AgentProfileStore(state_root) controller = Controller( - FixtureGitHubReader(fixture), - GitRepository(repo), + reader, + managed_git, StateStore(state_root), - locator=RunLocatorIndex(state_home / "agent-run" / "run-locator.json"), + locator=RunLocatorIndex.default(), profiles=profiles, delivery_policy=policy, ) @@ -254,8 +268,8 @@ def take_option(name: str) -> str | None: run_id = str(state["run_id"]) if idle_control and not resumed: seed_idle_control( - TaskControlStore(repo / ".agent-run"), - TaskKey(repo, str(state["repository"]), int(parent)), + TaskControlStore(workspace.state_root), + TaskKey(managed_git.root, str(state["repository"]), int(parent)), run_id, state_dir=state_root, ) diff --git a/tests/run_acceptance_test_support.py b/tests/run_acceptance_test_support.py index edae801..6edf35b 100644 --- a/tests/run_acceptance_test_support.py +++ b/tests/run_acceptance_test_support.py @@ -205,7 +205,7 @@ def review(self, request: dict[str, Any]) -> ReviewResult: ) def _completed_run( - git_repo: Path, *, ticket_number: int = 2, + git_repo: Path, *, ticket_number: int = 2, state_root: Path | None = None, ) -> tuple[dict[str, Any], StateStore, GitRepository]: ticket_key = str(ticket_number) fixture = write_fixture( @@ -221,7 +221,7 @@ def _completed_run( } }, ) - states = StateStore(git_repo / ".agent-run") + states = StateStore(state_root if state_root is not None else git_repo / ".agent-run") git = GitRepository(git_repo) controller = Controller(FixtureGitHubReader(fixture), git, states) state, _ = controller.start(1) diff --git a/tests/run_merge_resolution_fixture.py b/tests/run_merge_resolution_fixture.py index 7ec0b72..fe7a7c6 100644 --- a/tests/run_merge_resolution_fixture.py +++ b/tests/run_merge_resolution_fixture.py @@ -218,10 +218,12 @@ def publication(self, _request: dict[str, Any]) -> dict[str, str]: class MergeResolutionFixture: - def __init__(self, git_repo: Path, scenario: str) -> None: + def __init__( + self, git_repo: Path, scenario: str, *, state_root: Path | None = None, + ) -> None: self.git_repo = git_repo self.scenario = scenario - self.state, self.states, self.git = _completed_run(git_repo) + self.state, self.states, self.git = _completed_run(git_repo, state_root=state_root) self.fixture = git_repo / "github.json" self.run_branch = str(self.state["run_branch"]) self.candidate_requires_follow_up = scenario in { diff --git a/tests/run_publication_test_support.py b/tests/run_publication_test_support.py index 7e7645b..c42737b 100644 --- a/tests/run_publication_test_support.py +++ b/tests/run_publication_test_support.py @@ -213,9 +213,11 @@ def run_publication(self, request: dict[str, Any]) -> dict[str, Any]: } def _accepted_run( - git_repo: Path, *, ticket_number: int = 2, + git_repo: Path, *, ticket_number: int = 2, state_root: Path | None = None, ) -> tuple[dict[str, Any], Any, Any, FixtureGitHubPublisher]: - state, states, git = _completed_run(git_repo, ticket_number=ticket_number) + state, states, git = _completed_run( + git_repo, ticket_number=ticket_number, state_root=state_root, + ) tree = git.resolve(f"{state['run_branch']}^{{tree}}") integrated = subprocess.run( [ diff --git a/tests/support/inprocess_cli.py b/tests/support/inprocess_cli.py index 4f2c287..825f58c 100644 --- a/tests/support/inprocess_cli.py +++ b/tests/support/inprocess_cli.py @@ -25,7 +25,6 @@ def invoke_cli_inprocess( """ environment = os.environ.copy() - environment.setdefault("XDG_STATE_HOME", str(repo / ".agent-run-test-state")) environment.update(extra_env or {}) command_arguments = [*arguments, "--github-fixture", str(fixture)] stdout, stderr = StringIO(), StringIO() diff --git a/tests/support/published_run.py b/tests/support/published_run.py index 9250c31..8a93b85 100644 --- a/tests/support/published_run.py +++ b/tests/support/published_run.py @@ -10,6 +10,7 @@ from agent_run.state import StateStore from agent_run.task_control import TaskControlStore, TaskKey from conftest import seed_idle_control, seed_run +from support.workspace import managed_repo, managed_state, prepare_workspace from run_publication_test_support import InvocationRunPublicationAgents, _accepted_run @@ -21,8 +22,12 @@ def prepare_accepted_run( The completed Ticket is historical setup. Git commits, Run Acceptance, durable state and CLI ownership registration remain real. """ - state, _, _, _ = _accepted_run(repo, ticket_number=ticket_number) - fixture = repo / "github.json" + workspace = prepare_workspace(repo) + state, _, _, _ = _accepted_run( + workspace.repository_root, ticket_number=ticket_number, + state_root=workspace.state_root, + ) + fixture = workspace.repository_root / "github.json" data = json.loads(fixture.read_text(encoding="utf-8")) data.setdefault("delivery", {}).update(delivery or {}) data["delivery"]["closed_issues"] = [ticket_number] @@ -46,10 +51,10 @@ def prepare_accepted_run( registered = seed_run(repo, fixture) assert registered.returncode == 0, registered.stderr seed_idle_control( - TaskControlStore(repo / ".agent-run"), - TaskKey(repo, str(state["repository"]), 1), + TaskControlStore(managed_state(repo)), + TaskKey(managed_repo(repo), str(state["repository"]), 1), str(state["run_id"]), - state_dir=repo / ".agent-run", + state_dir=managed_state(repo), ) return fixture, state @@ -61,10 +66,10 @@ def prepare_published_run( fixture, state = prepare_accepted_run( repo, ticket_number=ticket_number, delivery=delivery, ) - git = GitRepository(repo) + git = GitRepository(managed_repo(repo)) published = RunPublicationEngine( git=git, - states=StateStore(repo / ".agent-run"), + states=StateStore(managed_state(repo)), agents=InvocationRunPublicationAgents(), github=FixtureGitHubPublisher(fixture, git), default_branch="main", diff --git a/tests/support/workspace.py b/tests/support/workspace.py new file mode 100644 index 0000000..3fbd19f --- /dev/null +++ b/tests/support/workspace.py @@ -0,0 +1,46 @@ +"""Locations and explicit setup for CLI tests' Runner-owned clones.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from unittest.mock import patch + +from agent_run.managed_workspace import ManagedWorkspace, read_git_identity + + +def managed_workspace( + repo: Path, + *, + extra_env: dict[str, str] | None = None, + repository: str | None = None, +) -> ManagedWorkspace: + if repository is None: + fixture = repo / "github.json" + repository = ( + str(json.loads(fixture.read_text(encoding="utf-8"))["repository"]) + if fixture.is_file() else "example/project" + ) + with patch.dict(os.environ, extra_env or {}): + return ManagedWorkspace.for_repository(repository) + + +def managed_state(repo: Path, *, extra_env: dict[str, str] | None = None) -> Path: + return managed_workspace(repo, extra_env=extra_env).state_root + + +def managed_repo(repo: Path, *, extra_env: dict[str, str] | None = None) -> Path: + return managed_workspace(repo, extra_env=extra_env).repository_root + + +def prepare_workspace( + repo: Path, + *, + extra_env: dict[str, str] | None = None, + repository: str | None = None, +) -> ManagedWorkspace: + workspace = managed_workspace(repo, extra_env=extra_env, repository=repository) + with patch.dict(os.environ, extra_env or {}): + workspace.ensure(remote_url=str(repo), identity=read_git_identity(repo)) + return workspace diff --git a/tests/test_active_invocation_run_observation.py b/tests/test_active_invocation_run_observation.py index 66ca10f..8cf99a0 100644 --- a/tests/test_active_invocation_run_observation.py +++ b/tests/test_active_invocation_run_observation.py @@ -9,6 +9,8 @@ import pytest +from support.workspace import managed_repo, managed_state + from agent_run.executor_host import ExecutorSpec from agent_run.run_lifecycle import prepare_action_application_receipt from agent_run.state import StateStore @@ -98,9 +100,9 @@ def test_repeated_public_run_checks_host_while_invocation_is_active( monkeypatch.setenv(key, value) fixture = write_fixture(git_repo / "github.json", issues={}) _fail_parent_run(git_repo, fixture) - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) state = states.find_unfinished_runs("example/project", 1)[0] - task = TaskKey(git_repo, "example/project", 1) + task = TaskKey(managed_repo(git_repo), "example/project", 1) control = TaskControlStore(states.root) initial_control = control.load(task) assert initial_control is not None @@ -117,7 +119,7 @@ def test_repeated_public_run_checks_host_while_invocation_is_active( spec = ExecutorSpec( task=task, run_id=state["run_id"], action_id=claim.action_id, generation=record["action"]["executor_generation"], - state_root=states.root, command=("run", "1"), cwd=git_repo, + state_root=states.root, command=("run", "1"), cwd=managed_repo(git_repo), ) transport = FakeSystemdTransport() host = SystemdUserExecutorHost( diff --git a/tests/test_agent_profiles.py b/tests/test_agent_profiles.py index 35141c8..b1d9ce9 100644 --- a/tests/test_agent_profiles.py +++ b/tests/test_agent_profiles.py @@ -1,5 +1,7 @@ from __future__ import annotations +from support.workspace import managed_repo, managed_state, prepare_workspace + import json import os import subprocess @@ -414,7 +416,7 @@ def test_seeded_run_profile_can_be_updated_by_public_cli( ) assert started.returncode == 0, started.stderr run_id = str(stdout_json(started)["run_id"]) - profile_path = git_repo / ".agent-run" / "profiles" / f"{run_id}.json" + profile_path = managed_state(git_repo) / "profiles" / f"{run_id}.json" profile = json.loads(profile_path.read_text(encoding="utf-8")) assert profile["profile_revision"] == 1 assert profile["profiles"]["development"]["model"] == "gpt-5.6-luna" @@ -543,7 +545,7 @@ def test_public_configuration_during_active_invocation_keeps_old_binding( while not started_file.exists() and time.monotonic() < deadline: time.sleep(0.02) assert started_file.exists() - run_files = list((git_repo / ".agent-run" / "runs").glob("*.json")) + run_files = list((managed_state(git_repo) / "runs").glob("*.json")) assert len(run_files) == 1 run_id = run_files[0].stem @@ -762,7 +764,7 @@ def test_public_cli_drives_run_review_output_repair(git_repo: Path) -> None: time.sleep(0.02) assert started_file.exists() - run_files = list((git_repo / ".agent-run" / "runs").glob("*.json")) + run_files = list((managed_state(git_repo) / "runs").glob("*.json")) assert len(run_files) == 1 run_id = run_files[0].stem diff --git a/tests/test_cli.py b/tests/test_cli.py index 7c015a6..b0fb954 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -33,6 +33,7 @@ from agent_run.worker_sandbox import WorkerSandboxError from conftest import seed_run, write_fixture from support.inprocess_cli import invoke_cli_inprocess +from support.workspace import managed_repo, managed_state, managed_workspace, prepare_workspace PROJECT_ROOT = Path(__file__).parents[1] @@ -152,7 +153,8 @@ def test_status_does_not_fall_back_to_findings_from_another_work_subject( } state["schema_version"] = 1 fixture = write_fixture(git_repo / "github.json", issues={}) - StateStore(git_repo / ".agent-run").save_run("run-1", state) + prepare_workspace(git_repo) + StateStore(managed_state(git_repo)).save_run("run-1", state) result = invoke_cli_inprocess(git_repo, fixture, "status", "run-1", "--json") output = stdout_json(result) @@ -200,7 +202,8 @@ def test_status_binds_run_acceptance_verdict_to_publication_head( } state["schema_version"] = 1 fixture = write_fixture(git_repo / "github.json", issues={}) - StateStore(git_repo / ".agent-run").save_run("run-1", state) + prepare_workspace(git_repo) + StateStore(managed_state(git_repo)).save_run("run-1", state) result = invoke_cli_inprocess(git_repo, fixture, "status", "run-1", "--json") output = stdout_json(result) @@ -257,7 +260,8 @@ def test_status_binds_promoted_repair_acceptance_to_publication_boundary( "schema_version": 1, } fixture = write_fixture(git_repo / "github.json", issues={}) - StateStore(git_repo / ".agent-run").save_run( + prepare_workspace(git_repo) + StateStore(managed_state(git_repo)).save_run( "run-promoted-repair-status", state ) @@ -340,7 +344,8 @@ def test_status_keeps_matching_acceptance_conclusion_after_completion( "schema_version": 1, } fixture = write_fixture(git_repo / "github.json", issues={}) - StateStore(git_repo / ".agent-run").save_run( + prepare_workspace(git_repo) + StateStore(managed_state(git_repo)).save_run( "run-completed-conclusion", state ) @@ -391,7 +396,8 @@ def test_status_does_not_infer_completed_acceptance_without_matching_record( if acceptance_record is not None: parent_job["acceptance_record"] = acceptance_record fixture = write_fixture(git_repo / "github.json", issues={}) - StateStore(git_repo / ".agent-run").save_run( + prepare_workspace(git_repo) + StateStore(managed_state(git_repo)).save_run( "run-completed-without-proof", state ) @@ -513,7 +519,8 @@ def test_status_uses_saved_publication_boundary_before_publication_record( "schema_version": 1, } fixture = write_fixture(git_repo / "github.json", issues={}) - StateStore(git_repo / ".agent-run").save_run("run-1", state) + prepare_workspace(git_repo) + StateStore(managed_state(git_repo)).save_run("run-1", state) output = stdout_json( invoke_cli_inprocess(git_repo, fixture, "status", "run-1", "--json") @@ -690,7 +697,8 @@ def test_status_exposes_current_cycle_rounds_alongside_window_budget( "schema_version": 1, } fixture = write_fixture(git_repo / "github.json", issues={}) - StateStore(git_repo / ".agent-run").save_run("run-1", state) + prepare_workspace(git_repo) + StateStore(managed_state(git_repo)).save_run("run-1", state) json_result = invoke_cli_inprocess( git_repo, fixture, "status", "run-1", "--json" @@ -745,7 +753,8 @@ def test_status_fails_closed_when_current_candidate_identity_is_missing( "schema_version": 1, } fixture = write_fixture(git_repo / "github.json", issues={}) - StateStore(git_repo / ".agent-run").save_run("run-1", state) + prepare_workspace(git_repo) + StateStore(managed_state(git_repo)).save_run("run-1", state) result = invoke_cli_inprocess(git_repo, fixture, "status", "run-1", "--json") output = stdout_json(result) @@ -779,7 +788,8 @@ def test_plain_status_places_next_action_before_complete_finding_details( } state["schema_version"] = 1 fixture = write_fixture(git_repo / "github.json", issues={}) - StateStore(git_repo / ".agent-run").save_run("run-1", state) + prepare_workspace(git_repo) + StateStore(managed_state(git_repo)).save_run("run-1", state) result = invoke_cli_inprocess( git_repo, fixture, "status", "run-1", "--plain" @@ -917,9 +927,9 @@ def test_public_run_preserves_non_invocation_execution_failure_until_resume( } agents.write_text(json.dumps(agent_data), encoding="utf-8") run_id = stdout_json(seed_run(git_repo, fixture, "1", idle_control=True))["run_id"] - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) assert Controller( - FixtureGitHubReader(fixture), GitRepository(git_repo), states + FixtureGitHubReader(fixture), GitRepository(managed_repo(git_repo)), states ).record_execution_failure(run_id, "controller failed before an Agent started") failed = load_only_run_state(git_repo) assert failed["active_agent_invocation"] is None @@ -973,7 +983,7 @@ def test_public_run_preserves_non_invocation_execution_failure_until_resume( assert load_only_run_state(git_repo) == failed controller = Controller( - FixtureGitHubReader(fixture), GitRepository(git_repo), states + FixtureGitHubReader(fixture), GitRepository(managed_repo(git_repo)), states ) with pytest.raises(RequeueError, match="unresolved Execution Failure"): controller.requeue(run_id) @@ -1086,7 +1096,7 @@ def test_public_operator_docs_describe_the_v01_quickstart() -> None: reference = (PROJECT_ROOT / "docs" / "agent-run.md").read_text(encoding="utf-8") for required in ( "./install.sh", "release tag", "agent-run doctor", "auth app configure", - "--rollback", "--uninstall", "Linux", "./setup.sh", "目标交付仓库", + "--rollback", "--uninstall", "Linux", "./setup.sh", "独立克隆", ): assert required in reference @@ -3634,7 +3644,6 @@ def check_readiness(self, *, command: object = None) -> None: raise cli.ExecutionReadinessError("user systemd unavailable") monkeypatch.chdir(git_repo) - monkeypatch.setenv("XDG_DATA_HOME", str(git_repo / "runner-data")) monkeypatch.setattr(cli, "_running_active_runner", lambda: True) monkeypatch.setattr(cli, "SystemdUserExecutorHost", UnavailableSystemdHost) monkeypatch.setattr( @@ -3675,11 +3684,10 @@ def enter_lifecycle(*_args: object, **options: object) -> None: pytest.fail("environment rejection must stop lifecycle admission") monkeypatch.chdir(git_repo) - monkeypatch.setenv("XDG_DATA_HOME", str(git_repo / "runner-data")) - monkeypatch.setenv("XDG_STATE_HOME", str(git_repo / "runner-state")) monkeypatch.setattr(cli, "_running_active_runner", lambda: True) monkeypatch.setattr(cli, "SystemdUserExecutorHost", AvailableSystemdHost) monkeypatch.setattr(cli, "_run_lifecycle", enter_lifecycle) + prepare_workspace(git_repo) assert main(["run", "1", "--repo", "example/project", "--json"]) == 2 @@ -3712,7 +3720,7 @@ def issue( def run_cli( repo: Path, - fixture: Path, + fixture: Path | None, *arguments: str, extra_env: dict[str, str] | None = None, machine_output: bool = True, @@ -3730,7 +3738,6 @@ def run_cli( if not environment.get("PYTHONPATH") else f"{source_path}{os.pathsep}{environment['PYTHONPATH']}" ) - environment.setdefault("XDG_STATE_HOME", str(repo / ".agent-run-test-state")) if extra_env: environment.update(extra_env) command_arguments = list(arguments) @@ -3757,8 +3764,7 @@ def run_cli( "-m", "agent_run", *command_arguments, - "--github-fixture", - str(fixture), + *(["--github-fixture", str(fixture)] if fixture is not None else []), ], cwd=repo, env=environment, @@ -3807,13 +3813,14 @@ def run_internal_stage( if "--crash-after-save" in arguments else None ) - git = GitRepository.discover(repo) + workspace = prepare_workspace(repo) + git = workspace.open() states = ( FaultInjectingStateStore( - repo / ".agent-run", crash_after_save=crash_after_save + workspace.state_root, crash_after_save=crash_after_save ) if crash_after_save is not None - else StateStore(repo / ".agent-run") + else StateStore(workspace.state_root) ) reader = FixtureGitHubReader(fixture) controller = Controller(reader, git, states) @@ -3920,8 +3927,10 @@ def stdout_json(result: subprocess.CompletedProcess[str]) -> dict[str, Any]: return loaded -def load_only_run_state(repo: Path) -> dict[str, Any]: - run_files = list((repo / ".agent-run" / "runs").glob("*.json")) +def load_only_run_state( + repo: Path, *, extra_env: dict[str, str] | None = None, +) -> dict[str, Any]: + run_files = list((managed_state(repo, extra_env=extra_env) / "runs").glob("*.json")) assert len(run_files) == 1 loaded: object = json.loads(run_files[0].read_text(encoding="utf-8")) assert isinstance(loaded, dict) @@ -4006,13 +4015,13 @@ def test_seed_run_creates_one_run_branch_and_is_idempotent( assert state["active_ticket_job"]["ticket_number"] == 2 branches = subprocess.run( ["git", "for-each-ref", "--format=%(refname:short)", "refs/heads/agent-run/"], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, ).stdout.splitlines() assert branches == [state["run_branch"]] - assert len(list((git_repo / ".agent-run" / "runs").glob("*.json"))) == 1 + assert len(list((managed_state(git_repo) / "runs").glob("*.json"))) == 1 def test_seed_run_preserves_the_managed_run_branch_side_effect( @@ -4026,12 +4035,13 @@ def test_seed_run_preserves_the_managed_run_branch_side_effect( state = load_only_run_state(git_repo) branches = subprocess.run( ["git", "branch", "--format=%(refname:short)"], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, ).stdout.splitlines() assert state["run_branch"] in branches + assert not (git_repo / ".agent-run").exists() def test_run_repair_docs_describe_job_rotation_and_candidate_promotion() -> None: @@ -4053,7 +4063,7 @@ def test_status_and_history_locate_a_new_run_from_an_unrelated_directory( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) locator_home = tmp_path / "locator-home" - locator_env = {"XDG_STATE_HOME": str(locator_home)} + locator_env = {"XDG_DATA_HOME": str(locator_home)} started = seed_run(git_repo, fixture, "1", extra_env=locator_env) run_id = stdout_json(started)["run_id"] elsewhere = tmp_path / "elsewhere" @@ -4078,8 +4088,8 @@ def test_status_and_history_locate_a_new_run_from_an_unrelated_directory( "entries": [ { "run_id": run_id, - "repository_root": str(git_repo.resolve()), - "state_dir": str((git_repo / ".agent-run").resolve()), + "repository_root": str(managed_repo(git_repo, extra_env=locator_env).resolve()), + "state_dir": str((managed_state(git_repo, extra_env=locator_env)).resolve()), "updated_at": locator["entries"][0]["updated_at"], "repository": "example/project", "parent_number": 1, @@ -4096,7 +4106,7 @@ def test_status_and_history_do_not_initialize_online_dependencies( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) locator_home = tmp_path / "locator-home" - locator_env = {"XDG_STATE_HOME": str(locator_home)} + locator_env = {"XDG_DATA_HOME": str(locator_home)} run_id = stdout_json( seed_run(git_repo, fixture, "1", extra_env=locator_env) )["run_id"] @@ -4105,7 +4115,7 @@ def unavailable(*_args: object, **_kwargs: object) -> object: raise AssertionError("offline command initialized an online dependency") monkeypatch.chdir(git_repo) - monkeypatch.setenv("XDG_STATE_HOME", str(locator_home)) + monkeypatch.setenv("XDG_DATA_HOME", str(locator_home)) monkeypatch.setattr(cli, "SystemdUserExecutorHost", unavailable) monkeypatch.setattr(cli, "CodexCliBackend", unavailable) monkeypatch.setattr(cli, "GhGitHubReader", unavailable) @@ -4117,11 +4127,11 @@ def unavailable(*_args: object, **_kwargs: object) -> object: assert json.loads(output[-1])["run_id"] == run_id -def test_new_runs_from_separate_clones_have_distinct_locator_ids( +def test_new_runs_from_separate_clones_reuse_the_same_locator_id( git_repo: Path, tmp_path: Path ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) - locator_env = {"XDG_STATE_HOME": str(tmp_path / "locator-home")} + locator_env = {"XDG_DATA_HOME": str(tmp_path / "locator-home")} first_id = stdout_json( seed_run(git_repo, fixture, "1", extra_env=locator_env) )["run_id"] @@ -4133,7 +4143,8 @@ def test_new_runs_from_separate_clones_have_distinct_locator_ids( assert second.returncode == 0, second.stderr second_id = stdout_json(second)["run_id"] - assert second_id != first_id + assert second_id == first_id + assert stdout_json(second)["result"] == "resumed" elsewhere = tmp_path / "elsewhere" elsewhere.mkdir() assert run_cli( @@ -4144,11 +4155,11 @@ def test_new_runs_from_separate_clones_have_distinct_locator_ids( ).returncode == 0 -def test_status_prefers_current_directory_state_and_explicit_state_dir( +def test_status_ignores_user_directory_state_and_honors_explicit_state_dir( git_repo: Path, tmp_path: Path ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) - locator_env = {"XDG_STATE_HOME": str(tmp_path / "locator-home")} + locator_env = {"XDG_DATA_HOME": str(tmp_path / "locator-home")} run_id = stdout_json( seed_run(git_repo, fixture, "1", extra_env=locator_env) )["run_id"] @@ -4157,7 +4168,7 @@ def test_status_prefers_current_directory_state_and_explicit_state_dir( subprocess.run(["git", "init", "-b", "main"], cwd=local_repo, check=True) local_state_dir = local_repo / ".agent-run" / "runs" local_state_dir.mkdir(parents=True) - local_state = load_only_run_state(git_repo) + local_state = load_only_run_state(git_repo, extra_env=locator_env) local_state["diagnostics"] = [{"code": "local_priority", "message": "local"}] (local_state_dir / f"{run_id}.json").write_text( json.dumps(local_state), encoding="utf-8" @@ -4174,13 +4185,13 @@ def test_status_prefers_current_directory_state_and_explicit_state_dir( "status", run_id, "--state-dir", - str(git_repo / ".agent-run"), + str(local_state_dir.parent), "--json", extra_env=locator_env, ) - assert stdout_json(local)["diagnostics"][0]["code"] == "local_priority" - assert stdout_json(explicit)["diagnostics"] == [] + assert stdout_json(local)["diagnostics"] == [] + assert stdout_json(explicit)["diagnostics"][0]["code"] == "local_priority" @pytest.mark.parametrize("command", ["status", "history"]) @@ -4189,11 +4200,11 @@ def test_read_only_locator_errors_are_actionable_and_do_not_mutate_run_history( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) locator_home = tmp_path / "locator-home" - locator_env = {"XDG_STATE_HOME": str(locator_home)} + locator_env = {"XDG_DATA_HOME": str(locator_home)} run_id = stdout_json( seed_run(git_repo, fixture, "1", extra_env=locator_env) )["run_id"] - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo, extra_env=locator_env) / "runs").glob("*.json")) before = state_path.read_text(encoding="utf-8") locator_path = locator_home / "agent-run" / "run-locator.json" locator_path.parent.mkdir(parents=True, exist_ok=True) @@ -4203,7 +4214,7 @@ def test_read_only_locator_errors_are_actionable_and_do_not_mutate_run_history( "entries": [ { "run_id": run_id, - "repository_root": str(git_repo.resolve()), + "repository_root": str(managed_repo(git_repo, extra_env=locator_env).resolve()), "state_dir": str(tmp_path / "missing-state"), "updated_at": "2026-08-15T00:00:00+00:00", } @@ -4216,7 +4227,7 @@ def test_read_only_locator_errors_are_actionable_and_do_not_mutate_run_history( elsewhere.mkdir() result = run_cli( - elsewhere, fixture, command, run_id, "--json", extra_env=locator_env + elsewhere, None, command, run_id, "--json", extra_env=locator_env ) assert result.returncode == 2 @@ -4232,7 +4243,7 @@ def test_missing_and_conflicting_locators_return_dedicated_read_only_errors( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) locator_home = tmp_path / "locator-home" - locator_env = {"XDG_STATE_HOME": str(locator_home)} + locator_env = {"XDG_DATA_HOME": str(locator_home)} run_id = stdout_json( seed_run(git_repo, fixture, "1", extra_env=locator_env) )["run_id"] @@ -4242,12 +4253,12 @@ def test_missing_and_conflicting_locators_return_dedicated_read_only_errors( locator_path.unlink() missing = run_cli( - elsewhere, fixture, "status", run_id, "--json", extra_env=locator_env + elsewhere, None, "status", run_id, "--json", extra_env=locator_env ) assert stdout_json(missing)["diagnostics"][0]["code"] == "run_locator_missing" assert not locator_path.exists() - source_state = next((git_repo / ".agent-run" / "runs").glob("*.json")).read_text( + source_state = next((managed_state(git_repo, extra_env=locator_env) / "runs").glob("*.json")).read_text( encoding="utf-8" ) first_state_dir = tmp_path / "first-state" @@ -4264,13 +4275,13 @@ def test_missing_and_conflicting_locators_return_dedicated_read_only_errors( "entries": [ { "run_id": run_id, - "repository_root": str(git_repo.resolve()), + "repository_root": str(managed_repo(git_repo, extra_env=locator_env).resolve()), "state_dir": str(first_state_dir), "updated_at": "2026-08-15T00:00:00+00:00", }, { "run_id": run_id, - "repository_root": str(git_repo.resolve()), + "repository_root": str(managed_repo(git_repo, extra_env=locator_env).resolve()), "state_dir": str(second_state_dir), "updated_at": "2026-08-15T00:00:01+00:00", }, @@ -4281,7 +4292,7 @@ def test_missing_and_conflicting_locators_return_dedicated_read_only_errors( ) conflict = run_cli( - elsewhere, fixture, "history", run_id, "--json", extra_env=locator_env + elsewhere, None, "history", run_id, "--json", extra_env=locator_env ) assert conflict.returncode == 2 @@ -4299,29 +4310,32 @@ def test_missing_and_conflicting_locators_return_dedicated_read_only_errors( ("abandon", ("{run_id}",)), ], ) -def test_lifecycle_commands_do_not_use_cross_directory_locator( +def test_lifecycle_commands_apply_the_same_preconditions_from_any_directory( git_repo: Path, tmp_path: Path, command: str, arguments: tuple[str, ...], ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) - locator_env = {"XDG_STATE_HOME": str(tmp_path / "locator-home")} + locator_env = {"XDG_DATA_HOME": str(tmp_path / "locator-home")} run_id = stdout_json( seed_run(git_repo, fixture, "1", extra_env=locator_env) )["run_id"] - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo, extra_env=locator_env) / "runs").glob("*.json")) before = state_path.read_text(encoding="utf-8") elsewhere = tmp_path / "elsewhere" elsewhere.mkdir() command_arguments = tuple(argument.format(run_id=run_id) for argument in arguments) + local = run_cli( + git_repo, fixture, command, *command_arguments, extra_env=locator_env + ) result = run_cli( elsewhere, fixture, command, *command_arguments, extra_env=locator_env ) assert result.returncode == 2 - assert stdout_json(result)["diagnostics"][0]["code"] == "workspace_required" + assert stdout_json(result) == stdout_json(local) assert state_path.read_text(encoding="utf-8") == before @@ -4354,7 +4368,7 @@ def test_parent_selector_works_in_a_repo_and_across_directories( cwd=git_repo, check=True, ) - locator_env = {"XDG_STATE_HOME": str(tmp_path / "locator-home")} + locator_env = {"XDG_DATA_HOME": str(tmp_path / "locator-home")} run_id = stdout_json( seed_run(git_repo, fixture, "1", extra_env=locator_env) )["run_id"] @@ -4381,11 +4395,11 @@ def test_parent_selector_works_in_a_repo_and_across_directories( assert stdout_json(cross_directory)["run_id"] == run_id -def test_parent_selector_fails_closed_when_same_repository_has_multiple_clones( +def test_parent_selector_reuses_one_task_across_user_clones( git_repo: Path, tmp_path: Path ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) - locator_env = {"XDG_STATE_HOME": str(tmp_path / "locator-home")} + locator_env = {"XDG_DATA_HOME": str(tmp_path / "locator-home")} subprocess.run( ["git", "remote", "add", "origin", "git@github.com:example/project.git"], cwd=git_repo, @@ -4417,13 +4431,9 @@ def test_parent_selector_fails_closed_when_same_repository_has_multiple_clones( extra_env=locator_env, ) - assert result.returncode == 2 - diagnostic = stdout_json(result)["diagnostics"][0] - assert diagnostic["code"] == "run_selector_ambiguous" - assert {candidate["run_id"] for candidate in diagnostic["candidates"]} == { - first_id, - second_id, - } + assert result.returncode == 0 + assert stdout_json(result)["run_id"] == first_id == second_id + assert len(list((managed_state(git_repo, extra_env=locator_env) / "runs").glob("*.json"))) == 1 def test_repository_parent_selector_does_not_collide_on_same_issue_number( @@ -4435,7 +4445,7 @@ def test_repository_parent_selector_does_not_collide_on_same_issue_number( cwd=git_repo, check=True, ) - locator_env = {"XDG_STATE_HOME": str(tmp_path / "locator-home")} + locator_env = {"XDG_DATA_HOME": str(tmp_path / "locator-home")} first_id = stdout_json( seed_run(git_repo, fixture, "1", extra_env=locator_env) )["run_id"] @@ -4495,11 +4505,11 @@ def test_repository_parent_selector_ignores_unrelated_stale_locator( cwd=git_repo, check=True, ) - locator_env = {"XDG_STATE_HOME": str(tmp_path / "locator-home")} + locator_env = {"XDG_DATA_HOME": str(tmp_path / "locator-home")} first_id = stdout_json( seed_run(git_repo, fixture, "1", extra_env=locator_env) )["run_id"] - first_state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + first_state_path = next((managed_state(git_repo, extra_env=locator_env) / "runs").glob("*.json")) other_repo = tmp_path / "other-repo" subprocess.run(["git", "clone", "--quiet", str(git_repo), str(other_repo)], check=True) @@ -4514,7 +4524,7 @@ def test_repository_parent_selector_ignores_unrelated_stale_locator( second_id = stdout_json( seed_run(other_repo, other_fixture, "1", extra_env=locator_env) )["run_id"] - second_state_path = other_repo / ".agent-run" / "runs" / f"{second_id}.json" + second_state_path = managed_state(other_repo, extra_env=locator_env) / "runs" / f"{second_id}.json" second_state_path.unlink() locator_path = tmp_path / "locator-home" / "agent-run" / "run-locator.json" before_first_state = first_state_path.read_text(encoding="utf-8") @@ -4579,11 +4589,11 @@ def test_runs_discovers_bounded_human_run_candidates( cwd=git_repo, check=True, ) - locator_env = {"XDG_STATE_HOME": str(tmp_path / "locator-home")} + locator_env = {"XDG_DATA_HOME": str(tmp_path / "locator-home")} run_id = stdout_json( seed_run(git_repo, fixture, "1", extra_env=locator_env) )["run_id"] - state = load_only_run_state(git_repo) + state = load_only_run_state(git_repo, extra_env=locator_env) elsewhere = tmp_path / "elsewhere" elsewhere.mkdir() @@ -4605,10 +4615,10 @@ def test_runs_discovers_bounded_human_run_candidates( { "parent": 1, "repository": "example/project", - "repository_root": str(git_repo.resolve()), + "repository_root": str(managed_repo(git_repo, extra_env=locator_env).resolve()), "run_id": run_id, "started_at": state["created_at"], - "state_dir": str((git_repo / ".agent-run").resolve()), + "state_dir": str((managed_state(git_repo, extra_env=locator_env)).resolve()), "status": state["status"], } ] @@ -4630,15 +4640,15 @@ def test_runs_with_default_state_dir_reports_the_checkout_root( fixture, "runs", "--state-dir", - str(git_repo / ".agent-run"), + str(managed_state(git_repo)), "--json", ) assert discovered.returncode == 0, discovered.stderr candidate = stdout_json(discovered)["runs"][0] assert candidate["run_id"] == run_id - assert candidate["repository_root"] == str(git_repo.resolve()) - assert candidate["state_dir"] == str((git_repo / ".agent-run").resolve()) + assert candidate["repository_root"] == str(managed_repo(git_repo).resolve()) + assert candidate["state_dir"] == str((managed_state(git_repo)).resolve()) def test_runs_with_registered_custom_state_dir_reports_the_checkout_root( @@ -4651,7 +4661,7 @@ def test_runs_with_registered_custom_state_dir_reports_the_checkout_root( check=True, ) custom_state_dir = tmp_path / "custom-state" - locator_env = {"XDG_STATE_HOME": str(tmp_path / "locator-home")} + locator_env = {"XDG_DATA_HOME": str(tmp_path / "locator-home")} run_id = stdout_json( seed_run( git_repo, @@ -4676,7 +4686,7 @@ def test_runs_with_registered_custom_state_dir_reports_the_checkout_root( assert discovered.returncode == 0, discovered.stderr candidate = stdout_json(discovered)["runs"][0] assert candidate["run_id"] == run_id - assert candidate["repository_root"] == str(git_repo.resolve()) + assert candidate["repository_root"] == str(managed_repo(git_repo, extra_env=locator_env).resolve()) assert candidate["state_dir"] == str(custom_state_dir.resolve()) @@ -4686,7 +4696,7 @@ def test_registered_state_dir_without_checkout_identity_is_unavailable( fixture = write_fixture(git_repo / "github.json", issues={}) custom_state_dir = tmp_path / "custom-state" locator_home = tmp_path / "locator-home" - locator_env = {"XDG_STATE_HOME": str(locator_home)} + locator_env = {"XDG_DATA_HOME": str(locator_home)} run_id = stdout_json( seed_run( git_repo, @@ -4744,7 +4754,7 @@ def test_explicit_repository_run_without_origin_remains_selectable( fixture = write_fixture( git_repo / "github.json", issues={}, repository="a/project" ) - locator_env = {"XDG_STATE_HOME": str(tmp_path / "locator-home")} + locator_env = {"XDG_DATA_HOME": str(tmp_path / "locator-home")} started = seed_run( git_repo, @@ -4757,7 +4767,7 @@ def test_explicit_repository_run_without_origin_remains_selectable( assert started.returncode == 0, started.stderr run_id = stdout_json(started)["run_id"] - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo, extra_env=locator_env) / "runs").glob("*.json")) locator_path = tmp_path / "locator-home" / "agent-run" / "run-locator.json" before_state = state_path.read_text(encoding="utf-8") before_locator = locator_path.read_text(encoding="utf-8") @@ -4787,7 +4797,7 @@ def test_runs_with_unknown_custom_state_dir_marks_worktree_unavailable( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) run_id = stdout_json(seed_run(git_repo, fixture, "1"))["run_id"] - source = git_repo / ".agent-run" / "runs" / f"{run_id}.json" + source = managed_state(git_repo) / "runs" / f"{run_id}.json" custom_state_dir = tmp_path / "custom-state" custom_runs = custom_state_dir / "runs" custom_runs.mkdir(parents=True) @@ -4816,7 +4826,7 @@ def test_runs_with_nested_dot_agent_run_state_dir_marks_worktree_unavailable( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) run_id = stdout_json(seed_run(git_repo, fixture, "1"))["run_id"] - source = git_repo / ".agent-run" / "runs" / f"{run_id}.json" + source = managed_state(git_repo) / "runs" / f"{run_id}.json" custom_state_dir = git_repo / "nested" / ".agent-run" custom_runs = custom_state_dir / "runs" custom_runs.mkdir(parents=True) @@ -4850,7 +4860,7 @@ def test_locator_root_reuse_by_another_repository_fails_closed_and_preserves_inp check=True, ) locator_home = tmp_path / "locator-home" - locator_env = {"XDG_STATE_HOME": str(locator_home)} + locator_env = {"XDG_DATA_HOME": str(locator_home)} custom_state_dir = tmp_path / "custom-state" run_id = stdout_json( seed_run( @@ -4868,15 +4878,16 @@ def test_locator_root_reuse_by_another_repository_fails_closed_and_preserves_inp before_locator = locator_path.read_text(encoding="utf-8") original = tmp_path / "original" - git_repo.rename(original) - subprocess.run(["git", "clone", "--quiet", str(original), str(git_repo)], check=True) + root = managed_repo(git_repo, extra_env=locator_env) + root.rename(original) + subprocess.run(["git", "clone", "--quiet", str(original), str(root)], check=True) subprocess.run( ["git", "remote", "set-url", "origin", "git@github.com:other/project.git"], - cwd=git_repo, + cwd=root, check=True, ) replacement_fixture = write_fixture( - git_repo / "github.json", issues={}, repository="other/project" + git_repo / "github.json", issues={}, repository="example/project" ) elsewhere = tmp_path / "elsewhere" elsewhere.mkdir() @@ -4925,8 +4936,8 @@ def test_same_repository_replacement_clone_fails_closed_for_all_selectors( check=True, ) locator_home = tmp_path / "locator-home" - locator_env = {"XDG_STATE_HOME": str(locator_home)} - custom_state_dir = tmp_path / "custom-state" + locator_env = {"XDG_DATA_HOME": str(locator_home)} + custom_state_dir = managed_state(git_repo, extra_env=locator_env) run_id = stdout_json( seed_run( git_repo, @@ -4952,8 +4963,10 @@ def test_same_repository_replacement_clone_fails_closed_for_all_selectors( before_locator = locator_path.read_text(encoding="utf-8") original = tmp_path / "original" - git_repo.rename(original) - subprocess.run(["git", "clone", "--quiet", str(original), str(git_repo)], check=True) + root = managed_repo(git_repo, extra_env=locator_env) + root.rename(original) + subprocess.run(["git", "clone", "--quiet", str(original), str(root)], check=True) + subprocess.run(["git", "remote", "set-url", "origin", str(git_repo)], cwd=root, check=True) replacement_fixture = write_fixture( git_repo / "github.json", issues={}, repository="example/project" ) @@ -5026,7 +5039,7 @@ def test_same_repository_replacement_clone_fails_closed_for_all_selectors( assert not (git_repo / ".agent-run").exists() -def test_selector_does_not_cross_select_a_run_from_another_clone( +def test_selector_reuses_the_managed_run_from_another_user_clone( git_repo: Path, tmp_path: Path ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) @@ -5036,7 +5049,7 @@ def test_selector_does_not_cross_select_a_run_from_another_clone( check=True, ) locator_home = tmp_path / "locator-home" - locator_env = {"XDG_STATE_HOME": str(locator_home)} + locator_env = {"XDG_DATA_HOME": str(locator_home)} custom_state_dir = tmp_path / "custom-state" run_id = stdout_json( seed_run( @@ -5072,15 +5085,14 @@ def test_selector_does_not_cross_select_a_run_from_another_clone( "--json", extra_env=locator_env, ) - assert result.returncode == 2, result.stderr - diagnostic = stdout_json(result)["diagnostics"][0] - assert diagnostic["code"] == "run_selector_requires_checkout" + assert result.returncode == 0, result.stderr + assert stdout_json(result)["run_id"] == run_id assert state_path.read_text(encoding="utf-8") == before_state assert locator_path.read_text(encoding="utf-8") == before_locator assert not (clone / ".agent-run").exists() -def test_resume_with_explicit_state_dir_does_not_cross_select_another_clone( +def test_resume_rejects_a_noncanonical_state_dir_from_another_clone( git_repo: Path, tmp_path: Path ) -> None: from cli_fixtures import run_agents @@ -5092,7 +5104,7 @@ def test_resume_with_explicit_state_dir_does_not_cross_select_another_clone( check=True, ) locator_home = tmp_path / "locator-home" - locator_env = {"XDG_STATE_HOME": str(locator_home)} + locator_env = {"XDG_DATA_HOME": str(locator_home)} custom_state_dir = tmp_path / "custom-state" run_id = stdout_json( seed_run( @@ -5142,7 +5154,8 @@ def test_resume_with_explicit_state_dir_does_not_cross_select_another_clone( assert result.returncode == 2 diagnostic = stdout_json(result)["diagnostics"][0] - assert diagnostic["code"] == "run_selector_requires_checkout" + assert diagnostic["code"] == "command_failed" + assert "统一状态目录" in diagnostic["message"] assert state_path.read_text(encoding="utf-8") == before_state assert locator_path.read_text(encoding="utf-8") == before_locator assert not (clone / ".agent-run").exists() @@ -5157,14 +5170,14 @@ def test_parent_selector_fails_closed_with_all_ambiguous_candidates( cwd=git_repo, check=True, ) - locator_env = {"XDG_STATE_HOME": str(tmp_path / "locator-home")} + locator_env = {"XDG_DATA_HOME": str(tmp_path / "locator-home")} first = seed_run(git_repo, fixture, "1", extra_env=locator_env) first_id = stdout_json(first)["run_id"] second = seed_run( git_repo, fixture, extra_env=locator_env, reuse_existing=False ) second_id = stdout_json(second)["run_id"] - state_paths = sorted((git_repo / ".agent-run" / "runs").glob("*.json")) + state_paths = sorted((managed_state(git_repo, extra_env=locator_env) / "runs").glob("*.json")) before = [path.read_text(encoding="utf-8") for path in state_paths] result = run_cli( @@ -5192,11 +5205,11 @@ def test_parent_selector_reports_stale_index_candidates_without_mutation( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) locator_home = tmp_path / "locator-home" - locator_env = {"XDG_STATE_HOME": str(locator_home)} + locator_env = {"XDG_DATA_HOME": str(locator_home)} run_id = stdout_json( seed_run(git_repo, fixture, "1", extra_env=locator_env) )["run_id"] - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo, extra_env=locator_env) / "runs").glob("*.json")) before = state_path.read_text(encoding="utf-8") locator_path = locator_home / "agent-run" / "run-locator.json" locator = json.loads(locator_path.read_text(encoding="utf-8")) @@ -5251,7 +5264,7 @@ def test_resume_parent_selector_reuses_one_existing_recoverable_run( ) agents = run_agents(git_repo / "agents.json") run_id = stdout_json(seed_run(git_repo, fixture, "1", idle_control=True))["run_id"] - run_file = next((git_repo / ".agent-run" / "runs").glob("*.json")) + run_file = next((managed_state(git_repo) / "runs").glob("*.json")) state = load_only_run_state(git_repo) state.update( { @@ -5278,7 +5291,7 @@ def test_resume_parent_selector_reuses_one_existing_recoverable_run( resumed_state = load_only_run_state(git_repo) assert resumed_state["run_id"] == run_id assert resumed_state["resume_audit"]["total"] == 1 - assert len(list((git_repo / ".agent-run" / "runs").glob("*.json"))) == 1 + assert len(list((managed_state(git_repo) / "runs").glob("*.json"))) == 1 def test_frontier_uses_native_dependencies_labels_state_and_parent_order( @@ -5355,7 +5368,7 @@ def test_run_reports_an_incompatible_legacy_state_without_recording_a_failure( started = seed_run(git_repo, fixture, "1") state = load_only_run_state(git_repo) state["schema_version"] = 1 - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") before = deepcopy(state) @@ -5376,7 +5389,7 @@ def test_status_and_history_show_incompatible_legacy_evidence( run_id = stdout_json(started)["run_id"] state = load_only_run_state(git_repo) state["schema_version"] = 1 - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") before = deepcopy(state) @@ -5402,7 +5415,7 @@ def test_status_and_history_reject_an_invalid_timeline_without_mutation( state.pop("timeline") else: state["timeline"] = timeline - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") before = deepcopy(state) @@ -5420,7 +5433,7 @@ def test_history_rejects_a_timeline_with_a_non_event_without_mutation( run_id = stdout_json(seed_run(git_repo, fixture, "1"))["run_id"] state = load_only_run_state(git_repo) state["timeline"] = ["not an event"] - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") before = deepcopy(state) @@ -5439,7 +5452,7 @@ def test_cli_rejects_a_malformed_canonical_nested_state( run_id = stdout_json(seed_run(git_repo, fixture, "1"))["run_id"] state = load_only_run_state(git_repo) state["parent"].pop("number") - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") before = deepcopy(state) arguments = (command, run_id, "--json") if command != "resume" else (command, run_id) @@ -5467,7 +5480,7 @@ def test_status_rejects_malformed_invocation_records( state = load_only_run_state(git_repo) state["status"] = "execution_failed" state[field] = value - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") before = deepcopy(state) @@ -5488,7 +5501,7 @@ def test_resume_rejects_unknown_invocation_role_without_mutation( state["active_agent_invocation"] = failed_invocation( work_subject="ticket:2", role="unknown", phase="developing" ) - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") before = deepcopy(state) @@ -5509,7 +5522,7 @@ def test_resume_rejects_active_invocation_for_missing_ticket( state["active_agent_invocation"] = failed_invocation( work_subject="ticket:999", role="development", phase="developing" ) - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") before = deepcopy(state) @@ -5540,7 +5553,7 @@ def test_resume_rejects_final_publication_without_a_valid_owner( ), } ) - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") before = deepcopy(state) @@ -5579,7 +5592,7 @@ def test_resume_rejects_an_incomplete_run_acceptance_owner( ), } ) - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") before = deepcopy(state) @@ -5630,7 +5643,7 @@ def test_resume_rejects_an_owner_that_has_already_advanced( state["run_publication"] = owner else: state["run_acceptance"] = owner - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") before = deepcopy(state) @@ -5671,7 +5684,7 @@ def test_change_resume_rejects_an_owner_that_has_already_advanced( ), } ) - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") before = deepcopy(state) @@ -5714,7 +5727,7 @@ def test_completed_invocation_remains_a_readable_audit_snapshot( }, } ) - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") before = deepcopy(state) @@ -5731,7 +5744,7 @@ def test_incompatible_state_does_not_replay_its_diagnostics(git_repo: Path) -> N state["status"] = "blocked" state["diagnostics"] = [{"code": "attacker", "message": "not canonical"}] state["parent"].pop("number") - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") before = deepcopy(state) @@ -5764,7 +5777,7 @@ def test_cli_rejects_malformed_human_blocker_without_mutation(git_repo: Path) -> }, } ) - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") before = deepcopy(state) @@ -5784,7 +5797,7 @@ def test_cli_rejects_resolved_run_with_missing_observed_revisions( state = load_only_run_state(git_repo) state["parent"]["revision"] = None state["ticket_graph"]["revision"] = None - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") before = deepcopy(state) arguments = (command, run_id, "--json") if command != "resume" else (command, run_id) @@ -5801,7 +5814,7 @@ def test_status_prints_the_recovery_command_for_manual_boundaries( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={"2": issue(2)}) run_id = stdout_json(seed_run(git_repo, fixture, "1"))["run_id"] - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) current = load_only_run_state(git_repo) cases = [ ( @@ -5892,7 +5905,7 @@ def test_resume_does_not_refresh_a_run_without_an_agent_boundary( state = load_only_run_state(git_repo) tree = subprocess.run( ["git", "rev-parse", f"{state['run_branch']}^{{tree}}"], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, @@ -5907,14 +5920,14 @@ def test_resume_does_not_refresh_a_run_without_an_agent_boundary( "-m", "integrate accepted ticket", ], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, ).stdout.strip() subprocess.run( ["git", "update-ref", f"refs/heads/{state['run_branch']}", advanced], - cwd=git_repo, + cwd=managed_repo(git_repo), check=True, ) @@ -5927,7 +5940,7 @@ def test_resume_does_not_refresh_a_run_without_an_agent_boundary( assert ( subprocess.run( ["git", "rev-parse", state["run_branch"]], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, @@ -5951,6 +5964,7 @@ def test_live_default_head_is_fetched_before_run_branch_creation( cwd=git_repo, check=True, ) + managed_workspace(git_repo).ensure(remote_url=str(remote)) updater = tmp_path / "updater" subprocess.run( ["git", "clone", str(remote), str(updater)], @@ -5999,7 +6013,7 @@ def test_live_default_head_is_fetched_before_run_branch_creation( assert state["base"]["sha"] == live_head branch_head = subprocess.run( ["git", "rev-parse", state["run_branch"]], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, diff --git a/tests/test_cli_command_diagnostics.py b/tests/test_cli_command_diagnostics.py index 878900b..6eb673f 100644 --- a/tests/test_cli_command_diagnostics.py +++ b/tests/test_cli_command_diagnostics.py @@ -8,6 +8,8 @@ import pytest +from support.workspace import managed_state + from agent_run.cli import main from agent_run.github import GitHubReadError from agent_run.github_fixture import FixtureGitHubReader @@ -15,7 +17,7 @@ from conftest import seed_run, write_fixture -def test_resume_outside_repository_explains_directory_requirement(tmp_path: Path) -> None: +def test_resume_without_a_managed_workspace_explains_how_to_start(tmp_path: Path) -> None: environment = os.environ.copy() environment['PYTHONPATH'] = str(Path(__file__).parents[1] / 'src') result = subprocess.run( @@ -25,9 +27,9 @@ def test_resume_outside_repository_explains_directory_requirement(tmp_path: Path ) assert result.returncode == 2 diagnostic = json.loads(result.stdout)['diagnostics'][0] - assert diagnostic['code'] == 'workspace_required' - assert 'Git 仓库' in diagnostic['message'] - assert '目录' in diagnostic['message'] + assert diagnostic['code'] == 'run_selector_not_found' + assert 'Runner 工作区' in diagnostic['message'] + assert 'run' in diagnostic['message'] assert not (tmp_path / '.agent-run').exists() @@ -61,7 +63,7 @@ def test_new_command_error_does_not_replace_or_repeat_old_run_failure( ) -> None: fixture = write_fixture(git_repo / 'github.json', issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / '.agent-run') + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs('example/project', 1)[0] current['status'] = 'execution_failed' current['diagnostics'] = [{'code': 'old_failure', 'message': 'old usage limit'}] diff --git a/tests/test_cli_delivery.py b/tests/test_cli_delivery.py index 0a6c01d..97f2b55 100644 --- a/tests/test_cli_delivery.py +++ b/tests/test_cli_delivery.py @@ -13,6 +13,7 @@ from agent_run.task_control import TaskControlStore, TaskKey from conftest import seed_run, write_fixture from support.inprocess_cli import invoke_cli_inprocess +from support.workspace import managed_repo, managed_state, prepare_workspace from support.published_run import prepare_published_run from test_cli import ( load_only_run_state, @@ -777,7 +778,7 @@ def test_ctrl_c_last_development_attempt_resumes_without_new_budget( "executor_agent_interrupted" ) control = json.loads( - next((git_repo / ".agent-run" / "task-control").glob("*.json")).read_text( + next((managed_state(git_repo) / "task-control").glob("*.json")).read_text( encoding="utf-8" ) ) @@ -786,7 +787,7 @@ def test_ctrl_c_last_development_attempt_resumes_without_new_budget( assert control["executor"]["failure"] == "executor_agent_interrupted" state = load_only_run_state(git_repo) job = state["active_ticket_job"] - checkout = git_repo / ".agent-run" / "worktrees" / run_id / "ticket-3" + checkout = managed_state(git_repo) / "worktrees" / run_id / "ticket-3" assert (checkout / "feature.txt").read_text(encoding="utf-8") == ( "attempt 4 partial\n" ) @@ -947,7 +948,7 @@ def test_public_resume_reuses_pending_final_ci_fix_attempt( assert job["ci_evidence"]["pr_number"] == job["pr_number"] assert job["ci_evidence"]["head_sha"] == job["publication_sha"] assert job["ci_evidence"]["result"] == "fail" - checkout = git_repo / ".agent-run" / "worktrees" / run_id / "ticket-3" + checkout = managed_state(git_repo) / "worktrees" / run_id / "ticket-3" assert (checkout / "final-ci-partial.txt").read_text(encoding="utf-8") == ( "preserve me\n" ) @@ -2088,11 +2089,11 @@ def test_parent_only_cli_delivers_to_default_branch_after_explicit_approval( assert state["parent_branch"] not in mutable_fixture["delivery"]["published_branches"] assert subprocess.run( ["git", "show-ref", "--verify", f"refs/heads/{state['parent_branch']}"], - cwd=git_repo, + cwd=managed_repo(git_repo), capture_output=True, check=False, ).returncode != 0 - control_path = next((git_repo / ".agent-run" / "task-control").glob("*.json")) + control_path = next((managed_state(git_repo) / "task-control").glob("*.json")) completed_control = control_path.read_bytes() replayed = run_cli(git_repo, fixture, "resume", run_id) assert replayed.returncode == 2 @@ -2104,7 +2105,7 @@ def test_parent_only_cli_delivers_to_default_branch_after_explicit_approval( assert load_only_run_state(git_repo)["status"] == "completed" assert subprocess.run( ["git", "show-ref", "--verify", f"refs/heads/{state['parent_branch']}"], - cwd=git_repo, + cwd=managed_repo(git_repo), capture_output=True, check=False, ).returncode != 0 @@ -3121,7 +3122,7 @@ def test_parent_only_approve_waits_and_recovers_without_duplicate_delivery( fixture.write_text(json.dumps(data), encoding="utf-8") if waiting_case in {"pending", "unknown"}: - control_path = next((git_repo / ".agent-run" / "task-control").glob("*.json")) + control_path = next((managed_state(git_repo) / "task-control").glob("*.json")) control_before = control_path.read_bytes() fixture_before = fixture.read_bytes() refused = run_cli(git_repo, fixture, "run", "1") @@ -3233,7 +3234,7 @@ def test_parent_only_requeue_replaces_the_branch_and_closes_old_pr( canonical_stale = load_only_run_state(git_repo) wrong_subject = json.loads(json.dumps(canonical_stale)) wrong_subject["requeue_required"]["work_subject"] = "parent-only:wrong-run" - StateStore(git_repo / ".agent-run").save_run(run_id, wrong_subject) + StateStore(managed_state(git_repo)).save_run(run_id, wrong_subject) incompatible = run_cli(git_repo, fixture, "requeue", run_id) @@ -3242,13 +3243,13 @@ def test_parent_only_requeue_replaces_the_branch_and_closes_old_pr( wrong_generation_kind = json.loads(json.dumps(canonical_stale)) wrong_generation_kind["parent_job"]["ticket_branch_generation"] = 999 wrong_generation_kind["requeue_required"]["generation"] = 999 - StateStore(git_repo / ".agent-run").save_run(run_id, wrong_generation_kind) + StateStore(managed_state(git_repo)).save_run(run_id, wrong_generation_kind) incompatible = run_cli(git_repo, fixture, "requeue", run_id) assert incompatible.returncode == 2 assert stdout_json(incompatible)["status"] == "incompatible_run_state" - StateStore(git_repo / ".agent-run").save_run(run_id, canonical_stale) + StateStore(managed_state(git_repo)).save_run(run_id, canonical_stale) replacement_agents = git_repo / "parent-replacement.json" replacement_agents.write_text( @@ -3296,7 +3297,7 @@ def test_parent_only_requeue_replaces_the_branch_and_closes_old_pr( ] pulls = json.loads(fixture.read_text(encoding="utf-8"))["delivery"]["pull_requests"] assert [pull["state"] for pull in pulls] == ["CLOSED", "OPEN"] - control_path = next((git_repo / ".agent-run" / "task-control").glob("*.json")) + control_path = next((managed_state(git_repo) / "task-control").glob("*.json")) first_control = json.loads(control_path.read_text(encoding="utf-8")) first_requeue_action_id = first_control["action"]["action_id"] @@ -3572,10 +3573,10 @@ def test_abandon_closes_parent_pr_after_graph_drift(git_repo: Path) -> None: assert {"action": "close_parent_pr", "pr_number": 1} in after["delivery"][ "mutations" ] - assert not (git_repo / ".agent-run" / "worktrees" / run_id).exists() - assert str(git_repo / ".agent-run" / "worktrees" / run_id) not in subprocess.run( + assert not (managed_state(git_repo) / "worktrees" / run_id).exists() + assert str(managed_state(git_repo) / "worktrees" / run_id) not in subprocess.run( ["git", "worktree", "list", "--porcelain"], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, @@ -3605,7 +3606,7 @@ def test_abandon_closes_parent_pr_after_graph_drift(git_repo: Path) -> None: assert stdout_json(replayed)["status"] == "abandoned" replayed_fixture = json.loads(fixture.read_text(encoding="utf-8")) assert replayed_fixture["delivery"]["mutations"] == frozen_mutations - assert not (git_repo / ".agent-run" / "worktrees" / run_id).exists() + assert not (managed_state(git_repo) / "worktrees" / run_id).exists() def test_abandon_requires_explicit_authorization_to_discard_dirty_checkout( @@ -3645,9 +3646,9 @@ def test_abandon_requires_explicit_authorization_to_discard_dirty_checkout( str(agents), ) assert delivered.returncode == 0, delivered.stderr - checkout = git_repo / ".agent-run" / "worktrees" / run_id / "parent" + checkout = managed_state(git_repo) / "worktrees" / run_id / "parent" current = load_only_run_state(git_repo) - GitRepository(git_repo).prepare_ticket_checkout( + GitRepository(managed_repo(git_repo)).prepare_ticket_checkout( branch=current["parent_job"]["parent_branch"], base_sha=current["parent_job"]["base_sha"], checkout=checkout, @@ -3671,8 +3672,8 @@ def test_abandon_requires_explicit_authorization_to_discard_dirty_checkout( == mutations_before ) assert checkout.exists() - task_control = TaskControlStore(git_repo / ".agent-run").load( - TaskKey(git_repo, "example/project", 1) + task_control = TaskControlStore(managed_state(git_repo)).load( + TaskKey(managed_repo(git_repo), "example/project", 1) ) action = ( task_control.get("action") if isinstance(task_control, dict) else None @@ -3782,7 +3783,7 @@ def test_scripted_cli_delivers_active_ticket_end_to_end( assert job["ticket_branch"] not in delivery["published_branches"] assert subprocess.run( ["git", "show-ref", "--verify", f"refs/heads/{job['ticket_branch']}"], - cwd=git_repo, + cwd=managed_repo(git_repo), capture_output=True, check=False, ).returncode != 0 @@ -3794,13 +3795,13 @@ def test_scripted_cli_delivers_active_ticket_end_to_end( "--count", f"{state['base']['sha']}..{state['run_branch']}", ], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, ).stdout.strip() assert count == "1" - assert not (git_repo / ".agent-run" / "worktrees" / run_id).exists() + assert not (managed_state(git_repo) / "worktrees" / run_id).exists() replayed = run_internal_stage( git_repo, @@ -3884,26 +3885,26 @@ def test_forward_candidate_repair_removes_prior_out_of_scope_file( assert "human_blockers" not in job assert subprocess.run( ["git", "cat-file", "-e", f"{candidate_sha}:feature.txt"], - cwd=git_repo, + cwd=managed_repo(git_repo), capture_output=True, check=False, ).returncode == 0 assert subprocess.run( ["git", "cat-file", "-e", f"{candidate_sha}:out-of-scope.txt"], - cwd=git_repo, + cwd=managed_repo(git_repo), capture_output=True, check=False, ).returncode != 0 assert subprocess.run( ["git", "diff", "--name-status", str(job["base_sha"]), candidate_sha], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, ).stdout.splitlines() == ["A\tfeature.txt"] assert subprocess.run( ["git", "log", "--format=%s", "-2", candidate_sha], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, @@ -3935,9 +3936,10 @@ def test_run_cli_records_one_final_parent_linked_branch_display( ) agents = git_repo / "final-run-agents.json" agents.write_text(json.dumps(final_run_agents()), encoding="utf-8") + prepare_workspace(git_repo) before_config = subprocess.run( ["git", "config", "--local", "--list"], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, @@ -3968,7 +3970,7 @@ def test_run_cli_records_one_final_parent_linked_branch_display( ] after_config = subprocess.run( ["git", "config", "--local", "--list"], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, @@ -4141,7 +4143,7 @@ def test_final_run_pr_drift_returns_to_fresh_acceptance_without_rewriting_pr( data = json.loads(fixture.read_text(encoding="utf-8")) actual_head = state["run_acceptance"]["acceptance_record"]["reviewed_head_sha"] if base_branch != "main": - subprocess.run(["git", "branch", base_branch, "main"], cwd=git_repo, check=True) + subprocess.run(["git", "branch", base_branch, "main"], cwd=managed_repo(git_repo), check=True) data.setdefault("delivery", {}).setdefault("pull_requests", []).append( { "number": 99, @@ -4224,7 +4226,7 @@ def test_persisted_final_run_pr_recovery_rejects_foreign_identity_without_effect resumed_publication["status"] = "waiting_external" resumed_publication["terminal_kind"] = "waiting_external" resumed_publication["run_publication"]["phase"] = "waiting_external" - StateStore(git_repo / ".agent-run").save_run( + StateStore(managed_state(git_repo)).save_run( str(before_state["run_id"]), resumed_publication ) @@ -4266,7 +4268,7 @@ def test_public_run_fails_closed_for_an_ambiguous_final_pr_read(git_repo: Path) resumed_publication["status"] = "waiting_external" resumed_publication["terminal_kind"] = "waiting_external" resumed_publication["run_publication"]["phase"] = "waiting_external" - StateStore(git_repo / ".agent-run").save_run( + StateStore(managed_state(git_repo)).save_run( str(before_state["run_id"]), resumed_publication ) @@ -4435,7 +4437,7 @@ def test_one_ticket_run_reaches_final_parent_closeout(git_repo: Path) -> None: "--verify", f"refs/heads/{load_only_run_state(git_repo)['run_branch']}", ], - cwd=git_repo, + cwd=managed_repo(git_repo), capture_output=True, check=False, ).returncode != 0 @@ -4520,7 +4522,7 @@ def test_pending_required_checks_resume_without_duplicate_pr_or_attempt( "next_action": "wait for Required Checks", } ] - checkout = git_repo / ".agent-run" / "worktrees" / run_id / "ticket-3" + checkout = managed_state(git_repo) / "worktrees" / run_id / "ticket-3" assert checkout.is_dir() git_link = (checkout / ".git").read_text(encoding="utf-8") @@ -4923,7 +4925,7 @@ def test_resume_targets_failed_run_repair_publication_not_completed_ticket( assert failed.returncode == 2, failed.stderr assert stdout_json(failed)["status"] == "execution_failed" - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) failed_state = load_only_run_state(git_repo) completed_ticket = failed_state["ticket_jobs"]["3"] completed_ticket["publication_thread_id"] = "completed-ticket-publication" diff --git a/tests/test_cli_execution_recovery.py b/tests/test_cli_execution_recovery.py index abc4a89..1c0a3ab 100644 --- a/tests/test_cli_execution_recovery.py +++ b/tests/test_cli_execution_recovery.py @@ -2,7 +2,9 @@ import json import subprocess +import time from pathlib import Path +from types import SimpleNamespace from typing import Any import pytest @@ -50,7 +52,9 @@ def worker(arguments: list[str], *, on_stdout_line: Any = None, monkeypatch.setenv('XDG_STATE_HOME', str(tmp_path / 'state')) monkeypatch.setenv('XDG_CONFIG_HOME', str(tmp_path / 'config')) monkeypatch.setattr('agent_run.codex.run_worker_process', worker) - monkeypatch.setattr('agent_run.codex.time.sleep', waits.append) + monkeypatch.setattr( + 'agent_run.codex.time', SimpleNamespace(monotonic=time.monotonic, sleep=waits.append), + ) monkeypatch.setattr(cli, 'FakeExecutorHost', lambda **kwargs: FakeExecutorHost()) monkeypatch.setattr(cli, 'CodexCliBackend', lambda **kwargs: CodexCliBackend( credential_provider=lambda: 'fixture-reader', **kwargs, diff --git a/tests/test_cli_mvp.py b/tests/test_cli_mvp.py index 224c696..31d6fad 100644 --- a/tests/test_cli_mvp.py +++ b/tests/test_cli_mvp.py @@ -9,9 +9,12 @@ import pytest from agent_run.semantic_attempt import canonical_fingerprint +from agent_run.managed_workspace import ManagedWorkspace +from agent_run.git import GitRepository from cli_fixtures import run_agents as _run_agents from conftest import seed_run, write_fixture from support.inprocess_cli import invoke_cli_inprocess +from support.workspace import managed_repo, managed_state from test_cli import ( git_fetch_failure_wrapper, load_only_run_state, @@ -118,7 +121,10 @@ def test_displayed_final_approval_selects_the_unique_active_run( assert first_approved.returncode == 0, first_approved.stderr assert stdout_json(first_approved)["status"] == "completed" - fixture = write_fixture(git_repo / "github.json", issues={"3": ticket()}) + fixture = write_fixture( + git_repo / "github.json", issues={"3": ticket()}, + default_head_sha=GitRepository(managed_repo(git_repo)).resolve("main"), + ) second_start = seed_run(git_repo, fixture, reuse_existing=False) second_run_id = str(stdout_json(second_start)["run_id"]) assert second_run_id != first_run_id @@ -147,7 +153,7 @@ def test_displayed_final_approval_selects_the_unique_active_run( assert approved.returncode == 0, (approved.stdout, approved.stderr) assert stdout_json(approved)["run_id"] == second_run_id assert stdout_json(approved)["status"] == "completed" - runs_dir = git_repo / ".agent-run" / "runs" + runs_dir = managed_state(git_repo) / "runs" first_state = json.loads( (runs_dir / f"{first_run_id}.json").read_text(encoding="utf-8") ) @@ -167,7 +173,7 @@ def test_parent_approval_selector_refuses_two_approval_ready_runs( git_repo, fixture, "run", "1", "--agent-fixture", str(agents) ) run_id = str(stdout_json(pending)["run_id"]) - original_path = git_repo / ".agent-run" / "runs" / f"{run_id}.json" + original_path = managed_state(git_repo) / "runs" / f"{run_id}.json" duplicate_id = f"{run_id}-duplicate" duplicate_path = original_path.with_name(f"{duplicate_id}.json") duplicate = json.loads( @@ -313,7 +319,7 @@ def test_history_continues_after_timeline_capacity_without_new_invocations( for _ in range(255) ] state.pop("timeline_at_capacity", None) - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") approved = run_cli(git_repo, fixture, "approve", run_id) @@ -371,7 +377,7 @@ def test_history_renders_device_timezone_and_keeps_json_events_in_utc( "result": "started", } ] - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") local = run_cli( @@ -428,7 +434,7 @@ def test_history_keeps_later_invocations_after_an_early_timeline_tail( state = load_only_run_state(git_repo) early_event = state["timeline"][0] state["timeline"] = [early_event] - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") history = invoke_cli_inprocess(git_repo, fixture, "history", run_id, "--json") @@ -593,7 +599,7 @@ def test_supervision_timeout_resume_opens_a_new_window_without_duplicate_deliver paused_state = load_only_run_state(git_repo) # Persisted Runs created before the response-audit protocol remain # observable and resumable instead of being rejected as incompatible. - run_file = next((git_repo / ".agent-run" / "runs").glob("*.json")) + run_file = next((managed_state(git_repo) / "runs").glob("*.json")) paused_state.pop("human_response_audit_protocol") paused_state.pop("human_response_audit") run_file.write_text(json.dumps(paused_state), encoding="utf-8") @@ -950,10 +956,10 @@ def test_legacy_run_is_rejected_before_initial_repository_wait_mutates_it( fixture = write_fixture(git_repo / "github.json", issues={}) agents = _run_agents(git_repo / "agents.json") locator_home = tmp_path / "locator-home" - locator_env = {"XDG_STATE_HOME": str(locator_home)} + locator_env = {"XDG_DATA_HOME": str(locator_home)} started = seed_run(git_repo, fixture, "1", extra_env=locator_env) assert started.returncode == 0, started.stderr - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo, extra_env=locator_env) / "runs").glob("*.json")) legacy = json.loads(state_path.read_text(encoding="utf-8")) if legacy_protocol is None: legacy.pop("branch_authority_protocol") @@ -972,19 +978,19 @@ def test_legacy_run_is_rejected_before_initial_repository_wait_mutates_it( locator_path.unlink() head_before = subprocess.run( ["git", "rev-parse", "HEAD"], - cwd=git_repo, + cwd=managed_repo(git_repo, extra_env=locator_env), check=True, text=True, capture_output=True, ).stdout branches_before = subprocess.run( ["git", "branch", "--format=%(refname:short)"], - cwd=git_repo, + cwd=managed_repo(git_repo, extra_env=locator_env), check=True, text=True, capture_output=True, ).stdout - git_config_before = (git_repo / ".git" / "config").read_bytes() + git_config_before = (managed_repo(git_repo, extra_env=locator_env) / ".git" / "config").read_bytes() agent_before = agents.read_text(encoding="utf-8") arguments = ("run", "1", "--agent-fixture", str(agents)) @@ -996,19 +1002,19 @@ def test_legacy_run_is_rejected_before_initial_repository_wait_mutates_it( assert not locator_path.exists() assert subprocess.run( ["git", "rev-parse", "HEAD"], - cwd=git_repo, + cwd=managed_repo(git_repo, extra_env=locator_env), check=True, text=True, capture_output=True, ).stdout == head_before assert subprocess.run( ["git", "branch", "--format=%(refname:short)"], - cwd=git_repo, + cwd=managed_repo(git_repo, extra_env=locator_env), check=True, text=True, capture_output=True, ).stdout == branches_before - assert (git_repo / ".git" / "config").read_bytes() == git_config_before + assert (managed_repo(git_repo, extra_env=locator_env) / ".git" / "config").read_bytes() == git_config_before assert json.loads(fixture.read_text(encoding="utf-8")).get("delivery") == delivery_before assert agents.read_text(encoding="utf-8") == agent_before @@ -1234,7 +1240,7 @@ def test_status_keeps_allowed_actions_without_inventing_host_activity(git_repo: fixture = write_fixture(git_repo / "github.json", issues={"3": ticket()}) run_id = stdout_json(seed_run(git_repo, fixture, "1"))["run_id"] state = load_only_run_state(git_repo) - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) for status in ("waiting_merge", "parent_closeout_pending"): state["status"] = status @@ -1312,7 +1318,7 @@ def test_run_restarts_a_paused_supervision_window(git_repo: Path) -> None: agents = _run_agents(git_repo / "agents.json") started = seed_run(git_repo, fixture, "1", idle_control=True) run_id = stdout_json(started)["run_id"] - run_file = next((git_repo / ".agent-run" / "runs").glob("*.json")) + run_file = next((managed_state(git_repo) / "runs").glob("*.json")) state = load_only_run_state(git_repo) state.update( { @@ -1345,7 +1351,7 @@ def test_publish_run_retries_final_pr_after_external_wait(git_repo: Path) -> Non agents = _run_agents(git_repo / "agents.json") started = run_cli(git_repo, fixture, "run", "1", "--agent-fixture", str(agents)) run_id = stdout_json(started)["run_id"] - run_file = next((git_repo / ".agent-run" / "runs").glob("*.json")) + run_file = next((managed_state(git_repo) / "runs").glob("*.json")) state = load_only_run_state(git_repo) state.update({"status": "waiting_external", "terminal_kind": "waiting_external"}) state["run_publication"]["phase"] = "waiting_checks" @@ -1366,7 +1372,7 @@ def test_run_supervises_read_failures_after_supervision_timeout( agents = _run_agents(git_repo / "agents.json") started = seed_run(git_repo, fixture, "1", idle_control=True) run_id = stdout_json(started)["run_id"] - run_file = next((git_repo / ".agent-run" / "runs").glob("*.json")) + run_file = next((managed_state(git_repo) / "runs").glob("*.json")) state = load_only_run_state(git_repo) state.update( { @@ -1397,7 +1403,7 @@ def test_run_repauses_after_a_fresh_external_wait_window( fixture = write_fixture(git_repo / "github.json", issues={"3": ticket()}) agents = _run_agents(git_repo / "agents.json") run_id = stdout_json(seed_run(git_repo, fixture, "1", idle_control=True))["run_id"] - run_file = next((git_repo / ".agent-run" / "runs").glob("*.json")) + run_file = next((managed_state(git_repo) / "runs").glob("*.json")) state = load_only_run_state(git_repo) state.update( { @@ -1439,6 +1445,7 @@ def test_public_run_and_resume_reuse_initial_fetch_failure_run( cwd=git_repo, check=True, ) + ManagedWorkspace.for_repository("example/project").ensure(remote_url=str(remote)) updater = tmp_path / "updater" subprocess.run(["git", "clone", str(remote), str(updater)], check=True) subprocess.run(["git", "config", "user.name", "Updater"], cwd=updater, check=True) @@ -1487,7 +1494,7 @@ def test_public_run_and_resume_reuse_initial_fetch_failure_run( assert counter.read_text(encoding="utf-8") == "0\n" failed_state = load_only_run_state(git_repo) assert failed_state["base_resolution_pending"] is True - assert not list((git_repo / ".git" / "refs" / "heads" / "agent-run").rglob("*")) + assert not list((managed_repo(git_repo) / ".git" / "refs" / "heads" / "agent-run").rglob("*")) held = run_cli( git_repo, @@ -1505,7 +1512,7 @@ def test_public_run_and_resume_reuse_initial_fetch_failure_run( assert stdout_json(resumed)["run_id"] == run_id state = load_only_run_state(git_repo) assert state["status"] == "progress_exhausted" - assert len(list((git_repo / ".agent-run" / "runs").glob("*.json"))) == 1 + assert len(list((managed_state(git_repo) / "runs").glob("*.json"))) == 1 def test_run_reconciles_an_already_created_ticket_pr_after_response_loss( @@ -1607,12 +1614,12 @@ def test_incompatible_state_preserves_existing_publisher_ledger_and_worktree( assert interrupted.returncode == 2 state = load_only_run_state(git_repo) run_id = state["run_id"] - checkout = git_repo / ".agent-run" / "worktrees" / run_id / "ticket-3" + checkout = managed_state(git_repo) / "worktrees" / run_id / "ticket-3" assert checkout.is_dir() preserved = checkout / "legacy-recovery.txt" preserved.write_text("do not clean or publish\n", encoding="utf-8") state["schema_version"] = 1 - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") state_before = state_path.read_text(encoding="utf-8") fixture_before = fixture.read_text(encoding="utf-8") @@ -1625,7 +1632,7 @@ def test_incompatible_state_preserves_existing_publisher_ledger_and_worktree( ] worktrees_before = subprocess.run( ["git", "worktree", "list", "--porcelain"], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, @@ -1648,7 +1655,7 @@ def test_incompatible_state_preserves_existing_publisher_ledger_and_worktree( assert ( subprocess.run( ["git", "worktree", "list", "--porcelain"], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, @@ -1740,7 +1747,7 @@ def test_completed_run_is_not_reopened_by_run_command(git_repo: Path) -> None: assert stdout_json(replayed)["status"] == "progress_exhausted" states = [ json.loads(path.read_text(encoding="utf-8")) - for path in (git_repo / ".agent-run" / "runs").glob("*.json") + for path in (managed_state(git_repo) / "runs").glob("*.json") ] assert any(state["status"] == "completed" for state in states) assert any(state["status"] == "progress_exhausted" for state in states) diff --git a/tests/test_cli_notifications.py b/tests/test_cli_notifications.py index fe38d7d..ec1869c 100644 --- a/tests/test_cli_notifications.py +++ b/tests/test_cli_notifications.py @@ -12,6 +12,8 @@ import pytest +from support.workspace import managed_state + from agent_run.notification_cards import card from agent_run.notification_events import events, recovery_event from agent_run.notifications import read_notifications @@ -148,7 +150,7 @@ def execute() -> subprocess.CompletedProcess[str]: assert "未配置合并前检查" in json.dumps(approval_card, ensure_ascii=False) assert "任务已完成" not in titles assert all(item["schema"] == "2.0" for item in cards) - journal = read_notifications(git_repo / ".agent-run", output["run_id"]) + journal = read_notifications(managed_state(git_repo), output["run_id"]) assert journal["seen"] if drain: assert any(record["outcome"] == "success" for record in journal["records"]) @@ -170,7 +172,7 @@ def execute() -> subprocess.CompletedProcess[str]: assert queried.returncode == 0, queried.stderr assert "notifications" in stdout_json(queried) assert (messages.read_bytes() if messages.exists() else b"") == before - assert read_notifications(git_repo / ".agent-run", output["run_id"]) == journal + assert read_notifications(managed_state(git_repo), output["run_id"]) == journal # Approval reuses the immutable Run configuration even after defaults change. UserDefaultsStore().configure(notifications={"enabled": False}) approved = invoke("approve", output["run_id"]) diff --git a/tests/test_cli_run_final_approval.py b/tests/test_cli_run_final_approval.py index 22f4931..5da4c69 100644 --- a/tests/test_cli_run_final_approval.py +++ b/tests/test_cli_run_final_approval.py @@ -6,6 +6,8 @@ import pytest +from support.workspace import managed_state + from cli_fixtures import run_agents from conftest import seed_run, write_fixture @@ -498,7 +500,7 @@ def test_resume_finished_delivery_only_retries_unfinished_cleanup(git_repo: Path cleanup["status"] = "cleanup_pending" for item in cleanup["items"].values(): item["status"] = "cleanup_pending" - StateStore(git_repo / ".agent-run").save_run(run_id, state) + StateStore(managed_state(git_repo)).save_run(run_id, state) resumed = run_cli(git_repo, fixture, "resume", "1") assert resumed.returncode == 0, f"{resumed.stdout}\n{resumed.stderr}" @@ -545,7 +547,7 @@ def mark_executor_absent(self, *args: Any, **kwargs: Any) -> dict[str, Any]: assert interrupted.returncode == 2 completed = load_only_run_state(git_repo) assert completed["status"] == "completed" - control_path = next((git_repo / ".agent-run" / "task-control").glob("*.json")) + control_path = next((managed_state(git_repo) / "task-control").glob("*.json")) original_control = json.loads(control_path.read_text()) assert original_control["action"]["status"] == ( "completed" if receipt_saved else "applying" diff --git a/tests/test_cli_run_repair_supervision.py b/tests/test_cli_run_repair_supervision.py index bb0f6b2..3a2e6ea 100644 --- a/tests/test_cli_run_repair_supervision.py +++ b/tests/test_cli_run_repair_supervision.py @@ -8,6 +8,7 @@ from cli_fixtures import run_agents from conftest import write_fixture from support.inprocess_cli import invoke_cli_inprocess +from support.workspace import managed_state from support.published_run import prepare_accepted_run, prepare_published_run from test_cli import load_only_run_state, run_cli, run_internal_stage, stdout_json from test_cli_delivery import ( @@ -766,7 +767,7 @@ def test_run_supervises_post_approval_final_check_evidence_reads( recovery_agents = _resume_repair_agents( git_repo / "recovery-agents.json", expected_thread_id=None ) - control_path = next((git_repo / ".agent-run" / "task-control").glob("*.json")) + control_path = next((managed_state(git_repo) / "task-control").glob("*.json")) control_before = control_path.read_bytes() fixture_before = fixture.read_bytes() refused = run_cli(git_repo, fixture, "run", "1") diff --git a/tests/test_cli_run_supervision.py b/tests/test_cli_run_supervision.py index b74c6f9..3a923ef 100644 --- a/tests/test_cli_run_supervision.py +++ b/tests/test_cli_run_supervision.py @@ -12,6 +12,7 @@ from cli_fixtures import run_agents from conftest import write_fixture from support.inprocess_cli import invoke_cli_inprocess +from support.workspace import managed_state from test_cli import load_only_run_state, run_cli, stdout_json from test_cli_delivery import ( parent_publication, @@ -31,7 +32,7 @@ def _write_executor_record(repo: Path, pid: int, start_token: str) -> None: - directory = repo / ".agent-run" / "task-control" + directory = managed_state(repo) / "task-control" directory.mkdir(parents=True, exist_ok=True) (directory / "fixture.json").write_text( json.dumps( @@ -189,7 +190,7 @@ def test_waiting_status_and_history_expose_a_sanitized_supervision_snapshot( _interrupt_run(process, git_repo) state_files = { - path: path.read_bytes() for path in (git_repo / ".agent-run").rglob("*.json") + path: path.read_bytes() for path in (managed_state(git_repo)).rglob("*.json") } for host_exit, activity in ((4, "not_running"), (1, "unknown")): # systemctl exit 4 confirms an absent unit; exit 1 cannot establish @@ -222,7 +223,7 @@ def test_waiting_status_and_history_expose_a_sanitized_supervision_snapshot( assert "截止=" not in text assert "超时恢复:" not in text assert { - path: path.read_bytes() for path in (git_repo / ".agent-run").rglob("*.json") + path: path.read_bytes() for path in (managed_state(git_repo)).rglob("*.json") } == state_files finally: _interrupt_run(process, git_repo) diff --git a/tests/test_cli_user_defaults.py b/tests/test_cli_user_defaults.py index 8c8dfd9..7251af6 100644 --- a/tests/test_cli_user_defaults.py +++ b/tests/test_cli_user_defaults.py @@ -10,6 +10,8 @@ import pytest +from support.workspace import managed_state, prepare_workspace + from agent_run import cli from agent_run.agent_profiles import AgentProfileStore from agent_run.user_defaults import UserDefaultsStore @@ -33,7 +35,9 @@ def test_run_settings_distinguishes_initialization_from_missing_or_corrupt_profi from test_run_lifecycle import _file_snapshot monkeypatch.chdir(git_repo) - root = git_repo / ".agent-run" + prepare_workspace(git_repo) + subprocess.run(["git", "remote", "add", "origin", "https://github.com/example/project.git"], cwd=git_repo, check=True) + root = managed_state(git_repo) state = { "run_id": "run-settings", "repository": "example/project", "parent": {"number": 1}, "status": "starting", "schema_version": 1, @@ -97,7 +101,7 @@ def test_public_settings_file_cli_and_new_old_run_execution( assert original["notifications"]["enabled"] is False assert original["creation_configuration"]["notifications"] == original["notifications"] run_id = original["run_id"] - profile = AgentProfileStore(git_repo / ".agent-run").load(run_id) + profile = AgentProfileStore(managed_state(git_repo)).load(run_id) assert profile is not None assert profile["bindings"][0]["model"] == "old-model" assert original["agent_invocation_history"][0]["deadline_seconds"] == 660 @@ -123,20 +127,20 @@ def test_public_settings_file_cli_and_new_old_run_execution( assert resumed.returncode == 2, resumed.stdout + resumed.stderr original_after = load_only_run_state(git_repo) assert [i["deadline_seconds"] for i in original_after["agent_invocation_history"]] == [660, 660], resumed.stdout + resumed.stderr - frozen = AgentProfileStore(git_repo / ".agent-run").load(run_id) + frozen = AgentProfileStore(managed_state(git_repo)).load(run_id) assert frozen is not None assert frozen["bindings"] == profile["bindings"] second_repo = tmp_path / "second" subprocess.run(["git", "clone", "--local", str(git_repo), str(second_repo)], check=True, capture_output=True) - second_fixture = write_fixture(second_repo / "github.json", issues={}) + second_fixture = write_fixture(second_repo / "github.json", issues={}, repository="example/second") agents.write_text(json.dumps({"developments": [human_blocker_step("new-thread")]})) created = run_cli(second_repo, second_fixture, "run", "1", "--agent-fixture", str(agents)) assert created.returncode == 2, created.stdout + created.stderr new_state = load_only_run_state(second_repo) assert new_state["notifications"]["enabled"] is False assert new_state["notifications"]["open_id"] == "ou_new" - new_profile = AgentProfileStore(second_repo / ".agent-run").load(new_state["run_id"]) + new_profile = AgentProfileStore(managed_state(second_repo)).load(new_state["run_id"]) assert new_profile is not None assert new_profile["bindings"][0]["model"] == "new-model" assert new_profile["bindings"][0]["reasoning_effort"] == "high" diff --git a/tests/test_cli_workspace.py b/tests/test_cli_workspace.py new file mode 100644 index 0000000..d46d60d --- /dev/null +++ b/tests/test_cli_workspace.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +import pytest + +from agent_run.managed_workspace import ManagedWorkspace +from agent_run.task_control import TaskControlStore, TaskKey +from conftest import seed_idle_control, seed_run, write_fixture +from test_cli import run_cli, issue, stdout_json + + +def _files(root: Path) -> dict[str, bytes]: + return { + str(path.relative_to(root)): path.read_bytes() + for path in root.rglob("*") + if path.is_file() + } + + +@pytest.mark.parametrize("inherited_git_location", [False, True]) +def test_run_does_not_write_user_checkout( + git_repo: Path, tmp_path: Path, inherited_git_location: bool, +) -> None: + fixture = write_fixture( + git_repo / "github.json", issues={"2": issue(2, labels=[])}, + repository="Example/Project", + ).rename(tmp_path / "github.json") + (git_repo / "README.md").write_text("user's unfinished edit\n") + (git_repo / "private-note.txt").write_text("untracked user file\n") + before = _files(git_repo) + + environment = { + "GIT_DIR": str(git_repo / ".git"), + "GIT_WORK_TREE": str(git_repo), + "GIT_INDEX_FILE": str(git_repo / ".git" / "index"), + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "core.worktree", + "GIT_CONFIG_VALUE_0": str(git_repo), + } if inherited_git_location else {} + result = run_cli(git_repo, fixture, "run", "1", extra_env=environment) + + assert result.returncode == 2, result.stderr + assert stdout_json(result)["status"] == "progress_exhausted" + assert _files(git_repo) == before + + +def test_two_clones_and_outside_repo_commands_reuse_one_task( + git_repo: Path, tmp_path: Path, +) -> None: + subprocess.run( + ["git", "remote", "add", "origin", "https://github.com/example/project.git"], + cwd=git_repo, check=True, capture_output=True, + ) + second_clone = tmp_path / "second-clone" + subprocess.run( + ["git", "clone", "--no-local", str(git_repo), str(second_clone)], + check=True, capture_output=True, + ) + subprocess.run( + ["git", "remote", "set-url", "origin", "https://github.com/example/project.git"], + cwd=second_clone, check=True, capture_output=True, + ) + fixture = write_fixture( + git_repo / "github.json", issues={"2": issue(2, labels=[])} + ).rename(tmp_path / "github.json") + (git_repo / "README.md").write_text("first user's unfinished edit\n") + (second_clone / "private-note.txt").write_text("second user's untracked note\n") + originals = {repo: _files(repo) for repo in (git_repo, second_clone)} + outside = tmp_path / "outside" + outside.mkdir() + started = run_cli(git_repo, fixture, "run", "1") + assert started.returncode == 2, started.stderr + run_id = stdout_json(started)["run_id"] + + from_second_clone = run_cli(second_clone, fixture, "run", "1") + assert from_second_clone.returncode == 2, from_second_clone.stderr + assert stdout_json(from_second_clone)["run_id"] == run_id + inferred_status = subprocess.run( + [sys.executable, "-m", "agent_run", "status", "--parent", "1", "--json"], + cwd=second_clone, + env={**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")}, + text=True, capture_output=True, check=False, + ) + assert inferred_status.returncode == 0, inferred_status.stderr + assert stdout_json(inferred_status)["run_id"] == run_id + for command in ("status", "run", "resume"): + if command == "resume": + stopped = run_cli(outside, fixture, "stop", "1", "--repo", "example/project") + assert stopped.returncode == 0, stopped.stderr + assert stdout_json(stopped)["status"] == "operator_stopped" + selector = ("--parent", "1") if command == "status" else ("1",) + result = run_cli( + outside, fixture, command, *selector, "--repo", "example/project", "--json", + ) + assert result.returncode == (0 if command == "status" else 2), result.stderr + output = stdout_json(result) + assert output.get("run_id") == run_id, f"{command}: {json.dumps(output, ensure_ascii=False)}" + + workspace = ManagedWorkspace.for_repository("example/project") + assert [path.stem for path in (workspace.state_root / "runs").glob("*.json")] == [run_id] + assert all(_files(repo) == before for repo, before in originals.items()) + assert not list(outside.iterdir()) + + +def test_different_parents_keep_independent_unfinished_tasks( + git_repo: Path, tmp_path: Path, +) -> None: + fixtures: dict[int, Path] = {} + for parent, ticket in ((1, 2), (10, 11)): + fixtures[parent] = write_fixture( + git_repo / f"github-{parent}.json", + issues={str(ticket): issue(ticket, labels=[])}, + parent={ + "number": parent, + "title": f"Parent {parent}", + "body": "Deliver this independent task.", + "sub_issues": [ticket], + "sub_issue_order_reliable": True, + }, + ).rename(tmp_path / f"github-{parent}.json") + before = _files(git_repo) + run_ids: dict[int, str] = {} + + for parent, fixture in fixtures.items(): + result = run_cli(git_repo, fixture, "run", str(parent)) + assert result.returncode == 2, result.stderr + output = stdout_json(result) + assert output["status"] == "progress_exhausted" + run_ids[parent] = output["run_id"] + + assert len(set(run_ids.values())) == 2 + workspace = ManagedWorkspace.for_repository("example/project") + assert {path.stem for path in (workspace.state_root / "runs").glob("*.json")} == set(run_ids.values()) + for parent, fixture in fixtures.items(): + status = run_cli( + tmp_path, fixture, "status", "--repo", "example/project", "--parent", str(parent), "--json", + ) + assert status.returncode == 0, status.stderr + output = stdout_json(status) + assert output["run_id"] == run_ids[parent] + assert output["parent"]["number"] == parent + assert output["status"] == "progress_exhausted" + assert _files(git_repo) == before + + +def test_state_override_cannot_write_into_user_repository( + git_repo: Path, tmp_path: Path, +) -> None: + fixture = write_fixture( + git_repo / "github.json", issues={"2": issue(2, labels=[])} + ).rename(tmp_path / "github.json") + override = git_repo / ".agent-run" + before = _files(git_repo) + + result = run_cli(git_repo, fixture, "run", "1", "--state-dir", str(override)) + + assert result.returncode == 2, result.stderr + output = stdout_json(result) + assert any("统一状态目录" in item["message"] for item in output["diagnostics"]) + assert not override.exists() + assert _files(git_repo) == before + workspace = ManagedWorkspace.for_repository("example/project") + assert not list((workspace.state_root / "runs").glob("*.json")) + + +@pytest.mark.parametrize("command", ["stop", "abandon"]) +def test_control_rejects_a_record_bound_to_external_state( + git_repo: Path, tmp_path: Path, command: str, +) -> None: + fixture = write_fixture(git_repo / "github.json", issues={"2": issue(2)}) + started = seed_run(git_repo, fixture) + assert started.returncode == 0, started.stderr + run_id = stdout_json(started)["run_id"] + workspace = ManagedWorkspace.for_repository("example/project") + external = tmp_path / "external-state" + external.mkdir() + (external / "untouched.txt").write_text("user-owned state\n") + seed_idle_control( + TaskControlStore(workspace.state_root), + TaskKey(workspace.repository_root, "example/project", 1), + run_id, + state_dir=external, + ) + snapshots = {path: _files(path) for path in (git_repo, workspace.state_root, external)} + + result = run_cli(git_repo, fixture, command, run_id) + + assert result.returncode == 2, result.stderr + assert "统一状态目录" in result.stdout + assert all(_files(path) == before for path, before in snapshots.items()) diff --git a/tests/test_codex_backend.py b/tests/test_codex_backend.py index 4065631..8be93b7 100644 --- a/tests/test_codex_backend.py +++ b/tests/test_codex_backend.py @@ -4784,10 +4784,7 @@ def test_worker_descendant_inherits_hidden_control_and_runner_paths( runner_root = data_home / "agent-run" runner_root.mkdir(parents=True) (runner_root / "management-secret").write_text("secret", encoding="utf-8") - state_home = tmp_path / "state" - locator_root = state_home / "agent-run" - locator_root.mkdir(parents=True) - (locator_root / "run-locator.json").write_text("secret", encoding="utf-8") + (runner_root / "run-locator.json").write_text("secret", encoding="utf-8") runtime_home = tmp_path / "runtime" systemd_private = runtime_home / "systemd" / "private" systemd_private.parent.mkdir(parents=True) @@ -4801,7 +4798,6 @@ def test_worker_descendant_inherits_hidden_control_and_runner_paths( temporary = tmp_path / "worker-temp" temporary.mkdir() monkeypatch.setenv("XDG_DATA_HOME", str(data_home)) - monkeypatch.setenv("XDG_STATE_HOME", str(state_home)) monkeypatch.setenv("XDG_RUNTIME_DIR", str(runtime_home)) monkeypatch.setenv("DBUS_SESSION_BUS_ADDRESS", f"unix:path={user_bus}") monkeypatch.setenv("AGENT_RUN_EXECUTOR_ACTION_ID", "secret-action") @@ -4850,7 +4846,7 @@ def test_worker_descendant_inherits_hidden_control_and_runner_paths( "CUSTOM_PROFILE": str(custom_profile), "CUSTOM_LOCK": str(custom_lock), "RUNNER_SECRET": str(runner_root / "management-secret"), - "LOCATOR_SECRET": str(locator_root / "run-locator.json"), + "LOCATOR_SECRET": str(runner_root / "run-locator.json"), "CARRIER_SECRET": str(carrier), "USER_BUS": str(user_bus), "SYSTEMD_PRIVATE": str(systemd_private), diff --git a/tests/test_control_crash_activity.py b/tests/test_control_crash_activity.py index d985cec..a6207ae 100644 --- a/tests/test_control_crash_activity.py +++ b/tests/test_control_crash_activity.py @@ -5,6 +5,8 @@ import pytest +from support.workspace import managed_state + from agent_run import cli from agent_run.executor_host import FakeExecutorHost, HostObservation from agent_run.state import StateStore @@ -19,7 +21,7 @@ def test_control_receipt_crash_keeps_target_activity_unknown( ) -> None: fixture = write_fixture(git_repo / 'github.json', issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / '.agent-run') + states = StateStore(managed_state(git_repo)) state = states.find_unfinished_runs('example/project', 1)[0] run_id = str(state['run_id']) control, task, worker = _bind_running_executor(git_repo, run_id) diff --git a/tests/test_control_environment_admission.py b/tests/test_control_environment_admission.py index 052d73c..9efe24a 100644 --- a/tests/test_control_environment_admission.py +++ b/tests/test_control_environment_admission.py @@ -6,6 +6,8 @@ import pytest +from support.workspace import managed_repo, managed_state + from conftest import seed_idle_control, seed_run, write_fixture from test_ticket_194_stop_abandon import ( _bind_running_executor, @@ -49,7 +51,7 @@ def test_control_environment_rejected_before_ownership_changes( monkeypatch.setenv(key, value) fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = str(current["run_id"]) worker = None @@ -64,7 +66,7 @@ def cleanup_worker() -> None: request.addfinalizer(cleanup_worker) else: control = TaskControlStore(states.root) - task = TaskKey(git_repo, "example/project", 1) + task = TaskKey(managed_repo(git_repo), "example/project", 1) seed_idle_control(control, task, run_id, state_dir=states.root) transport = FakeSystemdTransport() host = SystemdUserExecutorHost( @@ -192,7 +194,7 @@ def test_running_control_attaches_with_oversize_observer_environment( monkeypatch.setenv(key, value) fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = str(current["run_id"]) control, task, worker = _bind_running_executor(git_repo, run_id) diff --git a/tests/test_control_receipt_reconciliation.py b/tests/test_control_receipt_reconciliation.py index a101b28..07654b4 100644 --- a/tests/test_control_receipt_reconciliation.py +++ b/tests/test_control_receipt_reconciliation.py @@ -5,6 +5,8 @@ import pytest +from support.workspace import managed_state + from agent_run import cli as cli_module from agent_run.executor_host import ExecutorSpec, FakeExecutorHost, HostObservation from agent_run.state import StateStore @@ -29,7 +31,7 @@ def test_control_command_reconciles_receipt_before_admission( monkeypatch.setenv(key, value) fixture = write_fixture(git_repo / "github.json", issues={"3": ticket()}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) state = states.find_unfinished_runs("example/project", 1)[0] run_id = state["run_id"] control, task, worker = _bind_running_executor(git_repo, run_id) @@ -130,7 +132,7 @@ def test_control_receipt_crash_before_business_effect_preserves_the_intent( monkeypatch.setenv(key, value) fixture = write_fixture(git_repo / "github.json", issues={"3": ticket()}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) state = states.find_unfinished_runs("example/project", 1)[0] run_id = state["run_id"] control, task, worker = _bind_running_executor(git_repo, run_id) diff --git a/tests/test_control_target_receipt_recovery.py b/tests/test_control_target_receipt_recovery.py index 816b473..85aadbb 100644 --- a/tests/test_control_target_receipt_recovery.py +++ b/tests/test_control_target_receipt_recovery.py @@ -8,6 +8,8 @@ import pytest +from support.workspace import managed_state + from agent_run import cli from agent_run import executor_host as host_module from agent_run.executor_host import ( @@ -39,7 +41,7 @@ def test_failed_stop_missing_control_keeps_target_until_confirmed_exit( agents = _parent_only_agents(git_repo / "agents.json") seeded = seed_run(git_repo, fixture) assert seeded.returncode == 0, seeded.stderr - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = str(current["run_id"]) control, task, worker = _bind_running_executor(git_repo, run_id) diff --git a/tests/test_delivery.py b/tests/test_delivery.py index 7e4ac59..d41efb3 100644 --- a/tests/test_delivery.py +++ b/tests/test_delivery.py @@ -2843,6 +2843,8 @@ def test_ticket_required_check_failure_uses_exact_head_repair_evidence( 'code-failure-steps = ["ci::test::Run tests"]\n', encoding="utf-8", ) + subprocess.run(["git", "add", "pyproject.toml"], cwd=git_repo, check=True) + subprocess.run(["git", "commit", "-qm", "configure check policy"], cwd=git_repo, check=True) fixture = write_fixture(git_repo / "github.json", issues={"3": issue(3)}) states = StateStore(git_repo / ".agent-run") state, _ = Controller( diff --git a/tests/test_executor_completion_attach.py b/tests/test_executor_completion_attach.py index eb31c58..07fece0 100644 --- a/tests/test_executor_completion_attach.py +++ b/tests/test_executor_completion_attach.py @@ -6,6 +6,8 @@ import pytest +from support.workspace import managed_repo, managed_state + from agent_run.executor_host import ExecutorSpec, FakeExecutorHost, HostObservation from agent_run.run_lifecycle import ( LifecycleRequest, @@ -38,10 +40,10 @@ def test_attach_preserves_executor_completion_at_host_inspection( monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg-config")) fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) state = states.find_unfinished_runs("example/project", 1)[0] run_id = state["run_id"] - task = TaskKey(git_repo, "example/project", 1) + task = TaskKey(managed_repo(git_repo), "example/project", 1) control = TaskControlStore(states.root) claim = control.claim_action(task, kind="run", payload={"parent": 1}) assert claim.action is not None and claim.action_id is not None diff --git a/tests/test_executor_exit_reconciliation.py b/tests/test_executor_exit_reconciliation.py index e1d9851..08fd7e3 100644 --- a/tests/test_executor_exit_reconciliation.py +++ b/tests/test_executor_exit_reconciliation.py @@ -6,6 +6,8 @@ import pytest +from support.workspace import managed_repo, managed_state + from agent_run.executor_host import _process_start_token from agent_run.cli import main from agent_run.run_lifecycle import prepare_action_application_receipt @@ -19,11 +21,11 @@ def test_stop_idle_delivery_persists_operator_pause(git_repo: Path) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) original = states.find_unfinished_runs("example/project", 1)[0] run_id = original["run_id"] control = TaskControlStore(states.root) - task = TaskKey(git_repo, "example/project", 1) + task = TaskKey(managed_repo(git_repo), "example/project", 1) seed_idle_control(control, task, run_id, state_dir=states.root) result = _run_cli(git_repo, fixture, "stop", run_id) @@ -45,11 +47,11 @@ def test_resume_reconciles_externally_exited_executor( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = current["run_id"] control = TaskControlStore(states.root) - task = TaskKey(git_repo, "example/project", 1) + task = TaskKey(managed_repo(git_repo), "example/project", 1) claim = control.claim_action(task, kind="run", payload={"parent": 1}) reservation = control.begin_executor(task, action_id=claim.action_id, run_id=run_id) child = subprocess.Popen([sys.executable, "-c", "pass"]) diff --git a/tests/test_executor_failure_recovery.py b/tests/test_executor_failure_recovery.py index eae21c5..8b496bd 100644 --- a/tests/test_executor_failure_recovery.py +++ b/tests/test_executor_failure_recovery.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import subprocess import sys from pathlib import Path @@ -12,7 +13,7 @@ from agent_run.task_control import ActionReconciliationError, TaskControlStore, TaskKey from cli_fixtures import run_agents from conftest import write_fixture -from test_cli import load_only_run_state, run_cli, stdout_json +from test_cli import PROJECT_ROOT, load_only_run_state, run_cli, stdout_json from test_cli_delivery import ticket @@ -72,7 +73,8 @@ def failed_push(self, branch, head_sha, *, expected_remote_sha): failed = subprocess.run( [sys.executable, "-c", script, "run", "1", "--json", "--github-fixture", str(fixture), "--agent-fixture", str(agents)], - cwd=git_repo, capture_output=True, text=True, check=False, timeout=30, + cwd=git_repo, env={**os.environ, "PYTHONPATH": str(PROJECT_ROOT / "src")}, + capture_output=True, text=True, check=False, timeout=30, ) assert failed.returncode == 2, failed.stderr diff --git a/tests/test_executor_fence_contention.py b/tests/test_executor_fence_contention.py index 2dc77dd..c0530f2 100644 --- a/tests/test_executor_fence_contention.py +++ b/tests/test_executor_fence_contention.py @@ -8,6 +8,8 @@ import pytest +from support.workspace import managed_repo, managed_state + from conftest import seed_run, write_fixture from test_run_lifecycle import _isolated_environment from test_receipt_successors import _install_host @@ -38,7 +40,7 @@ def test_cli_stop_preflight_contention_revalidates_before_commit( monkeypatch.setenv(key, value) fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) state = states.find_unfinished_runs("example/project", 1)[0] run_id = state["run_id"] # Only a terminal Run now takes the read-only Stop preflight. Keep the @@ -65,14 +67,14 @@ def test_cli_stop_preflight_contention_revalidates_before_commit( operations = DirectRunOperations( controller=SimpleNamespace(states=states), states=states, - git=GitRepository(git_repo), + git=GitRepository(managed_repo(git_repo)), github_reader=object(), publisher_factory=object, agents=object(), before_external_step=fence, ) - head = GitRepository(git_repo).checkout_head(git_repo) - (git_repo / "README.md").write_text("candidate\n") + head = GitRepository(managed_repo(git_repo)).checkout_head(managed_repo(git_repo)) + (managed_repo(git_repo) / "README.md").write_text("candidate\n") before_state = (states.runs_directory / f"{run_id}.json").read_bytes() before_control = control.path_for(task).read_bytes() host = _install_host(git_repo, fixture, monkeypatch, "running") @@ -129,7 +131,7 @@ def retry_sleep(_delay): ): future = executor.submit( operations.git.commit_candidate, - git_repo, + managed_repo(git_repo), ticket_number=194, attempt=1, expected_head=head, @@ -178,10 +180,10 @@ def retry_sleep(_delay): future.result(timeout=5) assert dispatched == (["commit"] if revocation == "none" else []) if revocation == "none": - assert GitRepository(git_repo).checkout_head(git_repo) != head + assert GitRepository(managed_repo(git_repo)).checkout_head(managed_repo(git_repo)) != head states.save_run(run_id, {**state, "status": "completed"}) else: - assert GitRepository(git_repo).checkout_head(git_repo) == head + assert GitRepository(managed_repo(git_repo)).checkout_head(managed_repo(git_repo)) == head with pytest.raises(ActionReconciliationError): states.save_run(run_id, {**state, "status": "completed"}) assert ( diff --git a/tests/test_github_publish.py b/tests/test_github_publish.py index 160b65b..a573326 100644 --- a/tests/test_github_publish.py +++ b/tests/test_github_publish.py @@ -51,6 +51,10 @@ def test_real_publisher_applies_repository_owned_check_repairability( 'code-failure-steps = ["tests::unit::Run tests"]\n', encoding="utf-8", ) + subprocess.run(["git", "add", "pyproject.toml"], cwd=git_repo, check=True) + subprocess.run(["git", "commit", "-qm", "configure check policy"], cwd=git_repo, check=True) + head = GitRepository(git_repo).resolve("HEAD") + (git_repo / "pyproject.toml").write_text("[project]\nname = 'uncommitted-policy'\n") publisher = GhGitHubPublisher("example/project", GitRepository(git_repo)) monkeypatch.setattr( publisher, @@ -71,14 +75,14 @@ def test_real_publisher_applies_repository_owned_check_repairability( monkeypatch.setattr( publisher, "live_pull_request", - lambda _pr: {"head_sha": "a" * 40}, + lambda _pr: {"head_sha": head}, ) monkeypatch.setattr( publisher, "_json", lambda *_args, **_kwargs: { "id": 33, - "head_sha": "a" * 40, + "head_sha": head, "name": "unit", "workflow_name": "tests", "status": "completed", @@ -95,7 +99,7 @@ def test_real_publisher_applies_repository_owned_check_repairability( }, ) - evidence = publisher.required_check_evidence(12, expected_head_sha="a" * 40) + evidence = publisher.required_check_evidence(12, expected_head_sha=head) assert evidence["checks"][0]["repairability"] == "code_failure" assert is_explicitly_repairable_code_failure(evidence) is True @@ -109,6 +113,9 @@ def test_real_publisher_does_not_mark_configured_job_platform_failure_repairable 'code-failure-steps = ["CI::quality::Run tests"]\n', encoding="utf-8", ) + subprocess.run(["git", "add", "pyproject.toml"], cwd=git_repo, check=True) + subprocess.run(["git", "commit", "-qm", "configure check policy"], cwd=git_repo, check=True) + head = GitRepository(git_repo).resolve("HEAD") publisher = GhGitHubPublisher("example/project", GitRepository(git_repo)) monkeypatch.setattr( publisher, @@ -127,14 +134,14 @@ def test_real_publisher_does_not_mark_configured_job_platform_failure_repairable monkeypatch.setattr( publisher, "live_pull_request", - lambda _pr: {"head_sha": "a" * 40}, + lambda _pr: {"head_sha": head}, ) monkeypatch.setattr( publisher, "_json", lambda *_args, **_kwargs: { "id": 33, - "head_sha": "a" * 40, + "head_sha": head, "name": "quality", "workflow_name": "CI", "status": "completed", @@ -157,7 +164,7 @@ def test_real_publisher_does_not_mark_configured_job_platform_failure_repairable }, ) - evidence = publisher.required_check_evidence(12, expected_head_sha="a" * 40) + evidence = publisher.required_check_evidence(12, expected_head_sha=head) assert "repairability" not in evidence["checks"][0] assert is_explicitly_repairable_code_failure(evidence) is False diff --git a/tests/test_history_language.py b/tests/test_history_language.py index 226ac49..ea77616 100644 --- a/tests/test_history_language.py +++ b/tests/test_history_language.py @@ -9,6 +9,8 @@ import pytest from rich.text import Text +from support.workspace import managed_state + from agent_run.cli import main from conftest import seed_run, write_fixture @@ -50,7 +52,7 @@ def save_history(repo: Path, events: list[dict[str, Any]]) -> tuple[str, Path]: started = seed_run(repo, fixture, idle_control=True) assert started.returncode == 0, started.stderr run_id = json.loads(started.stdout)["run_id"] - path = repo / ".agent-run" / "runs" / f"{run_id}.json" + path = managed_state(repo) / "runs" / f"{run_id}.json" state = json.loads(path.read_text()) state["timeline"] = events path.write_text(json.dumps(state)) diff --git a/tests/test_history_waits.py b/tests/test_history_waits.py index 8134fb4..adb9cd4 100644 --- a/tests/test_history_waits.py +++ b/tests/test_history_waits.py @@ -6,6 +6,8 @@ import pytest +from support.workspace import managed_state + from agent_run.delivery_history import history_records @@ -203,7 +205,7 @@ def isatty(self) -> bool: started = seed_run(git_repo, fixture, idle_control=True) assert started.returncode == 0, started.stderr run_id = json.loads(started.stdout)["run_id"] - state_path = git_repo / ".agent-run" / "runs" / f"{run_id}.json" + state_path = managed_state(git_repo) / "runs" / f"{run_id}.json" state = json.loads(state_path.read_text()) events = [check_event(0), check_event(4), check_event(13, "pass")] events[-1]["required_checks_evidence"] = {"checks": [{ @@ -213,7 +215,7 @@ def isatty(self) -> bool: state_path.write_text(json.dumps(state)) monkeypatch.chdir(git_repo) monkeypatch.setenv("COLUMNS", "120") - before = {path: path.read_bytes() for path in (git_repo / ".agent-run").rglob("*") if path.is_file()} + before = {path: path.read_bytes() for path in (managed_state(git_repo)).rglob("*") if path.is_file()} for arguments in (["--plain"], ["--plain", "--details"], [], ["--details"]): output = Terminal() with redirect_stdout(output): diff --git a/tests/test_interrupted_status.py b/tests/test_interrupted_status.py index ac52522..acc2749 100644 --- a/tests/test_interrupted_status.py +++ b/tests/test_interrupted_status.py @@ -5,6 +5,8 @@ import pytest +from support.workspace import managed_repo, managed_state + from agent_run import cli from agent_run.executor_host import HostObservation @@ -15,6 +17,12 @@ from test_run_lifecycle import _file_snapshot, _isolated_environment +def _workspace_snapshot( + repo: Path, *, extra_env: dict[str, str] | None = None, +) -> tuple[dict[str, bytes], dict[str, bytes]]: + return _file_snapshot(repo), _file_snapshot(managed_repo(repo, extra_env=extra_env).parent) + + @pytest.mark.parametrize("case", ["interrupted", "unknown", "ended", "capacity"]) def test_interrupted_agent_has_unknown_duration_and_recovery_guidance( git_repo: Path, @@ -32,7 +40,7 @@ def test_interrupted_agent_has_unknown_duration_and_recovery_guidance( ) assert first.returncode == 0, first.stderr run_id = stdout_json(first)['run_id'] - state_path = next((git_repo / '.agent-run' / 'runs').glob('*.json')) + state_path = next((managed_state(git_repo, extra_env=environment) / 'runs').glob('*.json')) state = json.loads(state_path.read_text()) invocation = dict(state['agent_invocation_history'][-1]) invocation.update( @@ -48,7 +56,7 @@ def test_interrupted_agent_has_unknown_duration_and_recovery_guidance( if case == 'ended': invocation['ended_at'] = '2026-09-07T01:47:25+00:00' state['agent_invocation_history'][-1] = dict(invocation) - control_path = next((git_repo / '.agent-run' / 'task-control').glob('*.json')) + control_path = next((managed_state(git_repo, extra_env=environment) / 'task-control').glob('*.json')) if case == 'unknown': control_path.unlink() if case == 'capacity': @@ -70,7 +78,7 @@ def test_interrupted_agent_has_unknown_duration_and_recovery_guidance( monkeypatch.chdir(git_repo) for key, value in environment.items(): monkeypatch.setenv(key, value) - before = _file_snapshot(git_repo) + before = _workspace_snapshot(git_repo, extra_env=environment) for command in ('status', 'history'): assert cli.main([command, run_id, '--json']) == 0 output = json.loads(capsys.readouterr().out) @@ -104,7 +112,7 @@ def test_interrupted_agent_has_unknown_duration_and_recovery_guidance( assert '2026-09-07' in human assert '你暂时无需操作' not in human assert '本轮剩余' not in human - assert _file_snapshot(git_repo) == before + assert _workspace_snapshot(git_repo, extra_env=environment) == before def test_normal_approval_gate_is_not_reported_as_interrupted( @@ -130,12 +138,12 @@ def test_budget_checkpoint_explains_new_window_authorization(git_repo: Path) -> from test_resume_intent import _checkpoint fixture, _, state = _checkpoint(git_repo) - before = _file_snapshot(git_repo) + before = _workspace_snapshot(git_repo) for command in ("status", "history"): result = run_cli(git_repo, fixture, command, state["run_id"]) assert result.returncode == 0, result.stderr assert "继续执行后,将按配置补充本次开发与验收额度" in result.stdout - assert _file_snapshot(git_repo) == before + assert _workspace_snapshot(git_repo) == before def test_completed_invocation_keeps_bounded_recovery_summary( @@ -150,13 +158,13 @@ def test_completed_invocation_keeps_bounded_recovery_summary( ) assert first.returncode == 0, first.stderr run_id = stdout_json(first)["run_id"] - state_path = next((git_repo / ".agent-run/runs").glob("*.json")) + state_path = next((managed_state(git_repo, extra_env=environment) / "runs").glob("*.json")) state = json.loads(state_path.read_text()) invocation = state["agent_invocation_history"][-1] assert invocation["status"] == "completed" invocation.update(ordinary_recovery_used=True, capacity_recovery_count=3) state_path.write_text(json.dumps(state)) - before = _file_snapshot(git_repo) + before = _workspace_snapshot(git_repo, extra_env=environment) for command in ("history", "status"): result = run_cli( git_repo, fixture, command, run_id, extra_env=environment, @@ -165,4 +173,4 @@ def test_completed_invocation_keeps_bounded_recovery_summary( assert "普通异常自动续接已触发 1 次" in result.stdout assert "模型容量不足等待已触发 3 次" in result.stdout assert "ordinary_recovery_used" not in result.stdout - assert _file_snapshot(git_repo) == before + assert _workspace_snapshot(git_repo, extra_env=environment) == before diff --git a/tests/test_managed_workspace.py b/tests/test_managed_workspace.py new file mode 100644 index 0000000..bc32ba7 --- /dev/null +++ b/tests/test_managed_workspace.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import fcntl +import shutil +import subprocess +from pathlib import Path + +import pytest + +from agent_run.managed_workspace import ( + ManagedWorkspace, + ManagedWorkspaceError, + read_git_identity, + workspace_state_root, +) +from agent_run.paths import app_data_root + + +def git(root: Path, *arguments: str) -> str: + return subprocess.run( + ["git", *arguments], cwd=root, check=True, capture_output=True, text=True + ).stdout.strip() + + +def test_workspace_clones_committed_private_files_and_reuses_its_own_git( + git_repo: Path, +) -> None: + (git_repo / "research").mkdir() + (git_repo / "research" / "private.md").write_text("private committed context\n") + (git_repo / "AGENTS.md").write_text("private instructions\n") + git(git_repo, "add", "research", "AGENTS.md") + git(git_repo, "commit", "-m", "private context") + (git_repo / "README.md").write_text("uncommitted local edit\n") + (git_repo / "local-only.md").write_text("not pushed\n") + before = { + str(path.relative_to(git_repo)): path.read_bytes() + for path in git_repo.rglob("*") if path.is_file() + } + + workspace = ManagedWorkspace.for_repository("Owner/Project") + managed = workspace.ensure(remote_url=str(git_repo)) + + assert ManagedWorkspace.for_repository("owner/project") == workspace + assert managed.root == workspace.repository_root + assert workspace_state_root(managed.root) == workspace.state_root + assert workspace.state_root.parent == managed.root.parent + assert (managed.root / "research" / "private.md").read_text() == "private committed context\n" + assert (managed.root / "AGENTS.md").read_text() == "private instructions\n" + assert (managed.root / "README.md").read_text() == "# fixture\n" + assert not (managed.root / "local-only.md").exists() + assert (managed.root / ".git").is_dir() + assert not (managed.root / ".git" / "objects" / "info" / "alternates").exists() + (managed.root / "README.md").write_text("managed unfinished work\n") + assert workspace.ensure().root == managed.root + assert (managed.root / "README.md").read_text() == "managed unfinished work\n" + assert before == { + str(path.relative_to(git_repo)): path.read_bytes() + for path in git_repo.rglob("*") if path.is_file() + } + source_inodes = {path.stat().st_ino for path in (git_repo / ".git").rglob("*") if path.is_file()} + assert not source_inodes.intersection( + path.stat().st_ino for path in (managed.root / ".git").rglob("*") if path.is_file() + ) + + +def test_missing_workspace_lookup_is_read_only(git_repo: Path) -> None: + workspace = ManagedWorkspace.for_repository("owner/project") + with pytest.raises(ManagedWorkspaceError): + workspace.open() + with pytest.raises(ManagedWorkspaceError): + workspace_state_root(git_repo) + assert not workspace.root.exists() + assert not (git_repo / ".agent-run").exists() + + +def test_failed_clone_removes_only_its_partial_checkout(tmp_path: Path) -> None: + workspace = ManagedWorkspace.for_repository("owner/project") + with pytest.raises(ManagedWorkspaceError, match="远端仓库不存在"): + workspace.ensure(remote_url=str(tmp_path / "missing-remote")) + assert not workspace.root.exists() + assert not list(workspace.root.parent.iterdir()) + + +def test_workspace_refuses_existing_unowned_directory(tmp_path: Path) -> None: + workspace = ManagedWorkspace.for_repository("owner/project") + workspace.root.mkdir(parents=True) + sentinel = workspace.root / "user-file" + sentinel.write_text("keep") + with pytest.raises(ManagedWorkspaceError, match="不属于 Runner"): + workspace.ensure(remote_url=str(tmp_path / "not-used")) + assert sentinel.read_text() == "keep" + + +def test_workspace_refuses_symlink_destination(tmp_path: Path) -> None: + workspace = ManagedWorkspace.for_repository("owner/project") + workspace.root.parent.mkdir(parents=True) + outside = tmp_path / "outside" + outside.mkdir() + workspace.root.symlink_to(outside, target_is_directory=True) + with pytest.raises(ManagedWorkspaceError, match="不是独立目录"): + workspace.ensure() + assert not list(outside.iterdir()) + + +def test_workspace_initialization_is_exclusive(tmp_path: Path) -> None: + workspace = ManagedWorkspace.for_repository("owner/project") + workspace.root.parent.mkdir(parents=True) + lock_path = app_data_root() / "repositories" / ".locks" / "owner" / "project.lock" + lock_path.parent.mkdir(parents=True) + with lock_path.open("w") as lock: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + with pytest.raises(ManagedWorkspaceError, match="正在创建"): + workspace.ensure(remote_url=str(tmp_path / "not-used")) + assert not workspace.root.exists() + + +@pytest.mark.parametrize("relative", ["data", ".git/runner-data"]) +def test_workspace_rejects_data_root_inside_a_user_repository( + git_repo: Path, monkeypatch: pytest.MonkeyPatch, relative: str, +) -> None: + data_home = git_repo / relative + monkeypatch.setenv("XDG_DATA_HOME", str(data_home)) + workspace = ManagedWorkspace.for_repository("owner/project") + with pytest.raises(ManagedWorkspaceError, match="必须位于用户仓库之外"): + workspace.ensure(remote_url=str(git_repo)) + assert not data_home.exists() + + +def test_workspace_accepts_a_symlink_to_the_data_disk( + git_repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + disk = tmp_path / "data-disk" + disk.mkdir() + alias = tmp_path / "data-alias" + alias.symlink_to(disk, target_is_directory=True) + monkeypatch.setenv("XDG_DATA_HOME", str(alias)) + workspace = ManagedWorkspace.for_repository("owner/project") + + managed = workspace.ensure(remote_url=str(git_repo)) + + assert managed.root == disk / "agent-run/repositories/owner/project/repository" + assert workspace.open().root == managed.root + assert workspace.ensure().root == managed.root + + +@pytest.mark.parametrize("relative", ["data", ".git/runner-data"]) +def test_existing_workspace_cannot_be_relocated_inside_a_user_repository( + git_repo: Path, monkeypatch: pytest.MonkeyPatch, relative: str, +) -> None: + workspace = ManagedWorkspace.for_repository("owner/project") + workspace.ensure(remote_url=str(git_repo)) + data_home = git_repo / relative + data_home.mkdir(parents=True) + shutil.move(str(app_data_root()), str(data_home / "agent-run")) + monkeypatch.setenv("XDG_DATA_HOME", str(data_home)) + moved = ManagedWorkspace.for_repository("owner/project") + before = { + path.relative_to(git_repo): path.read_bytes() + for path in git_repo.rglob("*") if path.is_file() + } + + for action in (moved.open, moved.ensure): + with pytest.raises(ManagedWorkspaceError, match="必须位于用户仓库之外"): + action() + + assert before == { + path.relative_to(git_repo): path.read_bytes() + for path in git_repo.rglob("*") if path.is_file() + } + + +def test_clone_timeout_removes_partial_workspace_and_releases_initialization_lock( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + import os + import sys + import agent_run.managed_workspace as module + + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + fake_gh = fake_bin / "gh" + fake_gh.write_text(f"#!{sys.executable}\nimport time\ntime.sleep(60)\n") + fake_gh.chmod(0o700) + monkeypatch.setenv("PATH", str(fake_bin) + os.pathsep + os.environ["PATH"]) + monkeypatch.setattr(module, "_CLONE_TIMEOUT_SECONDS", 0.05) + workspace = ManagedWorkspace.for_repository("owner/project") + with pytest.raises(ManagedWorkspaceError, match="超时"): + workspace.ensure() + assert not workspace.root.exists() + assert not list(workspace.root.parent.iterdir()) + with pytest.raises(ManagedWorkspaceError, match="远端仓库不存在"): + workspace.ensure(remote_url=str(tmp_path / "missing")) + + +@pytest.mark.parametrize("repository", ["../project", "owner/..", "owner/", "/repo", "owner/repo/more"]) +def test_workspace_rejects_invalid_repository_names(repository: str) -> None: + with pytest.raises(ManagedWorkspaceError, match="owner/name"): + ManagedWorkspace.for_repository(repository) + + +def test_workspace_accepts_github_dot_repository() -> None: + assert ManagedWorkspace.for_repository("Owner/.github").repository == "owner/.github" + + +def test_workspace_copies_only_local_commit_identity_and_can_commit( + git_repo: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + empty_home = git_repo.parent / "identity-home" + empty_home.mkdir() + monkeypatch.setenv("HOME", str(empty_home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(empty_home / ".config")) + for variable in ( + "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", + ): + monkeypatch.delenv(variable, raising=False) + for key in ("user.name", "user.email"): + global_value = subprocess.run( + ["git", "config", "--global", "--get", key], cwd=git_repo, capture_output=True, + ) + assert global_value.returncode == 1 + git(git_repo, "config", "--local", "user.name", "Local Maintainer") + git(git_repo, "config", "--local", "user.email", "local@example.invalid") + hooks = git_repo / ".git" / "private-hooks" + hooks.mkdir() + hook = hooks / "pre-commit" + hook.write_text("#!/bin/sh\nexit 97\n") + hook.chmod(0o700) + git(git_repo, "config", "--local", "core.hooksPath", str(hooks)) + git(git_repo, "config", "--local", "credential.helper", "!false") + git(git_repo, "config", "--local", "agent-run.private-note", "local-only") + before = { + path.relative_to(git_repo): (path.read_bytes(), path.stat().st_mtime_ns) + for path in git_repo.rglob("*") if path.is_file() + } + + identity = read_git_identity(git_repo) + assert identity == {"user.name": "Local Maintainer", "user.email": "local@example.invalid"} + workspace = ManagedWorkspace.for_repository("owner/project") + managed = workspace.ensure(remote_url=str(git_repo), identity=identity) + for key in ("core.hooksPath", "credential.helper", "agent-run.private-note"): + local_value = subprocess.run( + ["git", "config", "--local", "--get", key], cwd=managed.root, capture_output=True, + ) + assert local_value.returncode == 1 + assert not (managed.root / ".git" / "private-hooks").exists() + assert not (managed.root / ".git" / "hooks" / "pre-commit").exists() + (managed.root / "candidate.txt").write_text("independent committed change\n") + git(managed.root, "add", "candidate.txt") + git(managed.root, "commit", "-m", "candidate using local identity") + assert git(managed.root, "log", "-1", "--format=%an <%ae>") == "Local Maintainer " + workspace.ensure(identity={"user.name": "Different Maintainer"}) + assert git(managed.root, "config", "--local", "user.name") == "Local Maintainer" + assert before == { + path.relative_to(git_repo): (path.read_bytes(), path.stat().st_mtime_ns) + for path in git_repo.rglob("*") if path.is_file() + } diff --git a/tests/test_missing_ownership.py b/tests/test_missing_ownership.py index cdcdc72..27fba0c 100644 --- a/tests/test_missing_ownership.py +++ b/tests/test_missing_ownership.py @@ -10,6 +10,8 @@ import pytest +from support.workspace import managed_repo, managed_state + from agent_run import cli as cli_module from agent_run.delivery_policy import policy_snapshot_for_state from agent_run.executor_host import ExecutorSpec, HostObservation @@ -71,7 +73,7 @@ def advance(seconds: float) -> None: run_id = str(state["run_id"]) control, task, worker = _bind_running_executor(git_repo, run_id) try: - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) state = states.load_run(run_id) assert state is not None receipt = dict(state["action_application_receipt"]) @@ -250,7 +252,7 @@ def test_missing_receipt_does_not_make_stop_noop_or_refresh_approval( fixture = write_fixture(git_repo / "github.json", issues={}) agents = run_agents(git_repo / "agents.json") state_root = ( - git_repo / ".agent-run" + managed_state(git_repo) if state_case == "canonical" else git_repo.parent / "custom-state" ) @@ -260,8 +262,8 @@ def test_missing_receipt_does_not_make_stop_noop_or_refresh_approval( states = StateStore(state_root) state = states.find_unfinished_runs("example/project", 1)[0] run_id = str(state["run_id"]) - control = TaskControlStore(git_repo / ".agent-run") - task = TaskKey(git_repo, "example/project", 1) + control = TaskControlStore(managed_state(git_repo)) + task = TaskKey(managed_repo(git_repo), "example/project", 1) control.claim_action(task, kind="run", payload={"parent": 1}) worker = subprocess.Popen( [sys.executable, "-c", "import signal; signal.pause()"], @@ -307,7 +309,11 @@ def resolve_base(publisher: Publisher, branch: str, sha: str | None) -> str: code = cli_module.main(arguments) output = json.loads(capsys.readouterr().out) assert code == 2, output - assert output["diagnostics"][0]["code"] == "task_control", output + if command == "stop" and state_case == "custom": + assert output["diagnostics"][0]["code"] == "command_failed", output + assert "统一状态目录" in output["diagnostics"][0]["message"] + else: + assert output["diagnostics"][0]["code"] == "task_control", output assert host.start_count == 0 assert prepared == [] assert published == [] diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..f02dc14 --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import os +from collections.abc import Callable +from pathlib import Path + +import pytest + +from agent_run.executor_environment import default_executor_runtime_directory +from agent_run.github_auth_profile import GitHubAppProfileStore +from agent_run.paths import app_config_root, app_data_root, app_runtime_root +from agent_run.run_locator import RunLocatorIndex +from agent_run.runner_installer import InstallPaths +from agent_run.runner_lease import default_runner_lock_path +from agent_run.user_defaults import UserDefaultsStore + + +def test_all_runner_paths_share_the_selected_roots() -> None: + data = app_data_root() + config = app_config_root() + assert InstallPaths.from_environment().data_root == data + assert default_runner_lock_path() == data / "install.lock" + assert RunLocatorIndex.default().path == data / "run-locator.json" + assert UserDefaultsStore.default_path() == config / "user-defaults.json" + assert GitHubAppProfileStore.default_path() == config / "github-app.json" + assert default_executor_runtime_directory() == app_runtime_root() / "executor" + assert not data.exists() + assert not config.exists() + + +@pytest.mark.parametrize("value", [None, ""]) +def test_missing_or_empty_xdg_uses_defaults( + monkeypatch: pytest.MonkeyPatch, value: str | None, +) -> None: + for name in ("XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_RUNTIME_DIR"): + if value is None: + monkeypatch.delenv(name) + else: + monkeypatch.setenv(name, value) + assert app_data_root() == Path.home() / ".local" / "share" / "agent-run" + assert app_config_root() == Path.home() / ".config" / "agent-run" + assert app_runtime_root() == Path("/tmp") / f"agent-run-{os.geteuid()}" + + +@pytest.mark.parametrize("variable,function", [ + ("XDG_DATA_HOME", app_data_root), + ("XDG_CONFIG_HOME", app_config_root), + ("XDG_RUNTIME_DIR", app_runtime_root), +]) +def test_relative_xdg_directories_fail_instead_of_writing_under_cwd( + monkeypatch: pytest.MonkeyPatch, variable: str, function: Callable[[], Path], +) -> None: + monkeypatch.setenv(variable, "relative-directory") + with pytest.raises(ValueError, match="必须是绝对路径"): + function() diff --git a/tests/test_publication_operation_retry.py b/tests/test_publication_operation_retry.py index 985a8d8..1dda54c 100644 --- a/tests/test_publication_operation_retry.py +++ b/tests/test_publication_operation_retry.py @@ -7,6 +7,8 @@ import pytest +from support.workspace import managed_state + from agent_run.cli_presentation import _publication_operation_retries from agent_run.change_delivery import ChangeDeliveryEngine from agent_run.publication_operation_retry import ( @@ -242,7 +244,7 @@ def test_public_command_rejects_corrupt_publication_retry_before_mutation( started = seed_run(git_repo, fixture, "1") assert started.returncode == 0, started.stderr run_id = stdout_json(started)["run_id"] - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state = json.loads(state_path.read_text(encoding="utf-8")) state["active_ticket_job"]["publication_operation_retry"] = { "attempts": 1, diff --git a/tests/test_publish_revision_races.py b/tests/test_publish_revision_races.py index a61d50f..d6b9cdc 100644 --- a/tests/test_publish_revision_races.py +++ b/tests/test_publish_revision_races.py @@ -7,6 +7,8 @@ import pytest +from support.workspace import managed_repo, managed_state + from agent_run.state import SimulatedProcessCrash, StateStore from conftest import seed_run, write_fixture from support.inprocess_cli import invoke_cli_inprocess @@ -295,7 +297,7 @@ def test_resume_freezes_completed_ticket_assets_after_graph_drift( "ticket_completion_record": ticket_completion_record, "local_run_branch_sha": subprocess.run( ["git", "rev-parse", run_branch], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, @@ -330,7 +332,7 @@ def test_resume_freezes_completed_ticket_assets_after_graph_drift( } == frozen["ticket_completion_record"] assert subprocess.run( ["git", "rev-parse", run_branch], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, @@ -443,9 +445,9 @@ def test_abandon_closes_active_ticket_pr_after_graph_drift( assert {"action": "close_change_pr", "pr_number": 1} in after["delivery"][ "mutations" ] - assert str(git_repo / ".agent-run" / "worktrees" / run_id) not in subprocess.run( + assert str(managed_state(git_repo) / "worktrees" / run_id) not in subprocess.run( ["git", "worktree", "list", "--porcelain"], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, diff --git a/tests/test_receipt_successors.py b/tests/test_receipt_successors.py index 4787c88..1c78864 100644 --- a/tests/test_receipt_successors.py +++ b/tests/test_receipt_successors.py @@ -10,7 +10,10 @@ import pytest +from support.workspace import managed_repo, managed_state + import agent_run.cli as cli_module +import agent_run.workspace_cli as workspace_cli from agent_run.executor_host import ( ExecutorSpec, ExecutorStartUnknownError, @@ -84,8 +87,7 @@ def ensure( host = ReceiptHost() monkeypatch.chdir(git_repo) - monkeypatch.setenv("XDG_DATA_HOME", str(git_repo / "runner-data")) - monkeypatch.setenv("XDG_RUNTIME_DIR", str(git_repo / "runtime")) + monkeypatch.setenv("XDG_RUNTIME_DIR", str(git_repo.parent / "runtime")) monkeypatch.setattr( cli_module, "runner_usage_lease", lambda _path: nullcontext(UsageLease()) ) @@ -96,6 +98,10 @@ def ensure( "GhGitHubReader", lambda _repo, *, working_directory: FixtureGitHubReader(fixture), ) + monkeypatch.setattr( + workspace_cli, "GhGitHubReader", + lambda **_options: FixtureGitHubReader(fixture), + ) monkeypatch.setattr( cli_module, "GhGitHubPublisher", @@ -120,10 +126,10 @@ def test_dedicated_actions_reconcile_exact_receipt_at_cli_boundary( fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 state = load_only_run_state(git_repo) - state_root = git_repo / ".agent-run" + state_root = managed_state(git_repo) run_id = str(state["run_id"]) control = TaskControlStore(state_root) - task = TaskKey(git_repo, "example/project", 1) + task = TaskKey(managed_repo(git_repo), "example/project", 1) original = control.claim_action(task, kind="run", payload={"parent": 1}) prepare_action_application_receipt(state, original.action) state.update({"status": "execution_failed", "terminal_kind": "execution_failed"}) @@ -280,8 +286,8 @@ def test_recovered_receipt_successor_executes_the_real_cli_action( agents = _revision_agents(git_repo / "revision-agents.json") state = load_only_run_state(git_repo) receipt = state["action_application_receipt"] - control = TaskControlStore(git_repo / ".agent-run") - task = TaskKey(git_repo, "example/project", 1) + control = TaskControlStore(managed_state(git_repo)) + task = TaskKey(managed_repo(git_repo), "example/project", 1) control.path_for(task).write_text("not json", encoding="utf-8") host = _install_host(git_repo, fixture, monkeypatch, "exited", launch_unknown=False) arguments = [ @@ -352,12 +358,12 @@ def test_original_retry_unknown_then_different_successor_reconciles_again( payload = {"parent": 1, "run_id": run_id} if original_command == "revise": payload["message"] = "original revision" - control = TaskControlStore(git_repo / ".agent-run") - task = TaskKey(git_repo, "example/project", 1) + control = TaskControlStore(managed_state(git_repo)) + task = TaskKey(managed_repo(git_repo), "example/project", 1) original = control.claim_action(task, kind=original_command, payload=payload) prepare_action_application_receipt(state, original.action) state.update({"status": "run_approval_pending", "terminal_kind": None}) - StateStore(git_repo / ".agent-run").save_run(run_id, state) + StateStore(managed_state(git_repo)).save_run(run_id, state) control.path_for(task).unlink() first_host = _install_host(git_repo, fixture, monkeypatch, "unknown") arguments = [original_command, run_id, "--repo", "example/project", "--json"] diff --git a/tests/test_requeue.py b/tests/test_requeue.py index 4f1f3e0..95c08c9 100644 --- a/tests/test_requeue.py +++ b/tests/test_requeue.py @@ -1,5 +1,7 @@ from __future__ import annotations +from support.workspace import managed_repo, managed_state, prepare_workspace + from copy import deepcopy import json from pathlib import Path @@ -405,13 +407,14 @@ def test_requeue_waits_for_unparseable_transition_facts_before_closing_old_pr( git_repo: Path, ) -> None: fixture = write_fixture(git_repo / "github.json", issues={"7": issue(7)}) - git = GitRepository.discover(git_repo) - states = StateStore(git_repo / ".agent-run") + prepare_workspace(git_repo) + git = GitRepository(managed_repo(git_repo)) + states = StateStore(managed_state(git_repo)) controller = Controller(FixtureGitHubReader(fixture), git, states) state, _ = controller.start(1) seed_idle_control( - TaskControlStore(git_repo / ".agent-run"), - TaskKey(git_repo, "example/project", 1), + TaskControlStore(managed_state(git_repo)), + TaskKey(managed_repo(git_repo), "example/project", 1), str(state["run_id"]), ) active = state["active_ticket_job"] @@ -525,8 +528,8 @@ def test_requeue_rechecks_an_externally_closed_pr_before_retiring_it( fixture = write_fixture(git_repo / "github.json", issues={"7": issue(7)}) started = seed_run(git_repo, fixture, "1", idle_control=True) run_id = stdout_json(started)["run_id"] - states = StateStore(git_repo / ".agent-run") - git = GitRepository(git_repo) + states = StateStore(managed_state(git_repo)) + git = GitRepository(managed_repo(git_repo)) state = states.load_run(run_id) assert state is not None job = state["active_ticket_job"] @@ -605,8 +608,8 @@ def test_requeue_blocks_an_external_close_during_retirement( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={"7": issue(7)}) run_id = stdout_json(seed_run(git_repo, fixture, "1", idle_control=True))["run_id"] - states = StateStore(git_repo / ".agent-run") - git = GitRepository(git_repo) + states = StateStore(managed_state(git_repo)) + git = GitRepository(managed_repo(git_repo)) state = states.load_run(run_id) assert state is not None job = state["active_ticket_job"] @@ -673,8 +676,8 @@ def test_requeue_blocks_an_external_reopen_after_its_close_receipt( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={"7": issue(7)}) run_id = stdout_json(seed_run(git_repo, fixture, "1", idle_control=True))["run_id"] - states = StateStore(git_repo / ".agent-run") - git = GitRepository(git_repo) + states = StateStore(managed_state(git_repo)) + git = GitRepository(managed_repo(git_repo)) state = states.load_run(run_id) assert state is not None job = state["active_ticket_job"] @@ -707,8 +710,8 @@ def test_requeue_supervises_an_unreadable_persisted_pr(git_repo: Path) -> None: ) started = seed_run(git_repo, fixture, "1", idle_control=True) run_id = stdout_json(started)["run_id"] - states = StateStore(git_repo / ".agent-run") - git = GitRepository(git_repo) + states = StateStore(managed_state(git_repo)) + git = GitRepository(managed_repo(git_repo)) state = states.load_run(run_id) assert state is not None job = state["active_ticket_job"] @@ -762,7 +765,7 @@ def test_requeue_supervises_an_unreadable_persisted_pr(git_repo: Path) -> None: def test_requeue_supervises_a_repository_binding_read_failure(git_repo: Path) -> None: fixture = write_fixture(git_repo / "github.json", issues={"7": issue(7)}) run_id = stdout_json(seed_run(git_repo, fixture, "1", idle_control=True))["run_id"] - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) state = states.load_run(run_id) assert state is not None job = state["active_ticket_job"] @@ -775,7 +778,7 @@ def test_requeue_supervises_a_repository_binding_read_failure(git_repo: Path) -> "review_budget": _canonical_budget(), "review_budget_history": [], "effective_revision": "stale", - "base_sha": GitRepository(git_repo).resolve(str(state["run_branch"])), + "base_sha": GitRepository(managed_repo(git_repo)).resolve(str(state["run_branch"])), } ) state["ticket_jobs"] = {"7": job} @@ -818,7 +821,7 @@ def test_deliver_cannot_restart_a_stale_generation_without_requeue( fixture = write_fixture(git_repo / "github.json", issues={"7": issue(7)}) started = seed_run(git_repo, fixture, "1", idle_control=True) run_id = stdout_json(started)["run_id"] - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) state = states.load_run(run_id) assert state is not None job = state["active_ticket_job"] @@ -831,7 +834,7 @@ def test_deliver_cannot_restart_a_stale_generation_without_requeue( "review_budget": _canonical_budget(), "review_budget_history": [], "effective_revision": "old-revision", - "base_sha": GitRepository(git_repo).resolve(str(state["run_branch"])), + "base_sha": GitRepository(managed_repo(git_repo)).resolve(str(state["run_branch"])), } ) state["ticket_jobs"] = {"7": job} @@ -914,8 +917,8 @@ def test_public_views_keep_currentness_contradiction_bound_to_ticket( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={"7": issue(7)}) run_id = stdout_json(seed_run(git_repo, fixture, "1", idle_control=True))["run_id"] - states = StateStore(git_repo / ".agent-run") - git = GitRepository(git_repo) + states = StateStore(managed_state(git_repo)) + git = GitRepository(managed_repo(git_repo)) state = states.load_run(run_id) assert state is not None job = state["active_ticket_job"] diff --git a/tests/test_required_checks_policy.py b/tests/test_required_checks_policy.py new file mode 100644 index 0000000..c8ce79f --- /dev/null +++ b/tests/test_required_checks_policy.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +from agent_run.git import GitRepository +from agent_run.github_fixture import FixtureGitHubPublisher +from agent_run.required_checks import annotate_configured_code_failures + + +def _git(root: Path, *arguments: str) -> str: + return subprocess.run( + ["git", *arguments], cwd=root, check=True, text=True, capture_output=True + ).stdout.strip() + + +def _commit_policy(root: Path, value: str | None) -> str: + path = root / "pyproject.toml" + if value is None: + path.unlink(missing_ok=True) + else: + path.write_text(value) + _git(root, "add", "--all", "pyproject.toml") + _git(root, "commit", "-qm", "change check policy") + return _git(root, "rev-parse", "HEAD") + + +def _policy(step: str) -> str: + return '[tool.agent-run.required-checks]\ncode-failure-steps = ["CI::quality::' + step + '"]\n' + + +def _check(head: str, step: str = "New tests") -> dict[str, Any]: + return { + "name": "quality", "workflow": "CI", "repairability": "untrusted annotation", + "job": { + "name": "quality", "workflow_name": "CI", "head_sha": head, + "status": "completed", "conclusion": "failure", + "steps": [{"name": step, "status": "completed", "conclusion": "failure"}], + }, + } + + +def test_policy_is_bound_to_fetched_head_without_updating_root_checkout( + git_repo: Path, tmp_path: Path, +) -> None: + old_head = _commit_policy(git_repo, _policy("Old tests")) + managed = tmp_path / "managed" + _git(tmp_path, "clone", "--no-local", "--quiet", str(git_repo), str(managed)) + new_head = _commit_policy(git_repo, _policy("New tests")) + assert GitRepository(managed).resolve_base("main", new_head) == new_head + assert _git(managed, "rev-parse", "HEAD") == old_head + assert (managed / "pyproject.toml").read_text() == _policy("Old tests") + + result = annotate_configured_code_failures([_check(new_head)], managed, expected_head_sha=new_head) + assert result[0]["repairability"] == "code_failure" + old_result = annotate_configured_code_failures( + [_check(old_head, "Old tests")], managed, expected_head_sha=old_head, + ) + assert old_result[0]["repairability"] == "code_failure" + assert "repairability" not in annotate_configured_code_failures( + [_check(new_head, "Old tests")], managed, expected_head_sha=new_head, + )[0] + assert _git(managed, "status", "--porcelain") == "" + assert _git(managed, "rev-parse", "HEAD") == old_head + + +@pytest.mark.parametrize("policy", [None, "[project]\nname = 'no-policy'\n"]) +def test_missing_policy_never_uses_worktree_configuration(git_repo: Path, policy: str | None) -> None: + head = _commit_policy(git_repo, policy) + (git_repo / "pyproject.toml").write_text(_policy("New tests")) + result = annotate_configured_code_failures([_check(head)], git_repo, expected_head_sha=head) + assert "repairability" not in result[0] + + +@pytest.mark.parametrize("head", ["f" * 40, None]) +def test_unavailable_head_never_uses_worktree_configuration(git_repo: Path, head: str | None) -> None: + (git_repo / "pyproject.toml").write_text(_policy("New tests")) + result = annotate_configured_code_failures([_check(head or "unknown")], git_repo, expected_head_sha=head) + assert "repairability" not in result[0] + + +def test_invalid_versioned_policy_preserves_configuration_error(git_repo: Path) -> None: + head = _commit_policy(git_repo, '[tool.agent-run.required-checks]\ncode-failure-steps = "invalid"\n') + (git_repo / "pyproject.toml").write_text(_policy("New tests")) + with pytest.raises(ValueError, match="workflow::name::step"): + annotate_configured_code_failures([_check(head)], git_repo, expected_head_sha=head) + + +def test_fixture_publisher_reads_the_repository_head_policy_not_fixture_directory( + git_repo: Path, tmp_path: Path, +) -> None: + head = _commit_policy(git_repo, _policy("New tests")) + fixture = tmp_path / "github.json" + fixture.write_text(json.dumps({ + "repository": "owner/project", + "delivery": { + "pull_requests": [{ + "number": 1, "branch": "main", "base_branch": "main", + "head_sha": head, "state": "OPEN", + }], + "required_check_evidence": {"checks": [_check(head)]}, + }, + })) + (tmp_path / "pyproject.toml").write_text(_policy("Old tests")) + (git_repo / "pyproject.toml").write_text(_policy("Old tests")) + publisher = FixtureGitHubPublisher(fixture, GitRepository(git_repo)) + evidence = publisher.required_check_evidence(1, expected_head_sha=head) + assert evidence["checks"][0]["repairability"] == "code_failure" diff --git a/tests/test_resume_intent.py b/tests/test_resume_intent.py index 25b6bbf..bc1fcbc 100644 --- a/tests/test_resume_intent.py +++ b/tests/test_resume_intent.py @@ -6,6 +6,8 @@ import pytest +from support.workspace import managed_state + from agent_run import cli from agent_run.controller import Controller from agent_run.executor_host import FakeExecutorHost @@ -14,15 +16,6 @@ from test_cli_delivery import parent_round_agents -@pytest.fixture(autouse=True) -def isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - for key, directory in ( - ("HOME", "home"), ("XDG_CONFIG_HOME", "config"), - ("XDG_DATA_HOME", "data"), ("XDG_STATE_HOME", "state"), - ): - monkeypatch.setenv(key, str(tmp_path / directory)) - - def _checkpoint(repo: Path) -> tuple[Path, Path, dict[str, Any]]: fixture = write_fixture(repo / "github.json", issues={}) agents = repo / "agents.json" @@ -44,7 +37,7 @@ def test_budget_resume_receipt_records_explicit_window_authority(git_repo: Path) output = stdout_json(result) assert output["action"]["resume_authorization"] == "new_budget_window" assert output["action_audit"]["resume_intent"]["pause_reason"] == "budget_checkpoint" - control_path = next((git_repo / ".agent-run/task-control").glob("*.json")) + control_path = next((managed_state(git_repo) / "task-control").glob("*.json")) action = json.loads(control_path.read_text())["action"] assert action["payload"]["resume_intent"] == output["action_audit"]["resume_intent"] assert load_only_run_state(git_repo)["parent_job"]["review_budget"]["window"] == 2 @@ -56,7 +49,7 @@ def test_resume_rejects_changed_pause_target_before_applying_authority( drift_at: str, ) -> None: fixture, agents, initial = _checkpoint(git_repo) - state_path = next((git_repo / ".agent-run/runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) original_refresh = Controller._refresh class DriftingHost(FakeExecutorHost): @@ -108,9 +101,9 @@ def test_repeated_pending_resume_keeps_original_intent_and_generation( ] assert cli.main(arguments) == 2 capsys.readouterr() - control_path = next((git_repo / ".agent-run/task-control").glob("*.json")) + control_path = next((managed_state(git_repo) / "task-control").glob("*.json")) first = json.loads(control_path.read_text()) - state_path = next((git_repo / ".agent-run/runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state = json.loads(state_path.read_text()) state["parent_job"]["blocked_reason"] = "modification_budget_exhausted" state["parent_job"]["review_budget"]["checkpoint_reason"] = "modification_budget_exhausted" diff --git a/tests/test_run_acceptance.py b/tests/test_run_acceptance.py index f491dc4..b09553d 100644 --- a/tests/test_run_acceptance.py +++ b/tests/test_run_acceptance.py @@ -29,6 +29,7 @@ from conftest import seed_idle_control from support.inprocess_cli import invoke_cli_inprocess +from support.workspace import prepare_workspace from test_cli import run_internal_stage, run_cli, stdout_json from test_cli_delivery import final_run_publication @@ -385,14 +386,16 @@ def test_run_acceptance_rejects_completed_ticket_without_integration_record( def test_run_acceptance_cli_reports_incompatible_missing_integration_record( git_repo: Path, ) -> None: - state, states, _git = _completed_run(git_repo) + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, _git = _completed_run(repo, state_root=workspace.state_root) state["ticket_jobs"]["2"].pop("deterministic_integration_record") states.save_run(str(state["run_id"]), state) state_before = deepcopy(states.load_run(str(state["run_id"]))) result = run_cli( - git_repo, - git_repo / "github.json", + repo, + repo / "github.json", "resume", str(state["run_id"]), ) @@ -1349,8 +1352,10 @@ def review(self, request: dict[str, Any]) -> ReviewResult: def test_run_repair_development_human_blocker_stops_before_candidate_or_pr( git_repo: Path, ) -> None: - state, states, git = _completed_run(git_repo) - fixture = git_repo / "github.json" + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, git = _completed_run(repo, state_root=workspace.state_root) + fixture = repo / "github.json" class BlockedRunRepairDevelopment(ScriptedRunAgents): def develop( @@ -1386,12 +1391,12 @@ def develop( delivery = json.loads(fixture.read_text(encoding="utf-8"))["delivery"] assert delivery["pull_requests"] == [] status = stdout_json( - invoke_cli_inprocess(git_repo, fixture, "status", str(state["run_id"]), "--json") + invoke_cli_inprocess(repo, fixture, "status", str(state["run_id"]), "--json") ) assert status["status"] == "ready_for_human" assert status["diagnostics"][0]["message"].startswith("GitHub denied access") history = stdout_json( - invoke_cli_inprocess(git_repo, fixture, "history", str(state["run_id"]), "--json") + invoke_cli_inprocess(repo, fixture, "history", str(state["run_id"]), "--json") ) assert any( event.get("thread_id") == "run-repair-development-blocked" @@ -1428,8 +1433,10 @@ def develop( def test_run_repair_reviewer_human_blocker_history_uses_reviewer_thread( git_repo: Path, ) -> None: - state, states, git = _completed_run(git_repo) - fixture = git_repo / "github.json" + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, git = _completed_run(repo, state_root=workspace.state_root) + fixture = repo / "github.json" class BlockedRunRepairReviewer(ScriptedRunAgents): def __init__(self) -> None: @@ -1456,7 +1463,7 @@ def __init__(self) -> None: assert blocked["run_acceptance"]["repair_cycle"]["status"] == "human_blocked" assert not old_checkout.exists() history = stdout_json( - invoke_cli_inprocess(git_repo, fixture, "history", str(state["run_id"]), "--json") + invoke_cli_inprocess(repo, fixture, "history", str(state["run_id"]), "--json") ) assert any( event.get("worker") == "独立验收工作代理" @@ -1487,8 +1494,10 @@ def __init__(self) -> None: def test_run_repair_publication_human_blocker_stops_before_pr_mutation( git_repo: Path, ) -> None: - state, states, git = _completed_run(git_repo) - fixture = git_repo / "github.json" + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, git = _completed_run(repo, state_root=workspace.state_root) + fixture = repo / "github.json" class BlockedRunRepairPublication(ScriptedRunAgents): def publication( @@ -1527,7 +1536,7 @@ def publication( "pull_requests" ] == [] history = stdout_json( - invoke_cli_inprocess(git_repo, fixture, "history", str(state["run_id"]), "--json") + invoke_cli_inprocess(repo, fixture, "history", str(state["run_id"]), "--json") ) assert any( event.get("worker") == "发布工作代理" @@ -1894,8 +1903,10 @@ def review(self, request: dict[str, Any]) -> ReviewResult: def test_run_acceptance_fixture_repairs_malformed_output_in_same_thread( git_repo: Path, ) -> None: - state, states, _git = _completed_run(git_repo) - agents = git_repo / "repair-run-review.json" + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, _git = _completed_run(repo, state_root=workspace.state_root) + agents = repo / "repair-run-review.json" agents.write_text( json.dumps( { @@ -1917,8 +1928,8 @@ def test_run_acceptance_fixture_repairs_malformed_output_in_same_thread( ) accepted = run_internal_stage( - git_repo, - git_repo / "github.json", + repo, + repo / "github.json", "accept-run", str(state["run_id"]), "--agent-fixture", @@ -1939,8 +1950,10 @@ def test_run_acceptance_fixture_repairs_malformed_output_in_same_thread( def test_run_acceptance_fixture_missing_thread_marks_invocation_failed( git_repo: Path, ) -> None: - state, states, _git = _completed_run(git_repo) - agents = git_repo / "missing-run-review-thread.json" + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, _git = _completed_run(repo, state_root=workspace.state_root) + agents = repo / "missing-run-review-thread.json" agents.write_text( json.dumps( {"run_reviews": [{"no_thread": True, "artifact": _passing_artifact()}]} @@ -1949,8 +1962,8 @@ def test_run_acceptance_fixture_missing_thread_marks_invocation_failed( ) failed = run_internal_stage( - git_repo, - git_repo / "github.json", + repo, + repo / "github.json", "accept-run", str(state["run_id"]), "--agent-fixture", @@ -1965,8 +1978,10 @@ def test_run_acceptance_fixture_missing_thread_marks_invocation_failed( def test_run_acceptance_fixture_records_a_fresh_thread_without_expectation( git_repo: Path, ) -> None: - state, states, _git = _completed_run(git_repo) - agents = git_repo / "fresh-run-review.json" + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, _git = _completed_run(repo, state_root=workspace.state_root) + agents = repo / "fresh-run-review.json" agents.write_text( json.dumps( {"run_reviews": [{"thread_id": "fresh-run-reviewer", "artifact": _passing_artifact()}]} @@ -1975,8 +1990,8 @@ def test_run_acceptance_fixture_records_a_fresh_thread_without_expectation( ) accepted = run_internal_stage( - git_repo, - git_repo / "github.json", + repo, + repo / "github.json", "accept-run", str(state["run_id"]), "--agent-fixture", @@ -1994,14 +2009,16 @@ def test_run_acceptance_fixture_records_a_fresh_thread_without_expectation( def test_run_acceptance_fixture_resume_gets_a_fresh_repair_budget( git_repo: Path, ) -> None: - state, states, _git = _completed_run(git_repo) + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, _git = _completed_run(repo, state_root=workspace.state_root) seed_idle_control( TaskControlStore(states.root), - TaskKey(git_repo, str(state["repository"]), 1), + TaskKey(repo, str(state["repository"]), 1), str(state["run_id"]), state_dir=states.root, ) - failed_agents = git_repo / "failed-run-review.json" + failed_agents = repo / "failed-run-review.json" failed_agents.write_text( json.dumps( { @@ -2027,8 +2044,8 @@ def test_run_acceptance_fixture_resume_gets_a_fresh_repair_budget( encoding="utf-8", ) failed = run_internal_stage( - git_repo, - git_repo / "github.json", + repo, + repo / "github.json", "accept-run", str(state["run_id"]), "--agent-fixture", @@ -2036,7 +2053,7 @@ def test_run_acceptance_fixture_resume_gets_a_fresh_repair_budget( ) assert failed.returncode == 2 - resumed_agents = git_repo / "resumed-run-review.json" + resumed_agents = repo / "resumed-run-review.json" resumed_agents.write_text( json.dumps( { @@ -2053,8 +2070,8 @@ def test_run_acceptance_fixture_resume_gets_a_fresh_repair_budget( encoding="utf-8", ) resumed = run_cli( - git_repo, - git_repo / "github.json", + repo, + repo / "github.json", "resume", str(state["run_id"]), "--agent-fixture", diff --git a/tests/test_run_acceptance_currentness.py b/tests/test_run_acceptance_currentness.py index d711495..717110b 100644 --- a/tests/test_run_acceptance_currentness.py +++ b/tests/test_run_acceptance_currentness.py @@ -17,6 +17,7 @@ from agent_run.run_currentness import invalidate_stale_run_repair from agent_run.run_thread_identity import prior_thread_identities +from support.workspace import prepare_workspace from conftest import seed_idle_control, seed_run, write_fixture from agent_run.task_control import TaskControlStore, TaskKey from test_cli import failed_invocation, run_internal_stage, run_cli, stdout_json @@ -188,14 +189,16 @@ def test_run_repair_drift_discards_repair_before_fresh_acceptance( def test_public_resume_retires_stale_run_repair_before_preserving_dirty_checkout( git_repo: Path, ) -> None: - state, states, git = _completed_run(git_repo) + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, git = _completed_run(repo, state_root=workspace.state_root) seed_idle_control( - TaskControlStore(git_repo / ".agent-run"), - TaskKey(git_repo, "example/project", 1), + TaskControlStore(workspace.state_root), + TaskKey(repo, "example/project", 1), str(state["run_id"]), state_dir=states.root, ) - fixture = git_repo / "github.json" + fixture = repo / "github.json" publisher = FixtureGitHubPublisher(fixture, git) run_id = str(state["run_id"]) ticket_branch = f"agent-run/{run_id}/ticket-2" @@ -286,7 +289,7 @@ def test_public_resume_retires_stale_run_repair_before_preserving_dirty_checkout json.loads(fixture.read_text(encoding="utf-8"))["delivery"] ) - held = run_cli(git_repo, fixture, "run", "1") + held = run_cli(repo, fixture, "run", "1") assert held.returncode == 2 assert stdout_json(held)["status"] == "execution_failed" assert states.load_run(run_id) == state @@ -295,7 +298,7 @@ def test_public_resume_retires_stale_run_repair_before_preserving_dirty_checkout ) resumed = run_cli( - git_repo, + repo, fixture, "resume", run_id, @@ -340,10 +343,10 @@ def test_public_resume_retires_stale_run_repair_before_preserving_dirty_checkout ) assert delivery_after_resume == expected_delivery - no_agents = git_repo / "no-agents.json" + no_agents = repo / "no-agents.json" no_agents.write_text("{}", encoding="utf-8") still_dirty = run_cli( - git_repo, + repo, fixture, "run", "1", @@ -371,7 +374,7 @@ def test_public_resume_retires_stale_run_repair_before_preserving_dirty_checkout capture_output=True, ) untracked.unlink() - fresh_agents = git_repo / "fresh-run-agents.json" + fresh_agents = repo / "fresh-run-agents.json" fresh_agents.write_text( json.dumps( { @@ -387,7 +390,7 @@ def test_public_resume_retires_stale_run_repair_before_preserving_dirty_checkout ) recovered = run_cli( - git_repo, + repo, fixture, "run", "1", @@ -405,7 +408,7 @@ def test_public_resume_retires_stale_run_repair_before_preserving_dirty_checkout assert not checkout.exists() assert subprocess.run( ["git", "show-ref", "--verify", "--quiet", f"refs/heads/{repair_branch}"], - cwd=git_repo, + cwd=repo, check=False, ).returncode == 1 recovered_delivery = json.loads(fixture.read_text(encoding="utf-8"))["delivery"] @@ -698,16 +701,18 @@ def test_stale_run_repair_keeps_threads_out_of_fresh_review( def test_accept_run_does_not_review_after_a_github_refresh_failure( git_repo: Path, ) -> None: - state, states, _git = _completed_run(git_repo) - fixture = git_repo / "github.json" + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, _git = _completed_run(repo, state_root=workspace.state_root) + fixture = repo / "github.json" data = json.loads(fixture.read_text(encoding="utf-8")) data["error"] = {"code": "github_read_failed", "message": "offline"} fixture.write_text(json.dumps(data), encoding="utf-8") - agents = git_repo / "run-agents.json" + agents = repo / "run-agents.json" agents.write_text(json.dumps({"reviews": []}), encoding="utf-8") result = run_internal_stage( - git_repo, + repo, fixture, "accept-run", str(state["run_id"]), diff --git a/tests/test_run_candidate_acceptance.py b/tests/test_run_candidate_acceptance.py index ff7ad10..7fa7ce4 100644 --- a/tests/test_run_candidate_acceptance.py +++ b/tests/test_run_candidate_acceptance.py @@ -24,6 +24,7 @@ require_current_run_state, ) +from support.workspace import prepare_workspace from conftest import seed_idle_control from support.inprocess_cli import invoke_cli_inprocess from test_cli import run_cli, stdout_json @@ -179,10 +180,12 @@ def test_run_acceptance_repairs_then_rechecks_the_whole_run( def test_run_repair_budget_exhaustion_ends_the_repair_cycle( git_repo: Path, ) -> None: - state, states, git = _completed_run(git_repo) + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, git = _completed_run(repo, state_root=workspace.state_root) seed_idle_control( TaskControlStore(states.root), - TaskKey(git_repo, str(state["repository"]), 1), + TaskKey(repo, str(state["repository"]), 1), str(state["run_id"]), state_dir=states.root, ) @@ -241,12 +244,12 @@ def test_run_repair_budget_exhaustion_ends_the_repair_cycle( state["run_acceptance"] = run state["status"] = "run_acceptance_pending" states.save_run(str(state["run_id"]), state) - agent_fixture = git_repo / "agents.json" + agent_fixture = repo / "agents.json" agent_fixture.write_text(json.dumps({}), encoding="utf-8") result = run_cli( - git_repo, - git_repo / "github.json", + repo, + repo / "github.json", "run", "1", "--agent-fixture", @@ -256,13 +259,13 @@ def test_run_repair_budget_exhaustion_ends_the_repair_cycle( assert result.returncode == 2, result.stderr assert stdout_json(result)["status"] == "ready_for_human" status_result = invoke_cli_inprocess( - git_repo, git_repo / "github.json", "status", str(state["run_id"]), "--json" + repo, repo / "github.json", "status", str(state["run_id"]), "--json" ) text_status_result = invoke_cli_inprocess( - git_repo, git_repo / "github.json", "status", str(state["run_id"]) + repo, repo / "github.json", "status", str(state["run_id"]) ) history_result = invoke_cli_inprocess( - git_repo, git_repo / "github.json", "history", str(state["run_id"]), "--json" + repo, repo / "github.json", "history", str(state["run_id"]), "--json" ) assert status_result.returncode == text_status_result.returncode == 0 assert history_result.returncode == 0 @@ -300,7 +303,9 @@ def test_run_repair_budget_exhaustion_ends_the_repair_cycle( def test_status_distinguishes_stale_acceptance_generation_from_repair_cycle( git_repo: Path, ) -> None: - state, states, git = _completed_run(git_repo) + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, git = _completed_run(repo, state_root=workspace.state_root) run = state["run_acceptance"] = { "phase": "pending", "policy_snapshot": dict(state["policy_snapshot"]), @@ -342,7 +347,7 @@ def publication(self, _request: dict[str, Any]) -> dict[str, str]: git=git, states=states, agents=BlockedRepairAgents(), - github=FixtureGitHubPublisher(git_repo / "github.json", git), + github=FixtureGitHubPublisher(repo / "github.json", git), ).accept(str(state["run_id"])) job = blocked["run_acceptance"]["repair_job"] @@ -351,10 +356,10 @@ def publication(self, _request: dict[str, Any]) -> dict[str, str]: assert job["repair_generation"] == 2 json_status = invoke_cli_inprocess( - git_repo, git_repo / "github.json", "status", str(state["run_id"]), "--json" + repo, repo / "github.json", "status", str(state["run_id"]), "--json" ) text_status = invoke_cli_inprocess( - git_repo, git_repo / "github.json", "status", str(state["run_id"]) + repo, repo / "github.json", "status", str(state["run_id"]) ) assert json_status.returncode == text_status.returncode == 0 repair_status = stdout_json(json_status)["run_repair"] @@ -378,7 +383,9 @@ def publication(self, _request: dict[str, Any]) -> dict[str, str]: def test_status_keeps_passed_candidate_validation_separate_from_delivery_phase( git_repo: Path, job_phase: str, run_status: str ) -> None: - state, states, git = _completed_run(git_repo) + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, git = _completed_run(repo, state_root=workspace.state_root) candidate_sha = git.resolve(str(state["run_branch"])) state["status"] = run_status repair_job = { @@ -423,10 +430,10 @@ def test_status_keeps_passed_candidate_validation_separate_from_delivery_phase( states.save_run(str(state["run_id"]), state) json_status = invoke_cli_inprocess( - git_repo, git_repo / "github.json", "status", str(state["run_id"]), "--json" + repo, repo / "github.json", "status", str(state["run_id"]), "--json" ) text_status = invoke_cli_inprocess( - git_repo, git_repo / "github.json", "status", str(state["run_id"]) + repo, repo / "github.json", "status", str(state["run_id"]) ) assert json_status.returncode == text_status.returncode == 0 @@ -444,8 +451,10 @@ def test_status_keeps_passed_candidate_validation_separate_from_delivery_phase( def test_status_binds_candidate_verdict_to_the_candidate_being_reviewed( git_repo: Path, ) -> None: - state, states, git = _completed_run(git_repo) - fixture = git_repo / "github.json" + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, git = _completed_run(repo, state_root=workspace.state_root) + fixture = repo / "github.json" class ConsecutiveCandidateAgents(ScriptedRunAgents): def __init__(self) -> None: @@ -472,10 +481,10 @@ def review(self, request: dict[str, Any]) -> ReviewResult: self.review_requests.append(request) if len(self.review_requests) == 3: json_status = invoke_cli_inprocess( - git_repo, fixture, "status", str(state["run_id"]), "--json" + repo, fixture, "status", str(state["run_id"]), "--json" ) text_status = invoke_cli_inprocess( - git_repo, fixture, "status", str(state["run_id"]) + repo, fixture, "status", str(state["run_id"]) ) assert json_status.returncode == text_status.returncode == 0 repair_status = stdout_json(json_status)["run_repair"] @@ -488,10 +497,10 @@ def review(self, request: dict[str, Any]) -> ReviewResult: def publication(self, request: dict[str, Any]) -> dict[str, str]: json_status = invoke_cli_inprocess( - git_repo, fixture, "status", str(state["run_id"]), "--json" + repo, fixture, "status", str(state["run_id"]), "--json" ) text_status = invoke_cli_inprocess( - git_repo, fixture, "status", str(state["run_id"]) + repo, fixture, "status", str(state["run_id"]) ) assert json_status.returncode == text_status.returncode == 0 repair_status = stdout_json(json_status)["run_repair"] diff --git a/tests/test_run_candidate_merge_resolution.py b/tests/test_run_candidate_merge_resolution.py index 0d485c9..5daaddf 100644 --- a/tests/test_run_candidate_merge_resolution.py +++ b/tests/test_run_candidate_merge_resolution.py @@ -14,6 +14,7 @@ from agent_run.github_fixture import FixtureGitHubPublisher, FixtureGitHubReader from agent_run.run_acceptance import RunAcceptanceEngine +from support.workspace import prepare_workspace from conftest import seed_idle_control from test_cli import run_cli @@ -33,33 +34,35 @@ def test_merged_conflict_candidate_default_drift_stays_in_same_repair_cycle( latest_combination_has_finding: bool, external_base_drift: bool, ) -> None: - state, states, git = _completed_run(git_repo) + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, git = _completed_run(repo, state_root=workspace.state_root) if not latest_combination_has_finding: seed_idle_control( TaskControlStore(states.root), - TaskKey(git_repo, str(state["repository"]), 1), + TaskKey(repo, str(state["repository"]), 1), str(state["run_id"]), state_dir=states.root, ) - fixture = git_repo / "github.json" + fixture = repo / "github.json" run_branch = str(state["run_branch"]) - subprocess.run(["git", "switch", run_branch], cwd=git_repo, check=True) - (git_repo / "shared.txt").write_text("run branch\n", encoding="utf-8") - subprocess.run(["git", "add", "shared.txt"], cwd=git_repo, check=True) + subprocess.run(["git", "switch", run_branch], cwd=repo, check=True) + (repo / "shared.txt").write_text("run branch\n", encoding="utf-8") + subprocess.run(["git", "add", "shared.txt"], cwd=repo, check=True) subprocess.run( ["git", "commit", "-m", "change shared file on run branch"], - cwd=git_repo, + cwd=repo, check=True, capture_output=True, ) run_head = git.resolve(run_branch) - subprocess.run(["git", "switch", "main"], cwd=git_repo, check=True) - (git_repo / "shared.txt").write_text("default branch\n", encoding="utf-8") - subprocess.run(["git", "add", "shared.txt"], cwd=git_repo, check=True) + subprocess.run(["git", "switch", "main"], cwd=repo, check=True) + (repo / "shared.txt").write_text("default branch\n", encoding="utf-8") + subprocess.run(["git", "add", "shared.txt"], cwd=repo, check=True) subprocess.run( ["git", "commit", "-m", "change shared file on default branch"], - cwd=git_repo, + cwd=repo, check=True, capture_output=True, ) @@ -125,15 +128,15 @@ def publication(self, _request: dict[str, Any]) -> dict[str, str]: class DriftAfterMergePublisher(FixtureGitHubPublisher): def sync_run_branch(self, *, run_branch: str, integrated_sha: str) -> None: super().sync_run_branch(run_branch=run_branch, integrated_sha=integrated_sha) - (git_repo / "default-after-merge.txt").write_text( + (repo / "default-after-merge.txt").write_text( "advanced\n", encoding="utf-8" ) subprocess.run( - ["git", "add", "default-after-merge.txt"], cwd=git_repo, check=True + ["git", "add", "default-after-merge.txt"], cwd=repo, check=True ) subprocess.run( ["git", "commit", "-m", "advance default after conflict merge"], - cwd=git_repo, + cwd=repo, check=True, capture_output=True, ) @@ -185,11 +188,11 @@ def sync_run_branch(self, *, run_branch: str, integrated_sha: str) -> None: pull["base_branch"] = "main" mutations = deepcopy(data["delivery"].get("mutations", [])) fixture.write_text(json.dumps(data), encoding="utf-8") - empty_agents = git_repo / "blocked-revalidation-agents.json" + empty_agents = repo / "blocked-revalidation-agents.json" empty_agents.write_text("{}", encoding="utf-8") blocked = run_cli( - git_repo, + repo, fixture, "run", "1", @@ -231,7 +234,7 @@ def sync_run_branch(self, *, run_branch: str, integrated_sha: str) -> None: currentness_reader=FixtureGitHubReader(fixture), ).accept(str(state["run_id"])) else: - revalidation_agents = git_repo / "revalidation-agents.json" + revalidation_agents = repo / "revalidation-agents.json" revalidation_agents.write_text( json.dumps( { @@ -262,7 +265,7 @@ def sync_run_branch(self, *, run_branch: str, integrated_sha: str) -> None: encoding="utf-8", ) resumed = run_cli( - git_repo, + repo, fixture, "run", "1", diff --git a/tests/test_run_crash_recovery.py b/tests/test_run_crash_recovery.py index fa071c8..74ebe51 100644 --- a/tests/test_run_crash_recovery.py +++ b/tests/test_run_crash_recovery.py @@ -6,6 +6,8 @@ import pytest +from support.workspace import managed_state + from agent_run.state import StateStore from conftest import seed_run, write_fixture from support.inprocess_cli import invoke_cli_inprocess @@ -321,7 +323,7 @@ def test_repeated_resume_after_executor_crash_does_not_replay_the_attempt( for invocation in after_first["agent_invocation_history"]: if invocation.get("started_at") == started_at: invocation["status"] = "resuming" - StateStore(git_repo / ".agent-run").save_run(run_id, after_first) + StateStore(managed_state(git_repo)).save_run(run_id, after_first) interrupted_again = run_cli( git_repo, @@ -357,7 +359,7 @@ def test_repeated_resume_after_executor_crash_does_not_replay_the_attempt( assert final_state["ticket_jobs"]["2"]["review_budget"][ "development_attempts" ] == 1 - control_path = next((git_repo / ".agent-run" / "task-control").glob("*.json")) + control_path = next((managed_state(git_repo) / "task-control").glob("*.json")) final_control = json.loads(control_path.read_text(encoding="utf-8")) assert final_control["action"]["kind"] == "resume" assert final_control["action"]["status"] == "completed" @@ -645,8 +647,7 @@ def test_public_cli_preserves_uncommitted_work_after_worker_error( } ] checkout = ( - git_repo - / ".agent-run" + managed_state(git_repo) / "worktrees" / run_id / "ticket-2" diff --git a/tests/test_run_existing_executor.py b/tests/test_run_existing_executor.py index 3118f67..c7e6e81 100644 --- a/tests/test_run_existing_executor.py +++ b/tests/test_run_existing_executor.py @@ -9,6 +9,8 @@ import pytest +from support.workspace import managed_repo, managed_state + from agent_run.state import StateStore from agent_run.task_control import TaskControlStore, TaskKey from cli_run_supervision_support import _parent_only_agents @@ -83,7 +85,7 @@ def inspect(self, spec, control): assert select.select([executor_fd], [], [], 10)[0] == [executor_fd] Path(sys.argv[4]).write_text(json.dumps({ 'control': control.load(spec.task), - 'state': StateStore(Path.cwd() / '.agent-run').load_run(spec.run_id), + 'state': StateStore(spec.state_root).load_run(spec.run_id), 'fixture': json.loads(Path(sys.argv[-1]).read_text()), })) return original_inspect(self, spec, control) @@ -141,8 +143,8 @@ def test_run_observes_existing_executor_after_other_lifecycle_actions( ready, _, _ = select.select([ready_read], [], [], 5) assert ready == [ready_read] assert os.read(ready_read, 1) == b"1" - control = TaskControlStore(git_repo / ".agent-run") - task = TaskKey(git_repo, "example/project", 1) + control = TaskControlStore(managed_state(git_repo)) + task = TaskKey(managed_repo(git_repo), "example/project", 1) record = control.load(task) assert record is not None assert record["action"]["kind"] == action @@ -151,10 +153,10 @@ def test_run_observes_existing_executor_after_other_lifecycle_actions( ) assert record["executor"]["status"] == "running" executor_fd = os.pidfd_open(record["executor"]["pid"]) - state = StateStore(git_repo / ".agent-run").load_run(record["run_id"]) + state = StateStore(managed_state(git_repo)).load_run(record["run_id"]) assert state is not None roots = ( - git_repo / ".agent-run", tmp_path / "environment", tmp_path / "runtime" + managed_state(git_repo), tmp_path / "environment", tmp_path / "runtime" ) before = {str(root): _file_snapshot(root) for root in roots} fixture_before = fixture.read_bytes() @@ -192,7 +194,7 @@ def test_run_observes_existing_executor_after_other_lifecycle_actions( "parent_approval_pending" if action == "resume" else "completed" ) assert control.load(task) == finished["control"] - final_state = StateStore(git_repo / ".agent-run").load_run( + final_state = StateStore(managed_state(git_repo)).load_run( record["run_id"] ) assert final_state == finished["state"] diff --git a/tests/test_run_lifecycle.py b/tests/test_run_lifecycle.py index aa66a5a..b5eae72 100644 --- a/tests/test_run_lifecycle.py +++ b/tests/test_run_lifecycle.py @@ -15,6 +15,8 @@ import pytest +from support.workspace import managed_repo, managed_state, prepare_workspace + from agent_run import cli from agent_run.agent_invocation import session_interruption_is_persisted from agent_run.executor_host import ( @@ -43,7 +45,7 @@ TaskControlStore, TaskKey, ) -from conftest import seed_idle_control, seed_run, write_fixture +from conftest import _user_environment, seed_idle_control, seed_run, write_fixture from cli_fixtures import run_agents from cli_run_supervision_support import _parent_only_agents from test_cli import load_only_run_state, run_cli, stdout_json @@ -55,13 +57,7 @@ def _task(tmp_path: Path) -> TaskKey: def _isolated_environment(root: Path) -> dict[str, str]: - return { - "HOME": str(root / "home"), - "XDG_CONFIG_HOME": str(root / "config"), - "XDG_DATA_HOME": str(root / "data"), - "XDG_STATE_HOME": str(root / "state"), - "PATH": os.pathsep.join((str(Path(sys.executable).parent), "/usr/bin", "/bin")), - } + return _user_environment(root) def _file_snapshot(root: Path) -> dict[str, bytes]: @@ -324,8 +320,9 @@ def test_task_control_lock_contention_fails_without_persistent_mutation( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={"3": ticket()}) agents = run_agents(git_repo / "agents.json") - task = TaskKey(git_repo, "example/project", 1) - control = TaskControlStore(git_repo / ".agent-run") + prepare_workspace(git_repo) + task = TaskKey(managed_repo(git_repo), "example/project", 1) + control = TaskControlStore(managed_state(git_repo)) lock_path = control.directory / f".{task.fingerprint}.lock" lock_path.parent.mkdir(parents=True, exist_ok=True) ready = tmp_path / "lock-ready" @@ -354,7 +351,7 @@ def test_task_control_lock_contention_fails_without_persistent_mutation( if time.monotonic() >= deadline: raise AssertionError("Task Control lock holder did not become ready") time.sleep(0.01) - before = _file_snapshot(git_repo / ".agent-run") + before = _file_snapshot(managed_state(git_repo)) blocked = run_cli( git_repo, fixture, @@ -362,12 +359,11 @@ def test_task_control_lock_contention_fails_without_persistent_mutation( "1", "--agent-fixture", str(agents), - extra_env=_isolated_environment(tmp_path / "busy"), ) assert blocked.returncode == 2, f"{blocked.stdout}\n{blocked.stderr}" assert stdout_json(blocked)["diagnostics"][0]["code"] == "task_control" - assert _file_snapshot(git_repo / ".agent-run") == before + assert _file_snapshot(managed_state(git_repo)) == before finally: if holder.stdin is not None: holder.stdin.close() @@ -1573,11 +1569,11 @@ def test_receipt_only_control_closes_once_after_exact_host_exit_proof( ) assert interrupted.returncode == 2 - state_root = git_repo / ".agent-run" + state_root = managed_state(git_repo, extra_env=environment) states = StateStore(state_root) - state = load_only_run_state(git_repo) + state = load_only_run_state(git_repo, extra_env=environment) run_id = str(state["run_id"]) - task = TaskKey(git_repo, "example/project", 1) + task = TaskKey(managed_repo(git_repo, extra_env=environment), "example/project", 1) control = TaskControlStore(state_root) control_path = control.path_for(task) original_control = json.loads(control_path.read_text(encoding="utf-8")) @@ -1804,13 +1800,8 @@ def test_concurrent_public_runs_share_one_task_action_and_executor( environment = os.environ.copy() environment.update(_isolated_environment(tmp_path / "concurrent")) source = str(Path(__file__).resolve().parents[1] / "src") - isolated_home = tmp_path / "home" environment.update( { - "HOME": str(isolated_home), - "XDG_CONFIG_HOME": str(isolated_home / "config"), - "XDG_DATA_HOME": str(isolated_home / "data"), - "XDG_STATE_HOME": str(isolated_home / "state"), "PATH": os.pathsep.join( (str(Path(sys.executable).parent), "/usr/bin", "/bin") ), @@ -1857,8 +1848,8 @@ def test_concurrent_public_runs_share_one_task_action_and_executor( stderr=subprocess.PIPE, ) - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) - control_path = next((git_repo / ".agent-run" / "task-control").glob("*.json")) + state_path = next((managed_state(git_repo, extra_env=environment) / "runs").glob("*.json")) + control_path = next((managed_state(git_repo, extra_env=environment) / "task-control").glob("*.json")) state_before_conflict = state_path.read_bytes() control_before_conflict = control_path.read_bytes() conflict = subprocess.run( @@ -1890,7 +1881,7 @@ def test_concurrent_public_runs_share_one_task_action_and_executor( assert sorted(submissions) == ["attached", "started"] _assert_public_action_receipt(first_output, submission=submissions[0]) _assert_public_action_receipt(second_output, submission=submissions[1]) - state = load_only_run_state(git_repo) + state = load_only_run_state(git_repo, extra_env=environment) assert state["status"] == "run_approval_pending" development_invocations = [ invocation @@ -1898,7 +1889,7 @@ def test_concurrent_public_runs_share_one_task_action_and_executor( if invocation.get("role") == "development" ] assert len(development_invocations) == 1 - controls = list((git_repo / ".agent-run" / "task-control").glob("*.json")) + controls = list((managed_state(git_repo, extra_env=environment) / "task-control").glob("*.json")) assert len(controls) == 1 control_record = json.loads(controls[0].read_text(encoding="utf-8")) assert control_record["action"]["status"] == "completed" @@ -2011,7 +2002,7 @@ def command(parent: int, fixture: Path, agents: Path) -> list[str]: assert process.returncode == 0, f"{stdout}\n{stderr}" states = [ json.loads(path.read_text(encoding="utf-8")) - for path in sorted((git_repo / ".agent-run" / "runs").glob("*.json")) + for path in sorted((managed_state(git_repo, extra_env=environment) / "runs").glob("*.json")) ] assert len(states) == 2 assert {state["parent"]["number"] for state in states} == {1, 2} @@ -2042,8 +2033,8 @@ def test_repeated_run_reuses_the_completed_action_after_receipt_loss( extra_env=environment, ) first_output = stdout_json(first) - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) - control_path = next((git_repo / ".agent-run" / "task-control").glob("*.json")) + state_path = next((managed_state(git_repo, extra_env=environment) / "runs").glob("*.json")) + control_path = next((managed_state(git_repo, extra_env=environment) / "task-control").glob("*.json")) fixture_before = json.loads(fixture.read_text(encoding="utf-8")) state_before = state_path.read_bytes() control_before = control_path.read_bytes() @@ -2067,7 +2058,7 @@ def test_repeated_run_reuses_the_completed_action_after_receipt_loss( assert json.loads(fixture.read_text(encoding="utf-8")) == fixture_before assert state_path.read_bytes() == state_before assert control_path.read_bytes() == control_before - state = load_only_run_state(git_repo) + state = load_only_run_state(git_repo, extra_env=environment) assert ( sum( invocation.get("role") == "development" @@ -2279,12 +2270,12 @@ def test_repeated_run_reconciles_proven_executor_crash_without_replay( extra_env=environment, ) assert interrupted.returncode == 2 - interrupted_state = load_only_run_state(git_repo) + interrupted_state = load_only_run_state(git_repo, extra_env=environment) receipt = interrupted_state.get("action_application_receipt") assert isinstance(receipt, dict) assert isinstance(receipt.get("action_id"), str) - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) - control_path = next((git_repo / ".agent-run" / "task-control").glob("*.json")) + state_path = next((managed_state(git_repo, extra_env=environment) / "runs").glob("*.json")) + control_path = next((managed_state(git_repo, extra_env=environment) / "task-control").glob("*.json")) interrupted_control = json.loads(control_path.read_text(encoding="utf-8")) interrupted_action = interrupted_control["action"] interrupted_fixture_bytes = fixture.read_bytes() @@ -2337,7 +2328,7 @@ def test_repeated_run_reconciles_proven_executor_crash_without_replay( assert operator_gate["work_subject"] == "ticket:3" assert operator_gate["action_kind"] == "execution_failure" assert operator_gate["reason"] == "session_interrupted" - recovered_state = load_only_run_state(git_repo) + recovered_state = load_only_run_state(git_repo, extra_env=environment) require_current_run_state(recovered_state) assert recovered_state["status"] == "execution_failed" assert recovered_state["terminal_kind"] == "execution_failed" @@ -2417,11 +2408,11 @@ def test_session_closeout_retries_only_the_control_commit( ) assert interrupted.returncode == 2 - state_root = git_repo / ".agent-run" + state_root = managed_state(git_repo, extra_env=environment) states = StateStore(state_root) - initial = load_only_run_state(git_repo) + initial = load_only_run_state(git_repo, extra_env=environment) run_id = str(initial["run_id"]) - task = TaskKey(git_repo, "example/project", 1) + task = TaskKey(managed_repo(git_repo, extra_env=environment), "example/project", 1) class CrashAfterRunSaveStore(TaskControlStore): injected = False @@ -2572,11 +2563,11 @@ def test_repeated_run_fails_closed_after_action_acceptance_before_run_receipt( extra_env=environment, ) assert interrupted.returncode == 2 - interrupted_state = load_only_run_state(git_repo) + interrupted_state = load_only_run_state(git_repo, extra_env=environment) assert "action_application_receipt" not in interrupted_state - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo, extra_env=environment) / "runs").glob("*.json")) interrupted_state_bytes = state_path.read_bytes() - control_path = next((git_repo / ".agent-run" / "task-control").glob("*.json")) + control_path = next((managed_state(git_repo, extra_env=environment) / "task-control").glob("*.json")) interrupted_control = json.loads(control_path.read_text(encoding="utf-8")) interrupted_action = interrupted_control["action"] @@ -2621,8 +2612,8 @@ def test_terminal_run_receipt_still_requires_host_exit_proof( extra_env=environment, ) first_output = stdout_json(first) - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) - control_path = next((git_repo / ".agent-run" / "task-control").glob("*.json")) + state_path = next((managed_state(git_repo, extra_env=environment) / "runs").glob("*.json")) + control_path = next((managed_state(git_repo, extra_env=environment) / "task-control").glob("*.json")) state_before = state_path.read_bytes() fixture_before = fixture.read_bytes() if control_case == "missing": @@ -2646,7 +2637,7 @@ def test_terminal_run_receipt_still_requires_host_exit_proof( _assert_public_action_receipt(first_output, submission="started") assert state_path.read_bytes() == state_before assert fixture.read_bytes() == fixture_before - repaired_controls = list((git_repo / ".agent-run" / "task-control").glob("*.json")) + repaired_controls = list((managed_state(git_repo, extra_env=environment) / "task-control").glob("*.json")) assert len(repaired_controls) == 1 repaired_control = json.loads(repaired_controls[0].read_text(encoding="utf-8")) repaired_action = repaired_control["action"] @@ -2718,7 +2709,7 @@ def test_public_run_reconciles_receipt_only_control_before_replay( ) assert first.returncode == 2, f"{first.stdout}\n{first.stderr}" - state_root = git_repo / ".agent-run" + state_root = managed_state(git_repo, extra_env=environment) state_path = next((state_root / "runs").glob("*.json")) control_path = next((state_root / "task-control").glob("*.json")) original_control = json.loads(control_path.read_text(encoding="utf-8")) @@ -2783,6 +2774,7 @@ def cleanup_startup(self, _spec: ExecutorSpec) -> None: "GhGitHubReader", lambda _repo, *, working_directory: cli.FixtureGitHubReader(fixture), ) + monkeypatch.setattr("agent_run.workspace_cli.GhGitHubReader", lambda _repo=None, *, working_directory: cli.FixtureGitHubReader(fixture)) control_after_first: bytes | None = None state_after_first: bytes | None = None @@ -2845,8 +2837,8 @@ def test_status_and_history_do_not_touch_run_or_task_control( ) assert first.returncode == 0, f"{first.stdout}\n{first.stderr}" run_id = stdout_json(first)["run_id"] - run_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) - control_path = next((git_repo / ".agent-run" / "task-control").glob("*.json")) + run_path = next((managed_state(git_repo, extra_env=environment) / "runs").glob("*.json")) + control_path = next((managed_state(git_repo, extra_env=environment) / "task-control").glob("*.json")) run_before = run_path.read_bytes() control_before = control_path.read_bytes() @@ -2893,7 +2885,7 @@ def test_status_and_history_mark_unavailable_task_control_unknown_without_writes ) assert first.returncode == 0, f"{first.stdout}\n{first.stderr}" run_id = stdout_json(first)["run_id"] - control_path = next((git_repo / ".agent-run" / "task-control").glob("*.json")) + control_path = next((managed_state(git_repo, extra_env=environment) / "task-control").glob("*.json")) if control_case == "missing": control_path.unlink() elif control_case == "corrupt": @@ -2982,7 +2974,7 @@ def test_status_and_history_project_only_read_only_host_proof( ) assert first.returncode == 0, f"{first.stdout}\n{first.stderr}" run_id = stdout_json(first)["run_id"] - control_path = next((git_repo / ".agent-run" / "task-control").glob("*.json")) + control_path = next((managed_state(git_repo, extra_env=environment) / "task-control").glob("*.json")) control = json.loads(control_path.read_text(encoding="utf-8")) control["executor"]["status"] = ( "absent" @@ -3099,7 +3091,7 @@ def test_status_and_history_bypass_an_active_action_without_persistent_writes( ) time.sleep(0.01) - run_id = load_only_run_state(git_repo)["run_id"] + run_id = load_only_run_state(git_repo, extra_env=environment)["run_id"] persistent_roots = [git_repo, tmp_path / "read-only-active"] before = {str(root): _file_snapshot(root) for root in persistent_roots} for command_name in ("status", "history"): @@ -3138,7 +3130,7 @@ def test_old_run_without_lifecycle_protocol_is_read_only_but_not_mutable( fixture = write_fixture(git_repo / "github.json", issues={"2": ticket()}) started = seed_run(git_repo, fixture, "1") run_id = stdout_json(started)["run_id"] - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state = json.loads(state_path.read_text(encoding="utf-8")) state.pop("lifecycle_action_protocol") state_path.write_text(json.dumps(state), encoding="utf-8") @@ -3211,7 +3203,7 @@ def test_custom_state_dir_cannot_fork_a_canonical_unfinished_run( ) time.sleep(0.01) - canonical_run = next((git_repo / ".agent-run" / "runs").glob("*.json")) + canonical_run = next((managed_state(git_repo, extra_env=environment) / "runs").glob("*.json")) canonical_before = canonical_run.read_bytes() alternate_state = tmp_path / "alternate-state" blocked = run_cli( @@ -3226,7 +3218,8 @@ def test_custom_state_dir_cannot_fork_a_canonical_unfinished_run( extra_env=environment, ) assert blocked.returncode == 2 - assert stdout_json(blocked)["diagnostics"][0]["code"] == "task_control" + assert stdout_json(blocked)["diagnostics"][0]["code"] == "command_failed" + assert "统一状态目录" in stdout_json(blocked)["diagnostics"][0]["message"] assert not list((alternate_state / "runs").glob("*.json")) assert canonical_run.read_bytes() == canonical_before finally: @@ -3235,13 +3228,13 @@ def test_custom_state_dir_cannot_fork_a_canonical_unfinished_run( assert first.returncode == 0, f"{stdout}\n{stderr}" -def test_default_run_routes_to_an_unfinished_custom_state_dir( +def test_default_run_reuses_an_explicit_canonical_state_dir( git_repo: Path, tmp_path: Path ) -> None: fixture = write_fixture(git_repo / "github.json", issues={"3": ticket()}) agents = run_agents(git_repo / "agents.json") environment = _isolated_environment(tmp_path / "custom-first") - custom_state = tmp_path / "custom-state" + custom_state = managed_state(git_repo, extra_env=environment) first = run_cli( git_repo, @@ -3273,7 +3266,7 @@ def test_default_run_routes_to_an_unfinished_custom_state_dir( _assert_public_action_receipt(first_output, submission="started") _assert_public_action_receipt(repeated_output, submission="attached") assert len(list((custom_state / "runs").glob("*.json"))) == 1 - assert not list((git_repo / ".agent-run" / "runs").glob("*.json")) + assert not (git_repo / ".agent-run").exists() def test_default_run_interrupt_does_not_make_cli_a_run_writer( @@ -3282,7 +3275,7 @@ def test_default_run_interrupt_does_not_make_cli_a_run_writer( fixture = write_fixture(git_repo / "github.json", issues={"3": ticket()}) agents = run_agents(git_repo / "agents.json") environment = _isolated_environment(tmp_path / "custom-interrupt") - custom_state = tmp_path / "custom-interrupt-state" + custom_state = managed_state(git_repo, extra_env=environment) started = tmp_path / "custom-interrupt-started" release = tmp_path / "custom-interrupt-release" @@ -3296,8 +3289,8 @@ def test_default_run_interrupt_does_not_make_cli_a_run_writer( ) assert initial.returncode == 0, f"{initial.stdout}\n{initial.stderr}" seed_idle_control( - TaskControlStore(git_repo / ".agent-run"), - TaskKey(git_repo, "example/project", 1), + TaskControlStore(managed_state(git_repo, extra_env=environment)), + TaskKey(managed_repo(git_repo, extra_env=environment), "example/project", 1), str(stdout_json(initial)["run_id"]), state_dir=custom_state, ) @@ -3354,7 +3347,7 @@ def test_default_run_interrupt_does_not_make_cli_a_run_writer( assert "" not in stdout custom_run_id = str(json.loads(custom_run_path.read_text())["run_id"]) assert custom_run_id not in stdout - control_path = next((git_repo / ".agent-run" / "task-control").glob("*.json")) + control_path = next((managed_state(git_repo, extra_env=environment) / "task-control").glob("*.json")) control_during_observation_exit = json.loads( control_path.read_text(encoding="utf-8") ) @@ -3382,10 +3375,10 @@ def test_default_run_interrupt_does_not_make_cli_a_run_writer( assert final_control["action"]["status"] == "completed" assert final_control["executor"]["status"] == "exited" assert final_control["executor"]["failure"] is None - assert not list((git_repo / ".agent-run" / "runs").glob("*.json")) + assert not (git_repo / ".agent-run").exists() -def test_default_run_routes_to_a_custom_seeded_run( +def test_default_run_rejects_noncanonical_state_in_task_control( git_repo: Path, tmp_path: Path ) -> None: fixture = write_fixture(git_repo / "github.json", issues={"3": ticket()}) @@ -3404,12 +3397,15 @@ def test_default_run_routes_to_a_custom_seeded_run( started_output = stdout_json(started) assert started.returncode == 0, f"{started.stdout}\n{started.stderr}" seed_idle_control( - TaskControlStore(git_repo / ".agent-run"), - TaskKey(git_repo, "example/project", 1), + TaskControlStore(managed_state(git_repo, extra_env=environment)), + TaskKey(managed_repo(git_repo, extra_env=environment), "example/project", 1), str(stdout_json(started)["run_id"]), state_dir=custom_state, ) + before_state = _file_snapshot(custom_state) + before_control = _file_snapshot(managed_state(git_repo, extra_env=environment)) + before_fixture = fixture.read_bytes() continued = run_cli( git_repo, fixture, @@ -3421,14 +3417,16 @@ def test_default_run_routes_to_a_custom_seeded_run( ) continued_output = stdout_json(continued) - assert continued.returncode == 0, f"{continued.stdout}\n{continued.stderr}" - assert continued_output["result"] == "resumed" - assert continued_output["run_id"] == started_output["run_id"] + assert continued.returncode == 2, f"{continued.stdout}\n{continued.stderr}" + assert continued_output["result"] == "error" + assert _file_snapshot(custom_state) == before_state + assert _file_snapshot(managed_state(git_repo, extra_env=environment)) == before_control + assert fixture.read_bytes() == before_fixture assert len(list((custom_state / "runs").glob("*.json"))) == 1 - assert not list((git_repo / ".agent-run" / "runs").glob("*.json")) + assert not list((managed_state(git_repo, extra_env=environment) / "runs").glob("*.json")) -def test_default_run_attaches_to_custom_state_during_executor_handshake( +def test_default_run_attaches_to_canonical_state_during_executor_handshake( git_repo: Path, tmp_path: Path ) -> None: fixture = write_fixture(git_repo / "github.json", issues={"3": ticket()}) @@ -3446,7 +3444,7 @@ def test_default_run_attaches_to_custom_state_during_executor_handshake( environment = _isolated_environment(tmp_path / "custom-handshake") source = str(Path(__file__).resolve().parents[1] / "src") environment["PYTHONPATH"] = source - custom_state = tmp_path / "custom-handshake-state" + custom_state = managed_state(git_repo, extra_env=environment) command = [ sys.executable, "-m", @@ -3510,4 +3508,4 @@ def test_default_run_attaches_to_custom_state_during_executor_handshake( if first.poll() is None: first.communicate(timeout=30) assert len(list((custom_state / "runs").glob("*.json"))) == 1 - assert not list((git_repo / ".agent-run" / "runs").glob("*.json")) + assert not (git_repo / ".agent-run").exists() diff --git a/tests/test_run_locator_selection.py b/tests/test_run_locator_selection.py index d65f873..5224a44 100644 --- a/tests/test_run_locator_selection.py +++ b/tests/test_run_locator_selection.py @@ -11,6 +11,7 @@ from agent_run.run_locator import RunLocatorIndex from conftest import seed_run, write_fixture from test_cli import run_cli, stdout_json +from support.workspace import managed_repo, managed_state def test_empty_checkout_without_origin_does_not_list_another_repository( @@ -21,12 +22,12 @@ def test_empty_checkout_without_origin_does_not_list_another_repository( assert seed_run(git_repo, fixture).returncode == 0 empty = tmp_path / "empty-checkout" subprocess.run(["git", "init", "-b", "main", str(empty)], check=True, capture_output=True) - before = _snapshot(git_repo / ".agent-run", RunLocatorIndex.default().path.parent) + before = _snapshot(managed_state(git_repo), RunLocatorIndex.default().path.parent) monkeypatch.chdir(empty) - assert main(["runs", "--json"]) == 0 - assert json.loads(capsys.readouterr().out)["runs"] == [] - assert _snapshot(git_repo / ".agent-run", RunLocatorIndex.default().path.parent) == before + assert main(["runs", "--json"]) == 2 + assert json.loads(capsys.readouterr().out)["diagnostics"][0]["code"] == "run_selector_context" + assert _snapshot(managed_state(git_repo), RunLocatorIndex.default().path.parent) == before @pytest.mark.parametrize("explicit_directory", [False, True]) @@ -36,7 +37,7 @@ def test_readable_legacy_state_with_unknown_parent_requires_disambiguation( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) target = stdout_json(seed_run(git_repo, fixture))["run_id"] - state_dir = git_repo / ".agent-run" + state_dir = managed_state(git_repo) target_state = json.loads((state_dir / "runs" / f"{target}.json").read_text()) legacy = { "run_id": "legacy-unknown-parent", "repository": "example/project", @@ -48,7 +49,7 @@ def test_readable_legacy_state_with_unknown_parent_requires_disambiguation( directory = ["--state-dir", str(state_dir)] if explicit_directory else [] for command in ("status", "history"): - assert main([command, "--parent", "1", *directory, "--json"]) == 2 + assert main([command, "--repo", "example/project", "--parent", "1", *directory, "--json"]) == 2 output = json.loads(capsys.readouterr().out) assert output["diagnostics"][0]["code"] == "run_locator_stale" assert main([command, target, *directory, "--json"]) == 0 @@ -60,7 +61,7 @@ def test_repository_parent_queries_ignore_a_deleted_unrelated_checkout( git_repo: Path, tmp_path: Path ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) - locator_env = {"XDG_STATE_HOME": str(tmp_path / "locator")} + locator_env = {"XDG_DATA_HOME": str(tmp_path / "locator")} target = stdout_json(seed_run(git_repo, fixture, extra_env=locator_env))["run_id"] other = tmp_path / "deleted-checkout" subprocess.run(["git", "clone", "--quiet", str(git_repo), str(other)], check=True) @@ -71,10 +72,11 @@ def test_repository_parent_queries_ignore_a_deleted_unrelated_checkout( ) other_fixture = write_fixture(other / "github.json", issues={}, repository="other/project") assert seed_run(other, other_fixture, extra_env=locator_env).returncode == 0 + shutil.rmtree(managed_repo(other, extra_env=locator_env)) shutil.rmtree(other) locator = tmp_path / "locator" / "agent-run" / "run-locator.json" before = locator.read_bytes() - state = git_repo / ".agent-run" / "runs" / f"{target}.json" + state = managed_state(git_repo, extra_env=locator_env) / "runs" / f"{target}.json" state_before = state.read_bytes() for command, cwd, selector in ( @@ -100,7 +102,7 @@ def test_parent_query_excludes_a_known_other_parent_with_missing_state( state_dir=git_repo / "missing", repository="example/project", parent_number=2, ) monkeypatch.chdir(git_repo) - assert main(["status", "--parent", "1", "--json"]) == 0 + assert main(["status", "--repo", "example/project", "--parent", "1", "--json"]) == 0 assert json.loads(capsys.readouterr().out)["run_id"] == target @@ -116,14 +118,14 @@ def test_selector_rejects_conflicting_locator_and_state_identity( locator["entries"][0]["parent_number"] = 2 locator_path.write_text(json.dumps(locator)) before = locator_path.read_bytes() - state_path = git_repo / ".agent-run" / "runs" / f"{target}.json" + state_path = managed_state(git_repo) / "runs" / f"{target}.json" if malformed_state: state = json.loads(state_path.read_text()) state.pop("timeline") state_path.write_text(json.dumps(state)) state_before = state_path.read_bytes() monkeypatch.chdir(git_repo) - assert main(["status", "--parent", "1", "--json"]) == 2 + assert main(["status", "--repo", "example/project", "--parent", "1", "--json"]) == 2 output = json.loads(capsys.readouterr().out) assert output["diagnostics"][0]["code"] == "run_locator_stale" assert "不一致" in output["diagnostics"][0]["message"] @@ -138,7 +140,7 @@ def test_other_parent_legacy_state_in_the_same_directory_does_not_block_queries( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) target = stdout_json(seed_run(git_repo, fixture))["run_id"] - state_dir = git_repo / ".agent-run" + state_dir = managed_state(git_repo) legacy = state_dir / "runs" / "legacy-run.json" # Historical identity is sufficient for exclusion; it is not a current # lifecycle payload and has no checkout identity or registration to repair. @@ -150,7 +152,7 @@ def test_other_parent_legacy_state_in_the_same_directory_does_not_block_queries( monkeypatch.chdir(git_repo) directory = ["--state-dir", str(state_dir)] if explicit_directory else [] for command in ("status", "history", "runs"): - assert main([command, "--parent", "1", *directory, "--json"]) == 0 + assert main([command, "--repo", "example/project", "--parent", "1", *directory, "--json"]) == 0 output = json.loads(capsys.readouterr().out) if command == "runs": assert [candidate["run_id"] for candidate in output["runs"]] == [target] @@ -175,22 +177,22 @@ def test_unknown_or_potentially_matching_records_fail_closed_but_exact_id_works( if unavailable == "old-deleted-checkout": entry["repository_root"] = str(git_repo / "deleted-checkout") if unavailable == "matching-corrupt": - (git_repo / ".agent-run" / "runs" / "unavailable-run.json").write_text("{") + (managed_state(git_repo) / "runs" / "unavailable-run.json").write_text("{") locator["entries"].append(entry) locator_path.write_text(json.dumps(locator)) - before = _snapshot(git_repo / ".agent-run", locator_path.parent) + before = _snapshot(managed_state(git_repo), locator_path.parent) monkeypatch.chdir(git_repo) for command in ("status", "history"): assert main([command, "--repo", "example/project", "--parent", "1", "--json"]) == 2 output = json.loads(capsys.readouterr().out) assert output["diagnostics"][0]["code"] == "run_locator_stale" assert "unavailable-run" in {item["run_id"] for item in output["diagnostics"][0]["candidates"]} - assert main([command, target, "--state-dir", str(git_repo / ".agent-run"), "--json"]) == 0 + assert main([command, target, "--state-dir", str(managed_state(git_repo)), "--json"]) == 0 assert json.loads(capsys.readouterr().out)["run_id"] == target - assert main(["runs", "--parent", "1", "--json"]) == 0 + assert main(["runs", "--repo", "example/project", "--parent", "1", "--json"]) == 0 candidates = json.loads(capsys.readouterr().out)["runs"] assert any(item["run_id"] == "unavailable-run" and "error" in item for item in candidates) - assert _snapshot(git_repo / ".agent-run", locator_path.parent) == before + assert _snapshot(managed_state(git_repo), locator_path.parent) == before @pytest.mark.parametrize("contradiction", ["checkout-repository", "checkout-identity", "run-id", "invalid-parent", "invalid-repository"]) @@ -215,11 +217,11 @@ def test_unregistered_legacy_identity_cannot_hide_conflicts_as_an_unrelated_pare legacy["parent"] = {"number": False} else: legacy["repository"] = "invalid" - state_dir = git_repo / ".agent-run" + state_dir = managed_state(git_repo) (state_dir / "runs" / "legacy-run.json").write_text(json.dumps(legacy)) before = _snapshot(state_dir, RunLocatorIndex.default().path.parent) monkeypatch.chdir(git_repo) - assert main(["status", "--parent", "1", "--json"]) == 2 + assert main(["status", "--repo", "example/project", "--parent", "1", "--json"]) == 2 assert json.loads(capsys.readouterr().out)["diagnostics"][0]["code"] == "run_locator_stale" assert _snapshot(state_dir, RunLocatorIndex.default().path.parent) == before @@ -231,7 +233,7 @@ def _snapshot(*roots: Path) -> dict[Path, tuple[bytes, int]]: } -def test_unregistered_local_run_without_origin_still_detects_another_clone( +def test_user_clones_reuse_the_same_unregistered_managed_run( git_repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: @@ -245,13 +247,14 @@ def test_unregistered_local_run_without_origin_still_detects_another_clone( locator = json.loads(locator_path.read_text()) locator["entries"] = [entry for entry in locator["entries"] if entry["run_id"] != target] locator_path.write_text(json.dumps(locator)) - before = _snapshot(git_repo / ".agent-run", clone / ".agent-run", locator_path.parent) - monkeypatch.chdir(git_repo) - assert main(["status", "--parent", "1", "--json"]) == 2 - diagnostic = json.loads(capsys.readouterr().out)["diagnostics"][0] - assert diagnostic["code"] == "run_selector_ambiguous" - assert {candidate["run_id"] for candidate in diagnostic["candidates"]} == {target, other} - assert _snapshot(git_repo / ".agent-run", clone / ".agent-run", locator_path.parent) == before + assert other == target + assert managed_repo(git_repo) == managed_repo(clone) + before = _snapshot(git_repo, clone, locator_path.parent) + for directory in (git_repo, clone, tmp_path): + monkeypatch.chdir(directory) + assert main(["status", "--repo", "example/project", "--parent", "1", "--json"]) == 0 + assert json.loads(capsys.readouterr().out)["run_id"] == target + assert _snapshot(git_repo, clone, locator_path.parent) == before def test_invalid_locator_query_failures_leave_all_state_unchanged( @@ -263,12 +266,12 @@ def test_invalid_locator_query_failures_leave_all_state_unchanged( locator = json.loads(locator_path.read_text()) locator["entries"][0].pop("parent_number") locator_path.write_text(json.dumps(locator)) - before = _snapshot(git_repo / ".agent-run", locator_path.parent) + before = _snapshot(managed_state(git_repo), locator_path.parent) monkeypatch.chdir(git_repo) for command in ("status", "history", "runs"): - assert main([command, "--parent", "1", "--json"]) == 2 + assert main([command, "--repo", "example/project", "--parent", "1", "--json"]) == 2 assert json.loads(capsys.readouterr().out)["diagnostics"][0]["code"] == "run_locator_invalid" - assert _snapshot(git_repo / ".agent-run", locator_path.parent) == before + assert _snapshot(managed_state(git_repo), locator_path.parent) == before def test_explicit_state_directory_does_not_disambiguate_multiple_matching_runs( @@ -277,11 +280,11 @@ def test_explicit_state_directory_does_not_disambiguate_multiple_matching_runs( fixture = write_fixture(git_repo / "github.json", issues={}) first = stdout_json(seed_run(git_repo, fixture))["run_id"] second = stdout_json(seed_run(git_repo, fixture, reuse_existing=False))["run_id"] - state_dir = git_repo / ".agent-run" + state_dir = managed_state(git_repo) before = _snapshot(state_dir, RunLocatorIndex.default().path.parent) monkeypatch.chdir(git_repo) for command in ("status", "history"): - assert main([command, "--parent", "1", "--state-dir", str(state_dir), "--json"]) == 2 + assert main([command, "--repo", "example/project", "--parent", "1", "--state-dir", str(state_dir), "--json"]) == 2 diagnostic = json.loads(capsys.readouterr().out)["diagnostics"][0] assert diagnostic["code"] == "run_selector_ambiguous" assert {item["run_id"] for item in diagnostic["candidates"]} == {first, second} diff --git a/tests/test_run_merge_resolution.py b/tests/test_run_merge_resolution.py index d559c56..eb794e4 100644 --- a/tests/test_run_merge_resolution.py +++ b/tests/test_run_merge_resolution.py @@ -21,6 +21,7 @@ MergeResolutionFixture, _git_output, ) +from support.workspace import prepare_workspace from conftest import seed_idle_control from test_cli import run_cli from run_acceptance_test_support import _passing_artifact @@ -413,10 +414,12 @@ def test_merge_resolution_finding_replay_save_crash_is_idempotent( def test_merge_resolution_staged_development_interruption_resumes_same_thread( git_repo: Path, ) -> None: - fixture = MergeResolutionFixture(git_repo, "development_staged_interruption") + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + fixture = MergeResolutionFixture(repo, "development_staged_interruption", state_root=workspace.state_root) seed_idle_control( TaskControlStore(fixture.states.root), - TaskKey(git_repo, str(fixture.state["repository"]), 1), + TaskKey(repo, str(fixture.state["repository"]), 1), str(fixture.state["run_id"]), state_dir=fixture.states.root, ) diff --git a/tests/test_run_orchestration.py b/tests/test_run_orchestration.py index 8267cb5..4259037 100644 --- a/tests/test_run_orchestration.py +++ b/tests/test_run_orchestration.py @@ -33,6 +33,7 @@ from agent_run.review_budget import new_budget from conftest import seed_run, write_fixture from support.inprocess_cli import invoke_cli_inprocess +from support.workspace import managed_repo, managed_state, prepare_workspace from test_cli import run_internal_stage, load_only_run_state, run_cli, stdout_json from test_cli_delivery import parent_round_agents, passing_acceptance @@ -376,10 +377,11 @@ def test_unbound_execution_failure_and_ticket_human_gate_fail_closed( git_repo: Path, ) -> None: fixture = write_fixture(git_repo / "github.json", issues={"2": _ticket(2)}) - states = StateStore(git_repo / ".agent-run") + workspace = prepare_workspace(git_repo) + states = StateStore(workspace.state_root) controller = Controller( FixtureGitHubReader(fixture), - GitRepository(git_repo), + GitRepository(workspace.repository_root), states, ) state, _ = controller.start(1) @@ -1438,7 +1440,7 @@ def test_deliver_advances_the_complete_dag_and_enters_run_acceptance( "--count", f"{state['base']['sha']}..{state['run_branch']}", ], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, @@ -1801,7 +1803,7 @@ def test_human_blocked_ticket_gates_independent_work_until_resume( for event in history_json["events"] ) state["timeline"] = [state["timeline"][0]] - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") stale_timeline_history = stdout_json( invoke_cli_inprocess(git_repo, fixture, "history", run_id, "--json") diff --git a/tests/test_run_publication.py b/tests/test_run_publication.py index 2c8bfdc..b87d194 100644 --- a/tests/test_run_publication.py +++ b/tests/test_run_publication.py @@ -22,6 +22,7 @@ from run_acceptance_test_support import _passing_artifact from support.inprocess_cli import invoke_cli_inprocess +from support.workspace import prepare_workspace from test_cli import run_internal_stage, run_cli, stdout_json from run_publication_test_support import ( @@ -369,7 +370,9 @@ def test_unknown_final_pr_narrative_write_does_not_replay( def test_final_publication_human_resume_clears_current_blocker( git_repo: Path, ) -> None: - state, states, git, publisher = _accepted_run(git_repo) + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, git, publisher = _accepted_run(repo, state_root=workspace.state_root) agents = HumanThenRunPublicationAgents() engine = RunPublicationEngine( git=git, @@ -390,8 +393,8 @@ def test_final_publication_human_resume_clears_current_blocker( assert publisher.data["delivery"]["pull_requests"] == [] for command in ("status", "history"): view = invoke_cli_inprocess( - git_repo, - git_repo / "github.json", + repo, + repo / "github.json", command, str(state["run_id"]), ) @@ -415,8 +418,8 @@ def test_final_publication_human_resume_clears_current_blocker( ) history = stdout_json( invoke_cli_inprocess( - git_repo, - git_repo / "github.json", + repo, + repo / "github.json", "history", str(state["run_id"]), "--json", @@ -430,7 +433,7 @@ def test_final_publication_human_resume_clears_current_blocker( ) resumed, _ = Controller( - FixtureGitHubReader(git_repo / "github.json"), git, states + FixtureGitHubReader(repo / "github.json"), git, states ).resume( str(state["run_id"]), resume_human_blocker=True, @@ -732,7 +735,9 @@ def test_approve_rejects_default_branch_drift_without_merging(git_repo: Path) -> def test_revise_and_abandon_preserve_audit_but_stop_future_mutation( git_repo: Path, ) -> None: - state, states, git, publisher = _accepted_run(git_repo) + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, git, publisher = _accepted_run(repo, state_root=workspace.state_root) engine = RunPublicationEngine( git=git, states=states, @@ -765,20 +770,20 @@ def test_revise_and_abandon_preserve_audit_but_stop_future_mutation( == "CLOSED" ) resumed = run_internal_stage( - git_repo, git_repo / "github.json", "accept-run", str(state["run_id"]) + repo, repo / "github.json", "accept-run", str(state["run_id"]) ) assert resumed.returncode == 0, resumed.stderr assert stdout_json(resumed)["status"] == "abandoned" removed_command = run_cli( - git_repo, - git_repo / "github.json", + repo, + repo / "github.json", "confirm-structure", str(state["run_id"]), ) assert removed_command.returncode == 2 assert "invalid choice" in removed_command.stderr assert not Controller( - FixtureGitHubReader(git_repo / "github.json"), git, states + FixtureGitHubReader(repo / "github.json"), git, states ).record_execution_failure(str(state["run_id"]), "must not overwrite abandonment") assert states.load_run(str(state["run_id"]))["status"] == "abandoned" diff --git a/tests/test_run_publication_agent_contract.py b/tests/test_run_publication_agent_contract.py index 4bfb9ec..c123ada 100644 --- a/tests/test_run_publication_agent_contract.py +++ b/tests/test_run_publication_agent_contract.py @@ -9,6 +9,8 @@ import pytest +from support.workspace import prepare_workspace + from agent_run.task_control import TaskControlStore, TaskKey from agent_run.agents import ReviewResult from agent_run.agent_invocation import canonical_fingerprint @@ -348,16 +350,18 @@ def run_publication(self, request: dict[str, Any]) -> dict[str, Any]: def test_malformed_final_run_publication_is_reported_as_execution_failed_by_cli( git_repo: Path, ) -> None: - state, states, _git, _publisher = _accepted_run(git_repo) - agents = git_repo / "malformed-run-publication.json" + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, _git, _publisher = _accepted_run(repo, state_root=workspace.state_root) + agents = repo / "malformed-run-publication.json" agents.write_text( json.dumps({"run_publications": [{"invalid": "publication"}]}), encoding="utf-8", ) failed = run_internal_stage( - git_repo, - git_repo / "github.json", + repo, + repo / "github.json", "publish-run", str(state["run_id"]), "--agent-fixture", @@ -379,16 +383,18 @@ def test_malformed_final_run_publication_is_reported_as_execution_failed_by_cli( def test_final_publication_fixture_missing_thread_marks_invocation_failed( git_repo: Path, ) -> None: - state, states, _git, _publisher = _accepted_run(git_repo) - agents = git_repo / "missing-run-publication-thread.json" + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, _git, _publisher = _accepted_run(repo, state_root=workspace.state_root) + agents = repo / "missing-run-publication-thread.json" agents.write_text( json.dumps({"run_publications": [{"no_thread": True}]}), encoding="utf-8", ) failed = run_internal_stage( - git_repo, - git_repo / "github.json", + repo, + repo / "github.json", "publish-run", str(state["run_id"]), "--agent-fixture", @@ -403,8 +409,10 @@ def test_final_publication_fixture_missing_thread_marks_invocation_failed( def test_final_publication_fixture_repairs_malformed_output_in_same_thread( git_repo: Path, ) -> None: - state, states, _git, _publisher = _accepted_run(git_repo) - agents = git_repo / "repair-run-publication.json" + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, _git, _publisher = _accepted_run(repo, state_root=workspace.state_root) + agents = repo / "repair-run-publication.json" artifact = RunPublicationAgents().run_publication({}) agents.write_text( json.dumps( @@ -427,8 +435,8 @@ def test_final_publication_fixture_repairs_malformed_output_in_same_thread( ) published = run_internal_stage( - git_repo, - git_repo / "github.json", + repo, + repo / "github.json", "publish-run", str(state["run_id"]), "--agent-fixture", @@ -446,16 +454,18 @@ def test_final_publication_fixture_repairs_malformed_output_in_same_thread( def test_final_publication_fixture_records_a_fresh_thread_without_expectation( git_repo: Path, ) -> None: - state, states, _git, _publisher = _accepted_run(git_repo) - agents = git_repo / "fresh-run-publication.json" + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, _git, _publisher = _accepted_run(repo, state_root=workspace.state_root) + agents = repo / "fresh-run-publication.json" agents.write_text( json.dumps({"run_publications": [RunPublicationAgents().run_publication({})]}), encoding="utf-8", ) published = run_internal_stage( - git_repo, - git_repo / "github.json", + repo, + repo / "github.json", "publish-run", str(state["run_id"]), "--agent-fixture", @@ -473,14 +483,16 @@ def test_final_publication_fixture_records_a_fresh_thread_without_expectation( def test_final_publication_fixture_resume_gets_a_fresh_repair_budget( git_repo: Path, ) -> None: - state, states, _git, _publisher = _accepted_run(git_repo) + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, _git, _publisher = _accepted_run(repo, state_root=workspace.state_root) seed_idle_control( TaskControlStore(states.root), - TaskKey(git_repo, str(state["repository"]), 1), + TaskKey(repo, str(state["repository"]), 1), str(state["run_id"]), state_dir=states.root, ) - failed_agents = git_repo / "failed-run-publication.json" + failed_agents = repo / "failed-run-publication.json" failed_agents.write_text( json.dumps( { @@ -506,8 +518,8 @@ def test_final_publication_fixture_resume_gets_a_fresh_repair_budget( encoding="utf-8", ) failed = run_internal_stage( - git_repo, - git_repo / "github.json", + repo, + repo / "github.json", "publish-run", str(state["run_id"]), "--agent-fixture", @@ -515,7 +527,7 @@ def test_final_publication_fixture_resume_gets_a_fresh_repair_budget( ) assert failed.returncode == 2 - resumed_agents = git_repo / "resumed-run-publication.json" + resumed_agents = repo / "resumed-run-publication.json" resumed_agents.write_text( json.dumps( { @@ -531,8 +543,8 @@ def test_final_publication_fixture_resume_gets_a_fresh_repair_budget( encoding="utf-8", ) resumed = run_cli( - git_repo, - git_repo / "github.json", + repo, + repo / "github.json", "resume", str(state["run_id"]), "--agent-fixture", @@ -552,26 +564,28 @@ def test_final_publication_fixture_resume_gets_a_fresh_repair_budget( def test_publish_run_cannot_bypass_exhausted_final_run_operation_retry( git_repo: Path, ) -> None: - state, states, git, publisher = _accepted_run(git_repo) + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, git, publisher = _accepted_run(repo, state_root=workspace.state_root) pending = RunPublicationEngine( git=git, states=states, agents=RunPublicationAgents(), - github=InterruptedRunPublisher(git_repo / "github.json", git), + github=InterruptedRunPublisher(repo / "github.json", git), default_branch="main", default_head_sha=git.resolve("main"), ).publish(str(state["run_id"])) acceptance = pending["run_acceptance"]["acceptance_record"] - agents = git_repo / "resume-publication-agents.json" + agents = repo / "resume-publication-agents.json" agents.write_text( json.dumps({"run_publications": [RunPublicationAgents().run_publication({})]}), encoding="utf-8", ) resumed = run_internal_stage( - git_repo, - git_repo / "github.json", + repo, + repo / "github.json", "publish-run", str(state["run_id"]), "--agent-fixture", diff --git a/tests/test_run_publication_cli.py b/tests/test_run_publication_cli.py index 655f412..10ac344 100644 --- a/tests/test_run_publication_cli.py +++ b/tests/test_run_publication_cli.py @@ -1,5 +1,7 @@ from __future__ import annotations +from support.workspace import managed_repo, managed_state, prepare_workspace + import json from pathlib import Path @@ -15,10 +17,14 @@ def test_public_cli_publish_then_approve_is_an_end_to_end_user_flow( git_repo: Path, ) -> None: - state, states, _git, _publisher = _accepted_run(git_repo) + workspace = prepare_workspace(git_repo) + state, states, _git, _publisher = _accepted_run( + workspace.repository_root, state_root=workspace.state_root + ) + fixture = workspace.repository_root / "github.json" seed_idle_control( TaskControlStore(states.root), - TaskKey(git_repo, str(state["repository"]), 1), + TaskKey(managed_repo(git_repo), str(state["repository"]), 1), str(state["run_id"]), state_dir=states.root, ) @@ -28,7 +34,7 @@ def test_public_cli_publish_then_approve_is_an_end_to_end_user_flow( published = run_internal_stage( git_repo, - git_repo / "github.json", + fixture, "publish-run", str(state["run_id"]), "--agent-fixture", @@ -38,12 +44,12 @@ def test_public_cli_publish_then_approve_is_an_end_to_end_user_flow( assert published.returncode == 0, published.stderr assert stdout_json(published)["status"] == "run_approval_pending" approved = run_cli( - git_repo, git_repo / "github.json", "approve", str(state["run_id"]) + git_repo, fixture, "approve", str(state["run_id"]) ) assert approved.returncode == 0, approved.stderr assert stdout_json(approved)["status"] == "completed" assert ( - FixtureGitHubPublisher(git_repo / "github.json", _git).live_pull_request(1)[ + FixtureGitHubPublisher(fixture, _git).live_pull_request(1)[ "state" ] == "MERGED" diff --git a/tests/test_run_repair_promotion.py b/tests/test_run_repair_promotion.py index 009a743..e9924aa 100644 --- a/tests/test_run_repair_promotion.py +++ b/tests/test_run_repair_promotion.py @@ -8,6 +8,8 @@ import pytest +from support.workspace import prepare_workspace + from agent_run.agents import DevelopmentResult, ReviewResult from agent_run.delivery_history import history_records from agent_run.github_fixture import FixtureGitHubPublisher, FixtureGitHubReader @@ -441,30 +443,32 @@ def review(self, request: dict[str, Any]) -> ReviewResult: def test_run_repair_default_drift_after_merge_revalidates_same_cycle( git_repo: Path, candidate_requires_more_repair: bool, thread_policy: str ) -> None: - state, states, git = _completed_run(git_repo) + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, git = _completed_run(repo, state_root=workspace.state_root) state.setdefault("policy_snapshot", {})["development_thread_policy"] = thread_policy states.save_run(str(state["run_id"]), state) successor_thread = ( "run-repair-successor" if thread_policy == "new-per-attempt" else "run-repair-developer" ) - fixture = git_repo / "github.json" + fixture = repo / "github.json" initial_default = git.resolve("main") class DefaultDriftingAfterMergePublisher(FixtureGitHubPublisher): def sync_run_branch(self, *, run_branch: str, integrated_sha: str) -> None: super().sync_run_branch(run_branch=run_branch, integrated_sha=integrated_sha) - (git_repo / "default-drift-after-merge.txt").write_text( + (repo / "default-drift-after-merge.txt").write_text( "advanced\n", encoding="utf-8" ) subprocess.run( ["git", "add", "default-drift-after-merge.txt"], - cwd=git_repo, + cwd=repo, check=True, ) subprocess.run( ["git", "commit", "-m", "advance default after repair merge"], - cwd=git_repo, + cwd=repo, check=True, capture_output=True, ) @@ -628,7 +632,7 @@ def develop(self, request: dict[str, Any]) -> DevelopmentResult: assert any(record.get("publication") for record in archived_records) history = invoke_cli_inprocess( - git_repo, fixture, "history", str(state["run_id"]), "--plain", "--details" + repo, fixture, "history", str(state["run_id"]), "--plain", "--details" ) assert history.returncode == 0, history.stderr assert "开发说明" in history.stdout @@ -645,8 +649,10 @@ def develop(self, request: dict[str, Any]) -> DevelopmentResult: def test_run_repair_required_check_default_drift_revalidates_same_cycle( git_repo: Path, ) -> None: - state, states, git = _completed_run(git_repo) - fixture = git_repo / "github.json" + workspace = prepare_workspace(git_repo) + repo = workspace.repository_root + state, states, git = _completed_run(repo, state_root=workspace.state_root) + fixture = repo / "github.json" publisher = FixtureGitHubPublisher(fixture, git) publisher.ensure_final_run_ref( branch=str(state["run_branch"]), @@ -690,17 +696,17 @@ def test_run_repair_required_check_default_drift_revalidates_same_cycle( class DefaultDriftingAfterMergePublisher(FixtureGitHubPublisher): def sync_run_branch(self, *, run_branch: str, integrated_sha: str) -> None: super().sync_run_branch(run_branch=run_branch, integrated_sha=integrated_sha) - (git_repo / "default-drift-after-required-check-repair.txt").write_text( + (repo / "default-drift-after-required-check-repair.txt").write_text( "advanced\n", encoding="utf-8" ) subprocess.run( ["git", "add", "default-drift-after-required-check-repair.txt"], - cwd=git_repo, + cwd=repo, check=True, ) subprocess.run( ["git", "commit", "-m", "advance default after required-check repair"], - cwd=git_repo, + cwd=repo, check=True, capture_output=True, ) @@ -711,14 +717,14 @@ def sync_run_branch(self, *, run_branch: str, integrated_sha: str) -> None: class StatusAssertingRepairAgents(ScriptedRunAgents): def develop(self, request: dict[str, Any]) -> DevelopmentResult: json_status = invoke_cli_inprocess( - git_repo, + repo, fixture, "status", str(state["run_id"]), "--json", ) text_status = invoke_cli_inprocess( - git_repo, + repo, fixture, "status", str(state["run_id"]), @@ -786,14 +792,14 @@ def develop(self, request: dict[str, Any]) -> DevelopmentResult: states.save_run(str(state["run_id"]), gated) gated_json_result = invoke_cli_inprocess( - git_repo, + repo, fixture, "status", str(state["run_id"]), "--json", ) gated_text = invoke_cli_inprocess( - git_repo, + repo, fixture, "status", str(state["run_id"]), @@ -861,9 +867,9 @@ def develop(self, request: dict[str, Any]) -> DevelopmentResult: assert len(second["run_acceptance"]["completed_repair_jobs"]) == 1 json_status = stdout_json( - invoke_cli_inprocess(git_repo, fixture, "status", str(state["run_id"]), "--json") + invoke_cli_inprocess(repo, fixture, "status", str(state["run_id"]), "--json") ) - text_status = invoke_cli_inprocess(git_repo, fixture, "status", str(state["run_id"])) + text_status = invoke_cli_inprocess(repo, fixture, "status", str(state["run_id"])) assert json_status["phase"] == "stale" assert json_status["progress"]["current_object"] == "Run Publication" assert "阶段: 已失效" in text_status.stdout diff --git a/tests/test_run_repair_state_contract.py b/tests/test_run_repair_state_contract.py index 1dbb904..30dfd71 100644 --- a/tests/test_run_repair_state_contract.py +++ b/tests/test_run_repair_state_contract.py @@ -9,6 +9,8 @@ import pytest +from support.workspace import managed_repo, managed_state + from agent_run.state_contract import ( IncompatibleRunStateError, require_current_run_state, @@ -72,13 +74,13 @@ def test_resume_rejects_noncanonical_run_repair_mode_before_mutation( "policy_snapshot": deepcopy(state["policy_snapshot"]), "repair_job": repair_job, } - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") state_before = deepcopy(state) fixture_before = fixture.read_text(encoding="utf-8") head_before = subprocess.run( ["git", "rev-parse", "HEAD"], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, @@ -93,7 +95,7 @@ def test_resume_rejects_noncanonical_run_repair_mode_before_mutation( assert fixture.read_text(encoding="utf-8") == fixture_before assert subprocess.run( ["git", "rev-parse", "HEAD"], - cwd=git_repo, + cwd=managed_repo(git_repo), text=True, capture_output=True, check=True, @@ -444,11 +446,11 @@ def test_resume_rejects_invalid_integrated_revalidation_merge_before_mutation( "integrated_revalidation_merge": marker, }, } - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_path.write_text(json.dumps(state), encoding="utf-8") state_before = deepcopy(state) fixture_before = fixture.read_text(encoding="utf-8") - refs_before = _git_refs(git_repo) + refs_before = _git_refs(managed_repo(git_repo)) result = run_cli(git_repo, fixture, "resume", run_id) @@ -457,7 +459,7 @@ def test_resume_rejects_invalid_integrated_revalidation_merge_before_mutation( assert stdout_json(result)["diagnostics"][0]["code"] == "incompatible_run_state" assert json.loads(state_path.read_text(encoding="utf-8")) == state_before assert fixture.read_text(encoding="utf-8") == fixture_before - assert _git_refs(git_repo) == refs_before + assert _git_refs(managed_repo(git_repo)) == refs_before def test_canonical_integrated_revalidation_merge_passes_state_validation( diff --git a/tests/test_runner_installer.py b/tests/test_runner_installer.py index 169744a..6a43bef 100644 --- a/tests/test_runner_installer.py +++ b/tests/test_runner_installer.py @@ -1595,10 +1595,13 @@ def test_public_quickstart_smoke_uses_login_shell_and_cleans_resources( seeded = seed_run( delivery, fixture, - extra_env={"XDG_STATE_HOME": str(home / "state")}, + extra_env={"XDG_DATA_HOME": str(home / "data")}, ) assert seeded.returncode == 0, seeded.stderr - target_state_before_uninstall = _file_tree(delivery / ".agent-run") + managed_root = _data_root(home) / "repositories" / "example" / "project" + target_state_before_uninstall = _file_tree(managed_root) + assert target_state_before_uninstall + assert list((managed_root / "state" / "runs").glob("*.json")) count_before_repeat = int(count.read_text(encoding="utf-8")) repeated = _run( @@ -1639,7 +1642,7 @@ def test_public_quickstart_smoke_uses_login_shell_and_cleans_resources( profile = profile_path.read_text(encoding="utf-8") assert "# >>> agent-run managed PATH >>>" not in profile assert "# <<< agent-run managed PATH <<<" not in profile - assert _file_tree(delivery / ".agent-run") == target_state_before_uninstall + assert _file_tree(managed_root) == target_state_before_uninstall assert not [marker for marker in markers if marker.exists()] assert not list(probe_tmp.iterdir()) @@ -2230,6 +2233,13 @@ def test_rollback_does_not_probe_and_uninstall_preserves_user_data( target = tmp_path / "target" (target / ".agent-run").mkdir(parents=True) (target / ".agent-run" / "run.json").write_text("keep", encoding="utf-8") + managed_workspace = _data_root(home) / "repositories" / "owner" / "project" + (managed_workspace / "repository").mkdir(parents=True) + (managed_workspace / "state").mkdir() + (managed_workspace / "repository" / "unfinished.txt").write_text("keep work") + (managed_workspace / "state" / "run.json").write_text("keep records") + managed_locator = _data_root(home) / "run-locator.json" + managed_locator.write_text("keep index") profile = home / ".profile" profile.write_text("before\n", encoding="utf-8") @@ -2250,6 +2260,9 @@ def test_rollback_does_not_probe_and_uninstall_preserves_user_data( assert (config / "github-app.json").read_bytes() == failed_config assert (locator / "run-locator.json").read_bytes() == failed_locator assert (target / ".agent-run" / "run.json").read_bytes() == failed_target + assert (managed_workspace / "repository" / "unfinished.txt").read_text() == "keep work" + assert (managed_workspace / "state" / "run.json").read_text() == "keep records" + assert managed_locator.read_text() == "keep index" assert profile.read_text(encoding="utf-8") == "before\n" uninstall = _run(source, home, fake_bin, "--uninstall") @@ -2257,6 +2270,9 @@ def test_rollback_does_not_probe_and_uninstall_preserves_user_data( assert (config / "github-app.json").read_text(encoding="utf-8") == "keep" assert (locator / "run-locator.json").read_text(encoding="utf-8") == "keep" assert (target / ".agent-run" / "run.json").read_text(encoding="utf-8") == "keep" + assert (managed_workspace / "repository" / "unfinished.txt").read_text() == "keep work" + assert (managed_workspace / "state" / "run.json").read_text() == "keep records" + assert managed_locator.read_text() == "keep index" assert (profile.read_text(encoding="utf-8") == "before\n") assert (_data_root(home) / "install.lock").exists() assert not (home / ".local" / "bin" / "agent-run").exists() @@ -2435,7 +2451,7 @@ def test_installed_runner_continues_a_delivery_run_and_does_not_write_incompatib cli_environment.update( { "HOME": str(home), - "XDG_STATE_HOME": str(home / "state"), + "XDG_DATA_HOME": str(home / "data"), "PATH": f"{home / '.local' / 'bin'}{os.pathsep}{cli_environment['PATH']}", } ) @@ -2443,12 +2459,13 @@ def test_installed_runner_continues_a_delivery_run_and_does_not_write_incompatib started = seed_run( delivery, fixture, - extra_env={"XDG_STATE_HOME": str(home / "state")}, + extra_env={"XDG_DATA_HOME": str(home / "data")}, ) assert started.returncode == 0, started.stderr started_output = json.loads(started.stdout) run_id = started_output["run_id"] - state_path = next((delivery / ".agent-run" / "runs").glob("*.json")) + state_root = _data_root(home) / "repositories" / "example" / "project" / "state" + state_path = state_root / "runs" / f"{run_id}.json" (source / "src" / "agent_run" / "__init__.py").write_text( "__version__ = 'second'\n", encoding="utf-8" @@ -2485,16 +2502,16 @@ def test_installed_runner_continues_a_delivery_run_and_does_not_write_incompatib incompatible["schema_version"] = 1 state_path.write_text(json.dumps(incompatible), encoding="utf-8") before = state_path.read_bytes() - locator_path = home / "state" / "agent-run" / "run-locator.json" + locator_path = _data_root(home) / "run-locator.json" locator_before = locator_path.read_bytes() target_files_before = { - path.relative_to(delivery / ".agent-run"): path.read_bytes() - for path in (delivery / ".agent-run").rglob("*") + path.relative_to(state_root): path.read_bytes() + for path in (state_root).rglob("*") if path.is_file() } target_paths_before = sorted( - path.relative_to(delivery / ".agent-run") - for path in (delivery / ".agent-run").rglob("*") + path.relative_to(state_root) + for path in (state_root).rglob("*") ) rejected = subprocess.run( [ @@ -2516,13 +2533,13 @@ def test_installed_runner_continues_a_delivery_run_and_does_not_write_incompatib assert state_path.read_bytes() == before assert locator_path.read_bytes() == locator_before assert { - path.relative_to(delivery / ".agent-run"): path.read_bytes() - for path in (delivery / ".agent-run").rglob("*") + path.relative_to(state_root): path.read_bytes() + for path in (state_root).rglob("*") if path.is_file() } == target_files_before assert sorted( - path.relative_to(delivery / ".agent-run") - for path in (delivery / ".agent-run").rglob("*") + path.relative_to(state_root) + for path in (state_root).rglob("*") ) == target_paths_before (source / "src" / "agent_run" / "__init__.py").write_text( @@ -2543,13 +2560,13 @@ def test_installed_runner_continues_a_delivery_run_and_does_not_write_incompatib assert state_path.read_bytes() == before assert locator_path.read_bytes() == locator_before assert { - path.relative_to(delivery / ".agent-run"): path.read_bytes() - for path in (delivery / ".agent-run").rglob("*") + path.relative_to(state_root): path.read_bytes() + for path in (state_root).rglob("*") if path.is_file() } == target_files_before assert sorted( - path.relative_to(delivery / ".agent-run") - for path in (delivery / ".agent-run").rglob("*") + path.relative_to(state_root) + for path in (state_root).rglob("*") ) == target_paths_before rolled_back_with_incompatible_state = _run(source, home, fake_bin, "--rollback") @@ -2567,11 +2584,11 @@ def test_installed_runner_continues_a_delivery_run_and_does_not_write_incompatib assert state_path.read_bytes() == before assert locator_path.read_bytes() == locator_before assert { - path.relative_to(delivery / ".agent-run"): path.read_bytes() - for path in (delivery / ".agent-run").rglob("*") + path.relative_to(state_root): path.read_bytes() + for path in (state_root).rglob("*") if path.is_file() } == target_files_before assert sorted( - path.relative_to(delivery / ".agent-run") - for path in (delivery / ".agent-run").rglob("*") + path.relative_to(state_root) + for path in (state_root).rglob("*") ) == target_paths_before diff --git a/tests/test_status_language.py b/tests/test_status_language.py index cc3ddcf..ee3d95f 100644 --- a/tests/test_status_language.py +++ b/tests/test_status_language.py @@ -5,12 +5,15 @@ from contextlib import redirect_stdout from io import StringIO from pathlib import Path +from typing import Any import pytest from rich.text import Text from agent_run import cli +from agent_run.run_locator import RunLocatorIndex from agent_run.state import StateStore +from support.workspace import prepare_workspace from test_run_lifecycle import _file_snapshot @@ -19,6 +22,19 @@ def isatty(self) -> bool: return True +def _save_run(repo: Path, state: dict[str, Any]) -> Path: + workspace = prepare_workspace(repo) + StateStore(workspace.state_root).save_run(state["run_id"], state) + RunLocatorIndex.default().register( + run_id=state["run_id"], + repository_root=workspace.repository_root, + state_dir=workspace.state_root, + repository=state["repository"], + parent_number=state["parent"]["number"], + ) + return workspace.state_root + + @pytest.mark.parametrize("mode, completed, status, acceptance, publication, expected", [ ("parent_only", 0, "active", None, None, "无子任务,直接推进需求"), (None, 0, "active", None, None, "无法确认进度"), @@ -56,8 +72,7 @@ def test_delivery_progress_preserves_audit( state["run_acceptance"] = {"phase": acceptance} if publication: state["run_publication"] = {"phase": publication} - root = git_repo / ".agent-run" - StateStore(root).save_run(state["run_id"], state) + root = _save_run(git_repo, state) before = _file_snapshot(root) monkeypatch.chdir(git_repo) monkeypatch.setenv("COLUMNS", "160") @@ -97,8 +112,7 @@ def test_completed_status_requires_cleanup_when_closeout_crashed_before_scheduli "phase": "merged", "approval_grant": {}, "parent_closed": True, }, "diagnostics": [], } - root = git_repo / ".agent-run" - StateStore(root).save_run(state["run_id"], state) + root = _save_run(git_repo, state) before = _file_snapshot(root) monkeypatch.chdir(git_repo) monkeypatch.setenv("COLUMNS", "160") @@ -148,8 +162,7 @@ def test_completed_status_keeps_work_facts_without_internal_labels( }], "delivery_cleanup": {"status": "completed", "items": {}}, "diagnostics": [], } - root = git_repo / ".agent-run" - StateStore(root).save_run(state["run_id"], state) + root = _save_run(git_repo, state) before = _file_snapshot(root) monkeypatch.chdir(git_repo) monkeypatch.setenv("COLUMNS", "160") @@ -200,8 +213,7 @@ def test_status_qualifies_manual_abandon_guidance_without_changing_json( "run_id": "run-action-language", "schema_version": 1, "repository": "example/project", "parent": {"number": 1}, "status": status, "diagnostics": [], } - root = git_repo / ".agent-run" - StateStore(root).save_run(state["run_id"], state) + root = _save_run(git_repo, state) before = _file_snapshot(root) monkeypatch.chdir(git_repo) output = StringIO() @@ -241,8 +253,7 @@ def test_repair_blocker_shows_action_and_work_facts_without_control_identifiers( "started_at": "2026-09-12T15:20:00+00:00", "ended_at": "2026-09-12T15:21:00+00:00", }], } - root = git_repo / ".agent-run" - StateStore(root).save_run(state["run_id"], state) + root = _save_run(git_repo, state) before = _file_snapshot(root) monkeypatch.chdir(git_repo) monkeypatch.setenv("TERM", "xterm-256color") diff --git a/tests/test_status_waiting.py b/tests/test_status_waiting.py index d6d9649..c781de5 100644 --- a/tests/test_status_waiting.py +++ b/tests/test_status_waiting.py @@ -9,9 +9,12 @@ import pytest from rich.text import Text +from support.workspace import managed_repo, managed_state, prepare_workspace + from agent_run import cli, external_supervision from agent_run.executor_host import HostObservation from agent_run.run_lifecycle import prepare_action_application_receipt +from agent_run.run_locator import RunLocatorIndex from agent_run.semantic_attempt import allocate_semantic_attempt from agent_run.state import StateStore from agent_run.task_control import TaskControlStore, TaskKey @@ -21,6 +24,10 @@ from support.published_run import prepare_published_run +def _workspace_snapshot(repo: Path) -> tuple[dict[str, bytes], dict[str, bytes]]: + return _file_snapshot(repo), _file_snapshot(managed_repo(repo).parent) + + class TerminalOutput(StringIO): def isatty(self) -> bool: return True @@ -37,7 +44,14 @@ def waiting_run( # startup remain covered by the public run/lifecycle integration tests. _, state = prepare_published_run(git_repo) run_id = state["run_id"] - path = git_repo / ".agent-run" / "runs" / f"{run_id}.json" + RunLocatorIndex.default().register( + run_id=run_id, + repository_root=managed_repo(git_repo), + state_dir=managed_state(git_repo), + repository=state["repository"], + parent_number=state["parent"]["number"], + ) + path = managed_state(git_repo) / "runs" / f"{run_id}.json" publication = state["run_publication"] publication["phase"] = "waiting_checks" publication["required_checks_evidence"] = { @@ -49,8 +63,8 @@ def waiting_run( state.update(status="waiting_checks", terminal_kind="waiting_external", diagnostics=[]) monkeypatch.setattr(external_supervision, "monotonic", lambda: 1_000_000.0) external_supervision.ensure_supervision_window(state, now=lambda: 999_760.0) - control = TaskControlStore(git_repo / ".agent-run") - task = TaskKey(git_repo, state["repository"], state["parent"]["number"]) + control = TaskControlStore(managed_state(git_repo)) + task = TaskKey(managed_repo(git_repo), state["repository"], state["parent"]["number"]) claim = control.claim_action(task, kind="run", payload={"parent": 1}) assert claim.action is not None and claim.action_id is not None control.bind_run(task, claim.action_id, run_id) @@ -84,7 +98,8 @@ def waiting_run( def status_text(run_id: str, *, plain: bool) -> str: output = TerminalOutput() with redirect_stdout(output): - assert cli.main(["status", run_id, *(["--plain"] if plain else [])]) == 0 + result = cli.main(["status", run_id, *(["--plain"] if plain else [])]) + assert result == 0, output.getvalue() return Text.from_ansi(output.getvalue()).plain @@ -111,9 +126,9 @@ def test_running_agent_status_does_not_offer_a_duplicate_run_command( "status": "running", "started_at": state["created_at"], "model": "gpt-5.6-luna", "reasoning_effort": "xhigh", } - path = repo / ".agent-run" / "runs" / f"{state['run_id']}.json" + path = managed_state(repo) / "runs" / f"{state['run_id']}.json" path.write_text(json.dumps(state)) - before = _file_snapshot(repo) + before = _workspace_snapshot(repo) human = status_text(state["run_id"], plain=plain) assert "你暂时无需操作" in human assert "gpt-5.6-luna" in human and "xhigh" in human @@ -125,14 +140,14 @@ def test_running_agent_status_does_not_offer_a_duplicate_run_command( assert audit["progress"]["execution_activity"] == "running" assert audit["operator_action"] is None assert audit["next_action"] == "agent-run run 1" - assert _file_snapshot(repo) == before + assert _workspace_snapshot(repo) == before def test_waiting_status_shows_current_checks_without_restart_command( waiting_run: tuple[Path, dict[str, Any]], ) -> None: repo, state = waiting_run - before = _file_snapshot(repo) + before = _workspace_snapshot(repo) before_user_state = _file_snapshot(Path.home().parent) for plain in (True, False): output = status_text(state["run_id"], plain=plain) @@ -155,7 +170,7 @@ def test_waiting_status_shows_current_checks_without_restart_command( assert audit["supervision"]["remaining_seconds"] == 2460 assert audit["progress"]["current_agent"]["is_active"] is False assert audit["executor_control"]["activity"] == "running" - assert _file_snapshot(repo) == before + assert _workspace_snapshot(repo) == before assert _file_snapshot(Path.home().parent) == before_user_state @@ -164,14 +179,14 @@ def test_waiting_status_does_not_promise_an_exited_or_unknown_runner_will_contin waiting_run: tuple[Path, dict[str, Any]], host: str, ) -> None: repo, state = waiting_run - control_path = next((repo / ".agent-run/task-control").glob("*.json")) + control_path = next((managed_state(repo) / "task-control").glob("*.json")) if host == "unknown": control_path.unlink() else: control = json.loads(control_path.read_text()) control["executor"].update(status="exited", pid=None) control_path.write_text(json.dumps(control)) - before = _file_snapshot(repo) + before = _workspace_snapshot(repo) for plain in (True, False): output = status_text(state["run_id"], plain=plain) assert "无需操作" not in output @@ -191,7 +206,7 @@ def test_waiting_status_does_not_promise_an_exited_or_unknown_runner_will_contin assert "agent-run run" not in output assert "agent-run resume" not in output assert "最近 Agent" not in output - assert _file_snapshot(repo) == before + assert _workspace_snapshot(repo) == before def test_timed_out_wait_shows_human_time_and_current_recovery_action( @@ -202,15 +217,15 @@ def test_timed_out_wait_shows_human_time_and_current_recovery_action( now=lambda: 1_010_000.0, sleeper=lambda _: pytest.fail("expired wait must not sleep"), ) assert supervisor.before_retry(state) is False - (repo / ".agent-run/runs" / f"{state['run_id']}.json").write_text(json.dumps(state)) - before = _file_snapshot(repo) + (managed_state(repo) / "runs" / f"{state['run_id']}.json").write_text(json.dumps(state)) + before = _workspace_snapshot(repo) for plain in (True, False): output = status_text(state["run_id"], plain=plain) assert "已超时" in output assert "agent-run resume 1 --repo example/project" in output assert "截止=" not in output assert "最近 Agent" not in output - assert _file_snapshot(repo) == before + assert _workspace_snapshot(repo) == before def test_unconfigured_checks_are_not_shown_as_a_successful_ci_run( @@ -218,7 +233,7 @@ def test_unconfigured_checks_are_not_shown_as_a_successful_ci_run( ) -> None: repo, state = waiting_run state["run_publication"]["required_checks_evidence"].update(result="none", checks=[]) - (repo / ".agent-run/runs" / f"{state['run_id']}.json").write_text(json.dumps(state)) + (managed_state(repo) / "runs" / f"{state['run_id']}.json").write_text(json.dumps(state)) for plain in (True, False): output = status_text(state["run_id"], plain=plain) assert "未配置合并前检查" in output @@ -238,9 +253,9 @@ def test_credential_wait_explains_failure_without_raw_failure_class( now=lambda: 1_010_000.0, sleeper=lambda _: pytest.fail("expired wait must not sleep"), ) assert supervisor.before_retry(state) is False - path = repo / ".agent-run/runs" / f"{state['run_id']}.json" + path = managed_state(repo) / "runs" / f"{state['run_id']}.json" path.write_text(json.dumps(state)) - before = _file_snapshot(repo) + before = _workspace_snapshot(repo) for plain in (True, False): output = status_text(state["run_id"], plain=plain) assert "暂时无法取得 GitHub 工作凭据" in output @@ -248,7 +263,7 @@ def test_credential_wait_explains_failure_without_raw_failure_class( assert "credential_unavailable" not in output assert "凭据 HTTP 状态" not in output assert "agent-run resume 1 --repo example/project" in output - assert _file_snapshot(repo) == before + assert _workspace_snapshot(repo) == before def test_waiting_status_does_not_invent_time_after_clock_discontinuity( @@ -266,7 +281,8 @@ def test_completed_cleanup_pending_never_says_no_action_needed( git_repo: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) - StateStore(git_repo / ".agent-run").save_run("run-1", { + prepare_workspace(git_repo) + StateStore(managed_state(git_repo)).save_run("run-1", { "schema_version": 1, "run_id": "run-1", "status": "completed", "repository": "example/project", "parent": {"number": 1}, "diagnostics": [], "delivery_cleanup": { @@ -278,7 +294,7 @@ def test_completed_cleanup_pending_never_says_no_action_needed( }}, }, }) - before = _file_snapshot(git_repo) + before = _workspace_snapshot(git_repo) for command in ("status", "history"): result = invoke_cli_inprocess(git_repo, fixture, command, "run-1", "--plain") assert result.returncode == 0, result.stderr @@ -294,4 +310,4 @@ def test_completed_cleanup_pending_never_says_no_action_needed( assert cli.main(["status", "run-1", "--github-fixture", str(fixture)]) == 0 assert "提交已变化" in Text.from_ansi(output.getvalue()).plain assert "/preserved/worktree" in Text.from_ansi(output.getvalue()).plain - assert _file_snapshot(git_repo) == before + assert _workspace_snapshot(git_repo) == before diff --git a/tests/test_systemd_completed_action_exit.py b/tests/test_systemd_completed_action_exit.py index 8c2c825..db556ff 100644 --- a/tests/test_systemd_completed_action_exit.py +++ b/tests/test_systemd_completed_action_exit.py @@ -10,6 +10,8 @@ import pytest +from support.workspace import managed_repo, managed_state + from agent_run.executor_host import ExecutorSpec, ExecutorStartUnknownError from agent_run.cli_presentation import human_next_action_for_state from agent_run.git import GitRepository @@ -38,7 +40,7 @@ def completed_action( monkeypatch.setenv(name, value) fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) state = states.find_unfinished_runs("example/project", 1)[0] assert state["status"] == "parent_delivery_pending" run_id = state["run_id"] @@ -62,11 +64,11 @@ def completed_action( "review_budget_history": [], } checkout = states.root / "worktrees" / run_id / "parent" - GitRepository(git_repo).prepare_ticket_checkout( + GitRepository(managed_repo(git_repo)).prepare_ticket_checkout( branch=state["parent_branch"], base_sha=state["base"]["sha"], checkout=checkout, ) (checkout / "partial-work.txt").write_text("preserve the unfinished work\n") - task = TaskKey(git_repo, "example/project", 1) + task = TaskKey(managed_repo(git_repo), "example/project", 1) control = TaskControlStore(states.root) claim = control.claim_action(task, kind="run", payload={"parent": 1}) assert claim.action_id is not None @@ -76,7 +78,7 @@ def completed_action( spec = ExecutorSpec( task=task, run_id=run_id, action_id=claim.action_id, generation=record["action"]["executor_generation"], state_root=states.root, - command=("run", "1"), cwd=git_repo, + command=("run", "1"), cwd=managed_repo(git_repo), ) transport = FakeSystemdTransport() host = SystemdUserExecutorHost( @@ -132,14 +134,14 @@ def forbidden(*_args: object, **_kwargs: object) -> Any: @pytest.mark.parametrize("native_status", ["exited", "absent"]) def test_systemd_completed_action_exit_closes_only_its_executor( - completed_action: tuple, native_status: str, + completed_action: tuple, native_status: str, git_repo: Path, ) -> None: states, control, spec, host, transport = completed_action initial = control.load(spec.task) before = states.load_current_run(spec.run_id) assert initial is not None and before is not None - worktrees = _file_snapshot(spec.task.workspace / ".agent-run" / "worktrees") - fixture = spec.task.workspace / "github.json" + worktrees = _file_snapshot(states.root / "worktrees") + fixture = git_repo / "github.json" fixture_before = fixture.read_bytes() transport.unit = replace( transport.unit, status=native_status, pid=None, @@ -167,7 +169,7 @@ def test_systemd_completed_action_exit_closes_only_its_executor( assert again == result assert control.path_for(spec.task).read_bytes() == control_before assert _file_snapshot(states.root / "runs") == states_before - assert _file_snapshot(spec.task.workspace / ".agent-run" / "worktrees") == worktrees + assert _file_snapshot(states.root / "worktrees") == worktrees assert fixture.read_bytes() == fixture_before assert transport.start_count == 1 assert not list(host.runtime_directory.glob("*.json")) diff --git a/tests/test_test_environment.py b/tests/test_test_environment.py index 7a6d41c..360d84f 100644 --- a/tests/test_test_environment.py +++ b/tests/test_test_environment.py @@ -62,8 +62,8 @@ def test_case(git_repo, tmp_path, monkeypatch, case): inherited = subprocess.run(["git", "config", "--get", "isolation.inherited"], cwd=git_repo) assert inherited.returncode == 1 private_config = Path(os.environ["HOME"]) / ".gitconfig" - assert not private_config.exists() - private_config.write_text(f"[isolation]\\n private = {case}\\n") + assert subprocess.check_output(["git", "config", "--global", "user.name"], text=True).strip() == "Agent Run Tests" + private_config.write_text(private_config.read_text() + f"[isolation]\\n private = {case}\\n") assert subprocess.check_output(["git", "config", "isolation.private"], cwd=git_repo, text=True).strip() == case with monkeypatch.context() as explicit: explicit.setenv("GIT_CONFIG_COUNT", "1") @@ -95,7 +95,7 @@ def test_case(git_repo, tmp_path, monkeypatch, case): directory = host / key.lower() directory.mkdir(parents=True) environment[key] = str(directory) - locator = Path(environment["XDG_STATE_HOME"]) / "agent-run/run-locator.json" + locator = Path(environment["XDG_DATA_HOME"]) / "agent-run/run-locator.json" locator.parent.mkdir() locator.write_text("host locator must not be read or overwritten", encoding="utf-8") host_bin = host / "bin" diff --git a/tests/test_ticket_190_repair.py b/tests/test_ticket_190_repair.py index 6f4db14..b619895 100644 --- a/tests/test_ticket_190_repair.py +++ b/tests/test_ticket_190_repair.py @@ -20,6 +20,7 @@ ) from cli_run_supervision_support import _parent_only_agents from conftest import write_fixture +from support.workspace import managed_workspace from test_cli_delivery import ticket @@ -52,6 +53,7 @@ def _write_competition_sitecustomize(path: Path) -> None: from pathlib import Path import time +from agent_run.managed_workspace import ManagedWorkspace from agent_run.task_control import ( TaskControlBusyError, TaskControlStore, @@ -123,8 +125,9 @@ def hold_once(): TaskControlStore._locked = held_lock def hold_same_parent_transaction(): - store = TaskControlStore(Path.cwd() / ".agent-run") - task = TaskKey(Path.cwd(), "example/project", 1) + workspace = ManagedWorkspace.for_repository("example/project") + store = TaskControlStore(workspace.state_root) + task = TaskKey(workspace.repository_root, "example/project", 1) deadline = time.monotonic() + 10 while True: record = store.load(task) @@ -168,6 +171,7 @@ def test_fixture_executor_keeps_reserved_generation_after_replacement( agents.write_text(json.dumps(data), encoding="utf-8") environment = _isolated_environment(tmp_path / "generation") environment["PYTHONPATH"] = str(Path(__file__).resolve().parents[1] / "src") + workspace = managed_workspace(git_repo, extra_env=environment) command = [ sys.executable, "-m", @@ -189,23 +193,26 @@ def test_fixture_executor_keeps_reserved_generation_after_replacement( ) try: _wait_for_marker(process, started) - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) - control = TaskControlStore(git_repo / ".agent-run") - task = TaskKey(git_repo, "example/project", 1) - control_path = next((git_repo / ".agent-run" / "task-control").glob("*.json")) + state_path = next((workspace.state_root / "runs").glob("*.json")) + control = TaskControlStore(workspace.state_root) + task = TaskKey(workspace.repository_root, "example/project", 1) + control_path = next((workspace.state_root / "task-control").glob("*.json")) record = json.loads(control_path.read_text(encoding="utf-8")) action_id = record["action"]["action_id"] generation = record["executor"]["generation"] state_before_release = state_path.read_bytes() fixture_before_release = fixture.read_bytes() agents_before_release = agents.read_bytes() - git_status_before_release = subprocess.run( - ["git", "status", "--short"], - cwd=git_repo, - text=True, - capture_output=True, - check=True, - ).stdout + git_status_before_release = { + repo: subprocess.run( + ["git", "status", "--short"], + cwd=repo, + text=True, + capture_output=True, + check=True, + ).stdout + for repo in (git_repo, workspace.repository_root) + } control.mark_executor_absent( task, action_id=action_id, generation=generation @@ -230,16 +237,16 @@ def test_fixture_executor_keeps_reserved_generation_after_replacement( assert state_path.read_bytes() == state_before_release assert fixture.read_bytes() == fixture_before_release assert agents.read_bytes() == agents_before_release - assert ( - subprocess.run( + assert { + repo: subprocess.run( ["git", "status", "--short"], - cwd=git_repo, + cwd=repo, text=True, capture_output=True, check=True, ).stdout - == git_status_before_release - ) + for repo in git_status_before_release + } == git_status_before_release current = control.load(task) assert current is not None assert current["executor"]["generation"] == successor.generation @@ -291,6 +298,7 @@ def test_real_cli_retries_state_commit_during_same_parent_contention( base_environment["PYTHONPATH"] = ( f"{sitecustomize}{os.pathsep}{source_path}" ) + workspace = managed_workspace(git_repo, extra_env=base_environment) command = [ sys.executable, "-m", @@ -351,7 +359,7 @@ def test_real_cli_retries_state_commit_during_same_parent_contention( ) _wait_for_marker(second, holder) control_path = next( - (git_repo / ".agent-run" / "task-control").glob("*.json") + (workspace.state_root / "task-control").glob("*.json") ) record_before = json.loads(control_path.read_text(encoding="utf-8")) executor_before = record_before["executor"] @@ -365,9 +373,9 @@ def test_real_cli_retries_state_commit_during_same_parent_contention( assert second.returncode == 0, f"{second_stdout}\n{second_stderr}" assert not holder_error.exists() - run_files = list((git_repo / ".agent-run" / "runs").glob("*.json")) + run_files = list((workspace.state_root / "runs").glob("*.json")) control_files = list( - (git_repo / ".agent-run" / "task-control").glob("*.json") + (workspace.state_root / "task-control").glob("*.json") ) assert len(run_files) == 1 assert len(control_files) == 1 diff --git a/tests/test_ticket_191_operator_gates.py b/tests/test_ticket_191_operator_gates.py index 5111205..4dcf576 100644 --- a/tests/test_ticket_191_operator_gates.py +++ b/tests/test_ticket_191_operator_gates.py @@ -7,6 +7,8 @@ import pytest +from support.workspace import managed_repo, managed_state, prepare_workspace + import agent_run.cli as cli_module from agent_run.git import GitRepository from agent_run.github_fixture import FixtureGitHubReader @@ -75,7 +77,7 @@ def test_ordinary_run_preserves_every_non_parent_review_budget_subject( subject_location: str, blocked_reason: str, ) -> None: - state, states, _git = _completed_run(git_repo) + state, states, _git = _completed_run(prepare_workspace(git_repo).repository_root, state_root=managed_state(git_repo)) budget = _canonical_run_budget() budget["checkpoint_reason"] = blocked_reason subject = { @@ -126,9 +128,9 @@ def test_ordinary_run_preserves_every_non_parent_review_budget_subject( state["run_acceptance"] = acceptance state.update({"status": "blocked", "terminal_kind": "waiting_human"}) states.save_run(str(state["run_id"]), state) - state_root = git_repo / ".agent-run" + state_root = managed_state(git_repo) state_before = _tree_snapshot(state_root) - fixture = git_repo / "github.json" + fixture = managed_repo(git_repo) / "github.json" fixture_before = fixture.read_bytes() # 每组参数都必须只读,复用边界前置流程并逐组核对同一快照。 @@ -161,7 +163,7 @@ def test_ordinary_run_preserves_local_gate_during_supervision_timeout( capsys: pytest.CaptureFixture[str], subject_location: str, ) -> None: - state, states, _git = _completed_run(git_repo) + state, states, _git = _completed_run(prepare_workspace(git_repo).repository_root, state_root=managed_state(git_repo)) subject = { "phase": "blocked", "blocked_reason": "review_budget_exhausted", @@ -228,9 +230,9 @@ def test_ordinary_run_preserves_local_gate_during_supervision_timeout( {"status": "supervision_timeout", "terminal_kind": "supervision_timeout"} ) states.save_run(str(state["run_id"]), state) - state_root = git_repo / ".agent-run" + state_root = managed_state(git_repo) state_before = _tree_snapshot(state_root) - fixture = git_repo / "github.json" + fixture = managed_repo(git_repo) / "github.json" fixture_before = fixture.read_bytes() # 每组参数都必须只读,复用边界前置流程并逐组核对同一快照。 @@ -267,22 +269,21 @@ def test_ordinary_run_preserves_local_gate_during_supervision_timeout( raise -def test_production_run_locates_custom_state_gate_before_readiness( +def test_production_run_locates_configured_data_root_gate_before_readiness( git_repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) - state_root = tmp_path / "custom-state" - state_home = tmp_path / "state-home" + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "custom-data")) + state_root = managed_state(git_repo) started = seed_run( git_repo, fixture, "1", "--state-dir", str(state_root), - extra_env={"XDG_STATE_HOME": str(state_home)}, ) state = StateStore(state_root).load_run(str(stdout_json(started)["run_id"])) assert state is not None @@ -305,7 +306,6 @@ def test_production_run_locates_custom_state_gate_before_readiness( (), expected_status="supervision_timeout", state_root=state_root, - state_home=state_home, ) assert not (git_repo / ".agent-run").exists() @@ -377,7 +377,7 @@ def _assert_run_preserves_boundary( expected_status: str, run_arguments: tuple[str, ...], ) -> None: - state_root = git_repo / ".agent-run" + state_root = managed_state(git_repo) state_path = next((state_root / "runs").glob("*.json")) control_path = next((state_root / "task-control").glob("*.json")) state_before = state_path.read_bytes() @@ -448,7 +448,7 @@ def test_ordinary_run_preserves_parent_budget_checkpoint( if blocked_reason == "modification_budget_exhausted": state["parent_job"]["blocked_reason"] = blocked_reason state["parent_job"]["review_budget"]["checkpoint_reason"] = blocked_reason - StateStore(git_repo / ".agent-run").save_run(str(state["run_id"]), state) + StateStore(managed_state(git_repo)).save_run(str(state["run_id"]), state) # 每组参数都必须只读,复用边界前置流程并逐组核对同一快照。 for run_arguments in ( @@ -483,7 +483,7 @@ def test_approval_refresh_exhaustion_does_not_create_a_successor_action( for attempt in range(MAX_READ_ATTEMPTS + 1) ] fixture.write_text(json.dumps(fixture_data), encoding="utf-8") - state_root = git_repo / ".agent-run" + state_root = managed_state(git_repo) state_path = next((state_root / "runs").glob("*.json")) control_path = next((state_root / "task-control").glob("*.json")) state_before = state_path.read_bytes() @@ -554,7 +554,7 @@ def test_ordinary_run_advances_normal_run_publication_pending( assert advanced.returncode == 0, advanced.stderr assert stdout_json(advanced)["status"] == "run_approval_pending" control_path = next( - (git_repo / ".agent-run" / "task-control").glob("*.json") + (managed_state(git_repo) / "task-control").glob("*.json") ) control = json.loads(control_path.read_bytes()) assert control["action"]["kind"] == "run" @@ -579,14 +579,14 @@ def test_progressing_run_reports_execution_readiness_when_unavailable( if current_status == "run_publication_pending": fixture, _agents = _establish_run_publication_pending(git_repo) else: - state, states, _git = _completed_run(git_repo) + state, states, _git = _completed_run(prepare_workspace(git_repo).repository_root, state_root=managed_state(git_repo)) state.update( {"status": "supervision_timeout", "terminal_kind": "supervision_timeout"} ) states.save_run(str(state["run_id"]), state) - fixture = git_repo / "github.json" + fixture = managed_repo(git_repo) / "github.json" - state_root = git_repo / ".agent-run" + state_root = managed_state(git_repo) state_tree_before = _tree_snapshot(state_root) fixture_before = fixture.read_bytes() environment_carrier = git_repo / "runtime" / "environment-carrier.json" @@ -615,7 +615,7 @@ def active_runner_available() -> bool: return readiness_failure == "systemd-unavailable" monkeypatch.chdir(git_repo) - monkeypatch.setenv("XDG_DATA_HOME", str(git_repo / "runner-data")) + monkeypatch.setenv("XDG_RUNTIME_DIR", str(git_repo / "runtime")) monkeypatch.setattr(cli_module, "runner_usage_lease", observed_runner_lease) monkeypatch.setattr(cli_module, "_running_active_runner", active_runner_available) @@ -627,6 +627,7 @@ def active_runner_available() -> bool: "GhGitHubReader", lambda _repo, *, working_directory: FixtureGitHubReader(fixture), ) + monkeypatch.setattr("agent_run.workspace_cli.GhGitHubReader", lambda _repo=None, *, working_directory: FixtureGitHubReader(fixture)) return_code = cli_module.main( ["run", "1", "--repo", "example/project", "--json"] @@ -655,7 +656,7 @@ def test_ordinary_run_preserves_requeue_required_boundary( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={"3": ticket()}) run_id = stdout_json(seed_run(git_repo, fixture, "1", idle_control=True))["run_id"] - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) state = states.load_run(run_id) assert state is not None job = state["active_ticket_job"] @@ -675,7 +676,7 @@ def test_ordinary_run_preserves_requeue_required_boundary( }, "review_budget_history": [], "effective_revision": "stale-revision", - "base_sha": GitRepository(git_repo).resolve(str(state["run_branch"])), + "base_sha": GitRepository(managed_repo(git_repo)).resolve(str(state["run_branch"])), } ) state["ticket_jobs"] = {"3": job} diff --git a/tests/test_ticket_191_resume.py b/tests/test_ticket_191_resume.py index 0c454ba..92b2ab7 100644 --- a/tests/test_ticket_191_resume.py +++ b/tests/test_ticket_191_resume.py @@ -10,6 +10,8 @@ import pytest +from support.workspace import managed_repo, managed_state + import agent_run.cli as cli_module from agent_run.agent_invocation import record_operator_stop from agent_run.delivery_policy import DeliveryPolicyStore @@ -166,8 +168,8 @@ def _wait_for_run_status(git_repo: Path, expected: str) -> dict[str, object]: def _assert_single_resume_action(git_repo: Path) -> None: - record = TaskControlStore(git_repo / ".agent-run").load( - TaskKey(git_repo, "example/project", 1) + record = TaskControlStore(managed_state(git_repo)).load( + TaskKey(managed_repo(git_repo), "example/project", 1) ) assert record is not None actions = [record.get("action"), *record.get("action_history", [])] @@ -503,7 +505,7 @@ def _establish_ordinary_run_boundary( _block_parent_for_human(git_repo, fixture) state = load_only_run_state(git_repo) state.update({"status": "blocked", "terminal_kind": "waiting_human"}) - StateStore(git_repo / ".agent-run").save_run(str(state["run_id"]), state) + StateStore(managed_state(git_repo)).save_run(str(state["run_id"]), state) elif boundary == "permanent_blocked": blocked = run_cli(git_repo, fixture, "run", "1") assert blocked.returncode == 2, blocked.stderr @@ -528,7 +530,7 @@ def _establish_ordinary_run_boundary( state.update( {"status": "publication_pending", "terminal_kind": "publication_pending"} ) - StateStore(git_repo / ".agent-run").save_run(str(state["run_id"]), state) + StateStore(managed_state(git_repo)).save_run(str(state["run_id"]), state) elif boundary == "unsupported_scope_change": started = run_cli( git_repo, @@ -573,7 +575,7 @@ def _establish_ordinary_run_boundary( {"status": "waiting_external", "terminal_kind": "waiting_external"} ) state["run_publication"]["phase"] = "waiting_external" - StateStore(git_repo / ".agent-run").save_run(str(state["run_id"]), state) + StateStore(managed_state(git_repo)).save_run(str(state["run_id"]), state) blocked = run_cli( git_repo, fixture, @@ -597,13 +599,13 @@ def _establish_ordinary_run_boundary( state.update( {"status": "abandonment_pending", "terminal_kind": "abandonment_pending"} ) - StateStore(git_repo / ".agent-run").save_run(str(state["run_id"]), state) + StateStore(managed_state(git_repo)).save_run(str(state["run_id"]), state) elif boundary == "progress_exhausted": blocked = run_cli(git_repo, fixture, "run", "1") assert blocked.returncode == 2, blocked.stderr state = load_only_run_state(git_repo) state["terminal_kind"] = "waiting_human" - StateStore(git_repo / ".agent-run").save_run(str(state["run_id"]), state) + StateStore(managed_state(git_repo)).save_run(str(state["run_id"]), state) else: agents = ( run_agents(git_repo / "initial-agents.json") @@ -654,7 +656,7 @@ def _establish_ordinary_run_boundary( state = load_only_run_state(git_repo) record_operator_stop( state, - save=lambda stopped: StateStore(git_repo / ".agent-run").save_run( + save=lambda stopped: StateStore(managed_state(git_repo)).save_run( str(stopped["run_id"]), stopped ), ) @@ -681,7 +683,7 @@ def _assert_production_run_does_not_prepare_executor( *, expected_return_code: int, ) -> None: - state_root = git_repo / ".agent-run" + state_root = managed_state(git_repo) state_tree_before = _tree_snapshot(state_root) fixture_before = fixture.read_bytes() environment_carrier = git_repo / "runtime" / "environment-carrier.json" @@ -705,7 +707,7 @@ def prepare_environment(self, _command: object) -> None: environment_carrier.write_text("unexpected\n", encoding="utf-8") monkeypatch.chdir(git_repo) - monkeypatch.setenv("XDG_DATA_HOME", str(git_repo / "runner-data")) + monkeypatch.setenv("XDG_RUNTIME_DIR", str(git_repo / "runtime")) monkeypatch.setattr(cli_module, "_running_active_runner", lambda: True) monkeypatch.setattr( @@ -719,6 +721,7 @@ def prepare_environment(self, _command: object) -> None: "GhGitHubReader", lambda _repo, *, working_directory: FixtureGitHubReader(fixture), ) + monkeypatch.setattr("agent_run.workspace_cli.GhGitHubReader", lambda _repo=None, *, working_directory: FixtureGitHubReader(fixture)) return_code = cli_module.main( ["run", "1", "--repo", "example/project", *run_arguments, "--json"] @@ -741,9 +744,8 @@ def _assert_production_run_rejects_before_readiness( *, expected_status: str, state_root: Path | None = None, - state_home: Path | None = None, ) -> None: - state_root = state_root or git_repo / ".agent-run" + state_root = state_root or managed_state(git_repo) state_tree_before = _tree_snapshot(state_root) fixture_before = fixture.read_bytes() environment_carrier = git_repo / "runtime" / "environment-carrier.json" @@ -776,10 +778,8 @@ def active_runner_available() -> bool: with monkeypatch.context() as readiness: readiness.chdir(git_repo) - readiness.setenv("XDG_DATA_HOME", str(git_repo / "runner-data")) + readiness.setenv("XDG_RUNTIME_DIR", str(git_repo / "runtime")) - if state_home is not None: - readiness.setenv("XDG_STATE_HOME", str(state_home)) readiness.setattr(cli_module, "runner_usage_lease", observed_runner_lease) readiness.setattr( cli_module, "_running_active_runner", active_runner_available @@ -792,6 +792,7 @@ def active_runner_available() -> bool: "GhGitHubReader", lambda _repo, *, working_directory: FixtureGitHubReader(fixture), ) + readiness.setattr("agent_run.workspace_cli.GhGitHubReader", lambda _repo=None, *, working_directory: FixtureGitHubReader(fixture)) return_code = cli_module.main( ["run", "1", "--repo", "example/project", *run_arguments, "--json"] @@ -826,8 +827,8 @@ def test_resume_reconciles_a_terminal_receipt_before_admitting_its_successor( state_before = load_only_run_state(git_repo) receipt_before = state_before["action_application_receipt"] assert isinstance(receipt_before, dict) - control_store = TaskControlStore(git_repo / ".agent-run") - task = TaskKey(git_repo, "example/project", 1) + control_store = TaskControlStore(managed_state(git_repo)) + task = TaskKey(managed_repo(git_repo), "example/project", 1) control_before = control_store.load(task) assert control_before is not None old_action = control_before["action"] @@ -877,7 +878,7 @@ def observe( host = ReconciliationHost() monkeypatch.chdir(git_repo) - monkeypatch.setenv("XDG_DATA_HOME", str(git_repo / "runner-data")) + monkeypatch.setenv("XDG_RUNTIME_DIR", str(git_repo / "runtime")) monkeypatch.setattr( cli_module, "runner_usage_lease", lambda _path: nullcontext(UsageLease()) @@ -891,6 +892,7 @@ def observe( "GhGitHubReader", lambda _repo, *, working_directory: FixtureGitHubReader(fixture), ) + monkeypatch.setattr("agent_run.workspace_cli.GhGitHubReader", lambda _repo=None, *, working_directory: FixtureGitHubReader(fixture)) monkeypatch.setattr( cli_module, "GhGitHubPublisher", @@ -906,7 +908,7 @@ def observe( str(_resumed_parent_agents(git_repo)), "--json", ] - state_path = next((git_repo / ".agent-run" / "runs").glob("*.json")) + state_path = next((managed_state(git_repo) / "runs").glob("*.json")) state_bytes = state_path.read_bytes() fixture_bytes = fixture.read_bytes() return_code = cli_module.main(command) @@ -1022,7 +1024,7 @@ def test_ordinary_run_preserves_explicit_authorization_boundaries( } ] fixture.write_text(json.dumps(fixture_data), encoding="utf-8") - state_root = git_repo / ".agent-run" + state_root = managed_state(git_repo) state_path = next((state_root / "runs").glob("*.json")) control_path = next((state_root / "task-control").glob("*.json")) current_state = load_only_run_state(git_repo) diff --git a/tests/test_ticket_192_cli_actions.py b/tests/test_ticket_192_cli_actions.py index b59d028..4153335 100644 --- a/tests/test_ticket_192_cli_actions.py +++ b/tests/test_ticket_192_cli_actions.py @@ -6,6 +6,8 @@ import pytest +from support.workspace import managed_state + from agent_run.cli import ( _print_interruption_result, build_parser, @@ -180,7 +182,7 @@ def test_approve_parent_receipt_is_human_safe_and_repeat_is_idempotent( status_term="任务已完成", next_action="下一步: 无", ) - control_path = next((git_repo / ".agent-run" / "task-control").glob("*.json")) + control_path = next((managed_state(git_repo) / "task-control").glob("*.json")) first_control = json.loads(control_path.read_text(encoding="utf-8")) first_action_id = first_control["action"]["action_id"] delivery_before = json.loads(fixture.read_text(encoding="utf-8"))["delivery"] @@ -271,13 +273,13 @@ def test_revise_parent_intent_creates_a_successor_at_the_next_human_boundary( repeated_output = stdout_json(repeated) assert repeated_output["action"]["submission"] == "started" assert repeated_output["action_audit"]["action_id"] != receipt["action_id"] - current = StateStore(git_repo / ".agent-run").load_run(run_id) + current = StateStore(managed_state(git_repo)).load_run(run_id) assert current is not None assert current["status"] == "run_approval_pending" assert current["run_acceptance"]["repair_generation"] == 2 assert current["run_acceptance"]["modification_attempts"] == attempts_before == 1 assert len(current["agent_invocation_history"]) == invocation_count_before + 4 - control_path = next((git_repo / ".agent-run" / "task-control").glob("*.json")) + control_path = next((managed_state(git_repo) / "task-control").glob("*.json")) control = json.loads(control_path.read_text(encoding="utf-8")) assert control["action"]["action_id"] == repeated_output["action_audit"]["action_id"] assert any( @@ -324,7 +326,7 @@ def test_revise_durable_save_crash_is_not_replayed( ) invocation_count_before = len(crashed_state["agent_invocation_history"]) fixture_before = fixture.read_bytes() - control_path = next((git_repo / ".agent-run" / "task-control").glob("*.json")) + control_path = next((managed_state(git_repo) / "task-control").glob("*.json")) control_before = json.loads(control_path.read_text(encoding="utf-8")) repeated = run_cli( @@ -344,7 +346,7 @@ def test_revise_durable_save_crash_is_not_replayed( assert repeated_output["action"]["status"] == "applied" assert repeated_output["action_audit"]["action_id"] == receipt["action_id"] assert repeated_output["action_audit"]["payload_digest"] == receipt["payload_digest"] - current = StateStore(git_repo / ".agent-run").load_run(str(crashed_state["run_id"])) + current = StateStore(managed_state(git_repo)).load_run(str(crashed_state["run_id"])) assert current is not None assert current["run_acceptance"]["repair_request"] == repair_request assert ( @@ -414,7 +416,7 @@ def test_run_failure_receipt_recovers_the_durable_parent_run( ("configure", ["--development-model", "custom"]), ], ) -def test_exact_run_repository_mismatch_failure_is_human_safe( +def test_unknown_repository_failure_does_not_use_the_launch_checkout( git_repo: Path, command: str, extra_arguments: list[str], @@ -442,10 +444,9 @@ def test_exact_run_repository_mismatch_failure_is_human_safe( assert failed.returncode == 2 assert "命令状态: 未执行" in failed.stdout - assert "Repository: example/project" in failed.stdout - assert "Parent Issue: #1" in failed.stdout - assert "交付状态: 等待人工批准" in failed.stdout - assert "下一步: agent-run approve 1 --repo example/project" in failed.stdout + assert "没有找到唯一匹配的交付" in failed.stdout + assert "不会猜测目标" in failed.stdout + assert load_only_run_state(git_repo) == state assert run_id not in failed.stdout assert internal_status not in failed.stdout assert "" not in failed.stdout @@ -466,7 +467,7 @@ def test_configure_parent_changes_only_the_profile_control_plane( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) run_id = str(stdout_json(seed_run(git_repo, fixture))["run_id"]) - state_root = git_repo / ".agent-run" + state_root = managed_state(git_repo) state_path = state_root / "runs" / f"{run_id}.json" profile_path = state_root / "profiles" / f"{run_id}.json" profile_before = json.loads(profile_path.read_bytes()) @@ -597,10 +598,10 @@ def test_parent_mutation_selector_never_guesses_a_missing_run( assert result.returncode == 2 assert stdout_json(result)["diagnostics"][0]["code"] == "run_selector_not_found" - assert not (git_repo / ".agent-run" / "runs").exists() + assert not (managed_state(git_repo) / "runs").exists() -def test_parent_mutation_selector_reports_repository_mismatch_and_ambiguity( +def test_parent_mutation_selector_reports_missing_repository_and_ambiguity( git_repo: Path, ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) @@ -622,7 +623,7 @@ def test_parent_mutation_selector_reports_repository_mismatch_and_ambiguity( ) assert mismatch.returncode == 2 assert stdout_json(mismatch)["diagnostics"][0]["code"] == ( - "run_selector_repository_mismatch" + "run_selector_not_found" ) ambiguous = run_cli( @@ -644,4 +645,4 @@ def test_parent_mutation_selector_reports_repository_mismatch_and_ambiguity( assert str(git_repo) not in human_ambiguous.stdout assert "state_dir" not in human_ambiguous.stdout assert "repository_root" not in human_ambiguous.stdout - assert len(list((git_repo / ".agent-run" / "runs").glob("*.json"))) == 2 + assert len(list((managed_state(git_repo) / "runs").glob("*.json"))) == 2 diff --git a/tests/test_ticket_194_stop_abandon.py b/tests/test_ticket_194_stop_abandon.py index 850682a..b7149ee 100644 --- a/tests/test_ticket_194_stop_abandon.py +++ b/tests/test_ticket_194_stop_abandon.py @@ -15,6 +15,8 @@ import pytest +from support.workspace import managed_repo, managed_state, prepare_workspace + from conftest import seed_idle_control, seed_run, write_fixture from cli_run_supervision_support import _parent_only_agents from test_run_publication import RunPublicationAgents, _accepted_run @@ -139,9 +141,9 @@ def ensure( def _bind_running_executor( repo: Path, run_id: str ) -> tuple[TaskControlStore, TaskKey, subprocess.Popen[bytes]]: - states = StateStore(repo / ".agent-run") - task = TaskKey(repo, "example/project", 1) - control = TaskControlStore(repo / ".agent-run") + states = StateStore(managed_state(repo)) + task = TaskKey(managed_repo(repo), "example/project", 1) + control = TaskControlStore(managed_state(repo)) claim = control.claim_action(task, kind="run", payload={"parent": 1}) reservation = control.begin_executor( task, action_id=claim.action_id, run_id=None, runner_binding="b" * 16 @@ -392,7 +394,7 @@ def test_stop_rejects_pending_action_at_pre_executor_barrier( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = str(current["run_id"]) if pending_kind == "resume": @@ -400,8 +402,8 @@ def test_stop_rejects_pending_action_at_pre_executor_barrier( {"status": "operator_stopped", "terminal_kind": "operator_stopped"} ) states.save_run(run_id, current) - task = TaskKey(git_repo, "example/project", 1) - control = TaskControlStore(git_repo / ".agent-run") + task = TaskKey(managed_repo(git_repo), "example/project", 1) + control = TaskControlStore(managed_state(git_repo)) pending = control.claim_action( task, kind=pending_kind, @@ -512,7 +514,7 @@ def test_public_stop_is_immediate_and_repeat_is_read_only(git_repo: Path) -> Non fixture = write_fixture(git_repo / "github.json", issues={}) seeded = seed_run(git_repo, fixture) assert seeded.returncode == 0, seeded.stderr - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) run = states.find_unfinished_runs("example/project", 1)[0] run_id = str(run["run_id"]) control, task, worker = _bind_running_executor(git_repo, run_id) @@ -590,15 +592,15 @@ def test_detached_control_failure_is_reported_and_blocks_ordinary_run( fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = str(current["run_id"]) dirty_checkout: Path | None = None mutations_before_failure: list[object] | None = None if kind == "abandon": seed_idle_control( - TaskControlStore(git_repo / ".agent-run"), - TaskKey(git_repo, "example/project", 1), + TaskControlStore(managed_state(git_repo)), + TaskKey(managed_repo(git_repo), "example/project", 1), run_id, ) prepared = _run_cli( @@ -614,8 +616,8 @@ def test_detached_control_failure_is_reported_and_blocks_ordinary_run( assert current is not None parent_job = current["parent_job"] assert isinstance(parent_job, dict) - dirty_checkout = git_repo / ".agent-run" / "worktrees" / run_id / "parent" - GitRepository(git_repo).prepare_ticket_checkout( + dirty_checkout = managed_state(git_repo) / "worktrees" / run_id / "parent" + GitRepository(managed_repo(git_repo)).prepare_ticket_checkout( branch=str(parent_job["parent_branch"]), base_sha=str(parent_job["base_sha"]), checkout=dirty_checkout, @@ -632,7 +634,7 @@ def test_detached_control_failure_is_reported_and_blocks_ordinary_run( monkeypatch.chdir(git_repo) def record_status_before_receipt() -> None: - durable = StateStore(git_repo / ".agent-run").load_run(run_id) + durable = StateStore(managed_state(git_repo)).load_run(run_id) assert durable is not None status_before_failed_receipt.append(str(durable["status"])) @@ -739,9 +741,7 @@ def active_runner_available() -> bool: capsys.readouterr() with monkeypatch.context() as readiness: readiness.chdir(git_repo) - readiness.setenv( - "XDG_DATA_HOME", str(git_repo / "runner-data") - ) + readiness.setenv( "XDG_RUNTIME_DIR", str(git_repo / "runtime") ) @@ -763,6 +763,9 @@ def active_runner_available() -> bool: fixture ), ) + readiness.setattr("agent_run.workspace_cli.GhGitHubReader", lambda _repo=None, *, working_directory: FixtureGitHubReader( + fixture + )) production_return_code = cli_module.main( [ "run", @@ -849,13 +852,13 @@ def test_failed_control_retries_the_unresolved_target_before_success( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = str(current["run_id"]) if kind == "abandon": seed_idle_control( - TaskControlStore(git_repo / ".agent-run"), - TaskKey(git_repo, "example/project", 1), + TaskControlStore(managed_state(git_repo)), + TaskKey(managed_repo(git_repo), "example/project", 1), run_id, ) prepared = _run_cli( @@ -1063,7 +1066,7 @@ def test_unresolved_target_blocks_successors_and_run_id_drift( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = str(current["run_id"]) control, task, worker = _bind_running_executor(git_repo, run_id) @@ -1122,12 +1125,12 @@ def test_public_abandon_can_permanently_close_an_operator_stopped_run( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) run = states.find_unfinished_runs("example/project", 1)[0] run_id = str(run["run_id"]) seed_idle_control( - TaskControlStore(git_repo / ".agent-run"), - TaskKey(git_repo, "example/project", 1), + TaskControlStore(managed_state(git_repo)), + TaskKey(managed_repo(git_repo), "example/project", 1), run_id, ) prepared = _run_cli( @@ -1196,7 +1199,7 @@ def test_operator_stop_binds_the_invocation_subject_after_ticket_completion( }, ) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) state = states.find_unfinished_runs("example/project", 1)[0] run_id = str(state["run_id"]) state["active_ticket_job"]["phase"] = "completed" @@ -1292,15 +1295,15 @@ def test_abandon_executor_survives_observer_exit_with_canonical_receipt( fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = str(current["run_id"]) - task = TaskKey(git_repo, "example/project", 1) - control = TaskControlStore(git_repo / ".agent-run") + task = TaskKey(managed_repo(git_repo), "example/project", 1) + control = TaskControlStore(managed_state(git_repo)) seed_idle_control(control, task, run_id) ready_read, ready_write = os.pipe() release_read, release_write = os.pipe() - effect = git_repo / ".agent-run" / "abandon-effect" + effect = managed_state(git_repo) / "abandon-effect" observer_pid = os.fork() if observer_pid == 0: # pragma: no branch - explicit observer process os.close(ready_read) @@ -1422,11 +1425,11 @@ def test_public_abandon_observer_exit_does_not_cancel_the_executor( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) run_id = str(states.find_unfinished_runs("example/project", 1)[0]["run_id"]) seed_idle_control( - TaskControlStore(git_repo / ".agent-run"), - TaskKey(git_repo, "example/project", 1), + TaskControlStore(managed_state(git_repo)), + TaskKey(managed_repo(git_repo), "example/project", 1), run_id, ) prepared = _run_cli( @@ -1438,8 +1441,8 @@ def test_public_abandon_observer_exit_does_not_cancel_the_executor( str(_parent_only_agents(git_repo / "observer-agents.json")), ) assert prepared.returncode == 0, prepared.stderr - task = TaskKey(git_repo, "example/project", 1) - control = TaskControlStore(git_repo / ".agent-run") + task = TaskKey(managed_repo(git_repo), "example/project", 1) + control = TaskControlStore(managed_state(git_repo)) ready_read, ready_write = os.pipe() release_read, release_write = os.pipe() attached_read, attached_write = os.pipe() @@ -1610,7 +1613,7 @@ def test_abandon_executor_recovers_api_response_loss_without_duplicate_effect( ) -> None: """The control Action preserves Publisher dispatch/readback idempotence.""" - state, states, git, publisher = _accepted_run(git_repo) + state, states, git, publisher = _accepted_run(prepare_workspace(git_repo).repository_root, state_root=managed_state(git_repo)) run_id = str(state["run_id"]) published = RunPublicationEngine( git=git, @@ -1621,12 +1624,12 @@ def test_abandon_executor_recovers_api_response_loss_without_duplicate_effect( default_head_sha=git.resolve("main"), ).publish(run_id) pr_number = int(published["run_publication"]["pr_number"]) - fixture_path = git_repo / "github.json" + fixture_path = managed_repo(git_repo) / "github.json" fixture = json.loads(fixture_path.read_text(encoding="utf-8")) fixture["delivery"]["crash_after_abandon_run_pr_once"] = True fixture_path.write_text(json.dumps(fixture), encoding="utf-8") - task = TaskKey(git_repo, str(state["repository"]), int(state["parent"]["number"])) - control = TaskControlStore(git_repo / ".agent-run") + task = TaskKey(managed_repo(git_repo), str(state["repository"]), int(state["parent"]["number"])) + control = TaskControlStore(managed_state(git_repo)) seed_idle_control(control, task, run_id) def execute_abandon( @@ -1721,11 +1724,11 @@ def test_stop_fences_executor_across_external_and_api_barriers( } fixture_path = write_fixture(git_repo / "github.json", issues={"3": ticket}) assert seed_run(git_repo, fixture_path).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = str(current["run_id"]) - task = TaskKey(git_repo, "example/project", 1) - control = TaskControlStore(git_repo / ".agent-run") + task = TaskKey(managed_repo(git_repo), "example/project", 1) + control = TaskControlStore(managed_state(git_repo)) barrier_read, barrier_write = os.pipe() class BarrierPublisher(FixtureGitHubPublisher): @@ -1765,7 +1768,7 @@ def pause_before_dispatch() -> None: signal.pause() BarrierPublisher( - fixture_path, GitRepository(git_repo) + fixture_path, GitRepository(managed_repo(git_repo)) ).close_primary_ticket( ticket_number=3, run_id=run_id, @@ -1880,7 +1883,7 @@ def execute_stop( assert len(close_mutations) == expected_effects assert len(delivery.get("closed_issues", [])) == expected_effects if expected_effects: - FixtureGitHubPublisher(fixture_path, GitRepository(git_repo)).close_primary_ticket( + FixtureGitHubPublisher(fixture_path, GitRepository(managed_repo(git_repo))).close_primary_ticket( ticket_number=3, run_id=run_id, pr_number=7, @@ -1914,7 +1917,7 @@ def test_public_stop_rejects_unknown_ownership_without_mutating_run( fixture = write_fixture(git_repo / "github.json", issues={}) seeded = seed_run(git_repo, fixture) assert seeded.returncode == 0, seeded.stderr - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) run = states.find_unfinished_runs("example/project", 1)[0] run_id = str(run["run_id"]) state_path = states.runs_directory / f"{run_id}.json" @@ -1944,11 +1947,11 @@ def test_stop_proven_no_active_observations_admit_pause_action( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = str(current["run_id"]) - task = TaskKey(git_repo, "example/project", 1) - control = TaskControlStore(git_repo / ".agent-run") + task = TaskKey(managed_repo(git_repo), "example/project", 1) + control = TaskControlStore(managed_state(git_repo)) claim = control.claim_action(task, kind="run", payload={"parent": 1}) assert claim.action_id is not None reservation = control.begin_executor(task, action_id=claim.action_id, run_id=None) @@ -2055,11 +2058,11 @@ def observe( fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = str(current["run_id"]) - task = TaskKey(git_repo, "example/project", 1) - control = TaskControlStore(git_repo / ".agent-run") + task = TaskKey(managed_repo(git_repo), "example/project", 1) + control = TaskControlStore(managed_state(git_repo)) claim = control.claim_action(task, kind="run", payload={"parent": 1}) assert claim.action_id is not None reservation = control.begin_executor(task, action_id=claim.action_id, run_id=None) @@ -2140,7 +2143,7 @@ def observe( fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = str(current["run_id"]) control, task, worker = _bind_running_executor(git_repo, run_id) @@ -2263,10 +2266,10 @@ def observe( ) monkeypatch.setenv("XDG_STATE_HOME", str(git_repo / "runner-state")) - monkeypatch.setenv("XDG_DATA_HOME", str(git_repo / "runner-data")) + fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = str(current["run_id"]) control, task, worker = _bind_running_executor(git_repo, run_id) @@ -2360,7 +2363,7 @@ def observe( fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = str(current["run_id"]) control, task, worker = _bind_running_executor(git_repo, run_id) @@ -2445,7 +2448,7 @@ def test_read_only_stop_rechecks_action_admission_in_one_transaction( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = str(current["run_id"]) current.update({"status": "completed", "terminal_kind": "completed"}) @@ -2482,7 +2485,7 @@ def barrier_proof( cli_module._read_only_stop_result( SimpleNamespace(run_id=run_id), states, - GitRepository(git_repo), + GitRepository(managed_repo(git_repo)), ) ) ) @@ -2553,10 +2556,10 @@ def check_readiness(self) -> None: pass monkeypatch.setenv("XDG_STATE_HOME", str(git_repo / "runner-state")) - monkeypatch.setenv("XDG_DATA_HOME", str(git_repo / "runner-data")) + fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = str(current["run_id"]) control, task, worker = _bind_running_executor(git_repo, run_id) @@ -2587,7 +2590,7 @@ def check_readiness(self) -> None: terminal.update({"status": "completed", "terminal_kind": "completed"}) states.save_run(run_id, terminal) - profile_path = git_repo / ".agent-run" / "profiles" / f"{run_id}.json" + profile_path = managed_state(git_repo) / "profiles" / f"{run_id}.json" if unavailable_dependency == "profile": profile_path.unlink() monkeypatch.chdir(git_repo) @@ -2645,10 +2648,10 @@ def test_production_stop_with_active_executor_still_requires_readiness( capsys: pytest.CaptureFixture[str], ) -> None: monkeypatch.setenv("XDG_STATE_HOME", str(git_repo / "runner-state")) - monkeypatch.setenv("XDG_DATA_HOME", str(git_repo / "runner-data")) + fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = str(current["run_id"]) control, task, worker = _bind_running_executor(git_repo, run_id) @@ -2681,14 +2684,14 @@ def test_production_stop_rejects_pending_action_before_execution_readiness( pending_kind: str, ) -> None: monkeypatch.setenv("XDG_STATE_HOME", str(git_repo / "runner-state")) - monkeypatch.setenv("XDG_DATA_HOME", str(git_repo / "runner-data")) + fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = str(current["run_id"]) - task = TaskKey(git_repo, "example/project", 1) - control = TaskControlStore(git_repo / ".agent-run") + task = TaskKey(managed_repo(git_repo), "example/project", 1) + control = TaskControlStore(managed_state(git_repo)) control.claim_action(task, kind=pending_kind, payload={"parent": 1}) state_path = states.runs_directory / f"{run_id}.json" control_path = control.path_for(task) @@ -2720,11 +2723,11 @@ def test_terminal_abandon_replay_completes_its_applying_control_action( fixture = write_fixture(git_repo / "github.json", issues={}) seeded = seed_run(git_repo, fixture) assert seeded.returncode == 0, seeded.stderr - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) run = states.find_unfinished_runs("example/project", 1)[0] run_id = str(run["run_id"]) - task = TaskKey(git_repo, "example/project", 1) - control = TaskControlStore(git_repo / ".agent-run") + task = TaskKey(managed_repo(git_repo), "example/project", 1) + control = TaskControlStore(managed_state(git_repo)) claim = control.claim_control_action( task, kind="abandon", @@ -2756,11 +2759,11 @@ def test_control_action_reconciles_durable_outcome_after_executor_exit( ) -> None: fixture = write_fixture(git_repo / "github.json", issues={}) assert seed_run(git_repo, fixture).returncode == 0 - states = StateStore(git_repo / ".agent-run") + states = StateStore(managed_state(git_repo)) current = states.find_unfinished_runs("example/project", 1)[0] run_id = str(current["run_id"]) - task = TaskKey(git_repo, "example/project", 1) - control = TaskControlStore(git_repo / ".agent-run") + task = TaskKey(managed_repo(git_repo), "example/project", 1) + control = TaskControlStore(managed_state(git_repo)) payload = {"parent": 1, "run_id": run_id} if kind == "abandon": payload["discard_worktree"] = True diff --git a/tests/test_worker_managed_workspace.py b/tests/test_worker_managed_workspace.py new file mode 100644 index 0000000..7b4e812 --- /dev/null +++ b/tests/test_worker_managed_workspace.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from agent_run.codex import _worker_hidden_paths +from agent_run.worker_sandbox import bubblewrap_command, worker_environment + + +@pytest.mark.parametrize("writable_checkout", [True, False]) +def test_worker_can_use_managed_checkout_without_exposing_runner_data( + git_repo: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + writable_checkout: bool, +) -> None: + if shutil.which("bwrap") is None: + pytest.skip("bubblewrap is unavailable") + data_home = tmp_path / "data" + runner_root = data_home / "agent-run" + repository_root = runner_root / "repositories" / "example" / "project" + clone = repository_root / "repository" + shutil.copytree(git_repo, clone) + state_root = repository_root / "state" + checkout = state_root / "worktrees" / "run" / "ticket" + subprocess.run( + ["git", "worktree", "add", "--detach", str(checkout), "HEAD"], + cwd=clone, + capture_output=True, + check=True, + ) + protected = ( + clone / "base-checkout-secret", + runner_root / "snapshots" / "installed" / "runner-secret", + runner_root / "run-locator.json", + state_root / "runs" / "run.json", + state_root / "task-control" / "authority.json", + state_root / "worktrees" / "other-run" / "private-source.py", + checkout / ".agent-run" / "runs" / "nested-secret.json", + ) + for path in protected: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("private", encoding="utf-8") + monkeypatch.setenv("XDG_DATA_HOME", str(data_home)) + monkeypatch.setenv("AGENT_RUN_INTERNAL_STATE_ROOT", str(state_root)) + temporary = tmp_path / "worker-temp" + temporary.mkdir() + environment = worker_environment(temporary / "gh", "read-token") + environment["PROTECTED_PATHS"] = json.dumps([str(path) for path in protected]) + probe = temporary / "probe.py" + probe.write_text( + "import json, os, subprocess\n" + "from pathlib import Path\n" + "source = Path('README.md')\n" + "before = source.read_text()\n" + "read = subprocess.run(['git', 'show', 'HEAD:README.md'], " + "text=True, capture_output=True)\n" + "mutation = subprocess.run(['git', 'update-ref', " + "'refs/heads/worker-escape', 'HEAD'], capture_output=True)\n" + "try:\n" + " source.write_text('worker change\\n')\n" + " writable = True\n" + "except OSError:\n" + " writable = False\n" + "print(json.dumps({\n" + " 'source': before, 'git_read': read.stdout,\n" + " 'git_returncode': read.returncode, 'writable': writable,\n" + " 'git_mutation_blocked': mutation.returncode != 0,\n" + " 'protected_visible': [Path(p).exists() " + "for p in json.loads(os.environ['PROTECTED_PATHS'])],\n" + "}))\n", + encoding="utf-8", + ) + command = bubblewrap_command( + [ + sys.executable, + "-c", + "import subprocess, sys; " + "subprocess.run([sys.executable, sys.argv[1]], check=True)", + str(probe), + ], + checkout=checkout, + temporary=temporary, + writable_checkout=writable_checkout, + environment=environment, + hidden_paths=_worker_hidden_paths(None, checkout=checkout), + ) + + result = subprocess.run( + command, cwd=checkout, env=environment, text=True, capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "source": "# fixture\n", + "git_read": "# fixture\n", + "git_returncode": 0, + "writable": writable_checkout, + "git_mutation_blocked": True, + "protected_visible": [False] * len(protected), + } + assert (checkout / "README.md").read_text(encoding="utf-8") == ( + "worker change\n" if writable_checkout else "# fixture\n" + ) + assert all(path.read_text(encoding="utf-8") == "private" for path in protected)