Merge pull request 'Make task recovery and synchronization ready for merge queue adoption' (#519) from feature/merge-queue-policy-draft into main
This commit was merged in pull request #519.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -25,8 +25,9 @@
|
||||
完整脱敏收据(不要求已有任务卡);按输出完成必读。`SHARED_WORKSPACE_BUSY / SYNC_REQUIRED /
|
||||
REMOTE_UNVERIFIED` 均不得进入写入流程;失败后排除原因再启动,不把缓存远端当最新事实。
|
||||
- 新任务运行 `make task-start NAME=feature/<slug> PRECHECK_RECEIPT=/tmp/<本次唯一名称>.json`,
|
||||
建立独立 worktree 与任务卡并导入首次收据。恢复已有任务在原任务工作区导入收据:
|
||||
`IMPORT_RECEIPT=/tmp/<本次唯一名称>.json make evidence`。任务中途不重复启动检查。
|
||||
建立独立 worktree 与任务卡并导入首次收据。恢复已有登记任务在原 worktree 用
|
||||
`make task-resume PRECHECK_RECEIPT=/tmp/<本次唯一名称>.json` 验证并原样导入收据。
|
||||
默认 delivery 要求严格 READY;局部恢复接口、启用条件与旧收据处理见 [开发门禁](policy/governance.md#启动与恢复)。任务中途不重复启动检查。
|
||||
- **分支层级**:任务分支一律从权威 `origin/main` 切(`task-start` 已强制);同批互不依赖的
|
||||
子任务用同一 R0 的兄弟分支。确有顺序依赖需从父 feature 分支切子分支时(collaboration.md
|
||||
允许的堆叠例外):启动命令增加 `PARENT=feature/<父slug> PARENT_PR=<父PR链接>`,
|
||||
@@ -35,6 +36,9 @@
|
||||
base 改回 main,方可声明可合入(分支保护要求 head 含 origin/main,未同步必被拦)。
|
||||
- **验收、请求审核、合入前**各运行一次 `make task-sync`;完成声明的 precheck、task-sync、
|
||||
测试三段输出由 `make evidence` 写入任务卡(见「证据与验证门禁」),不手抄。
|
||||
- **多个分支交付 / CI 调度**:先读 [合入调度](policy/merge-queue.md),核对采纳状态、协调者、
|
||||
记录及当前窗口。新模式尚未启用时保留既有同步节点;启用后最终同步至合入核验共用窗口,
|
||||
同基线 `task-sync` 只核验,固定后检查用源码外 RECEIPT 保存(见 [证据流程](policy/governance.md#候选固定前后))。
|
||||
- 合入后由工作区 owner 确认所有写者退出,在**已登记任务 worktree 根目录**运行
|
||||
`TASK_WRITERS_STOPPED=1 make task-finish`。父子仓改动、未跟踪/ignored 唯一文件先保存或显式处置;删除被拒时
|
||||
保留现场并按原因处理,禁止强删兜底。放弃未合入工作用
|
||||
|
||||
@@ -13,13 +13,15 @@ PLUGINS := plugins
|
||||
COMBO := core=$(shell git -C $(CORE) rev-parse --short HEAD 2>/dev/null)+backend=$(shell git -C $(BACKEND) rev-parse --short HEAD 2>/dev/null)+frontend=$(shell git -C $(FRONTEND) rev-parse --short HEAD 2>/dev/null)+ui=$(shell git -C $(UI) rev-parse --short HEAD 2>/dev/null)+agentcmd=$(shell git -C deps/agent-command rev-parse --short HEAD 2>/dev/null)+documents=$(shell git -C modules/documents rev-parse --short HEAD 2>/dev/null)+native=$(shell git -C deps/agent-native rev-parse --short HEAD 2>/dev/null)+squad=$(shell git -C deps/agent-squad rev-parse --short HEAD 2>/dev/null)+intent=$(shell git -C deps/agent-intent rev-parse --short HEAD 2>/dev/null)
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null)+$(COMBO)
|
||||
|
||||
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-sync task-close task-finish facts-verify evidence version
|
||||
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-resume task-sync task-close task-finish facts-verify evidence version
|
||||
|
||||
version:
|
||||
@echo "agent-system $(VERSION)"
|
||||
|
||||
TASK := scripts/task.sh
|
||||
export PRECHECK_RECEIPT RECEIPT IMPORT_RECEIPT TASK_WRITERS_STOPPED
|
||||
TASK_WORK_MODE ?= delivery
|
||||
export TASK_WORK_MODE
|
||||
TASK_PARENT_BRANCH ?= $(PARENT)
|
||||
TASK_PARENT_PR ?= $(PARENT_PR)
|
||||
export TASK_PARENT_BRANCH TASK_PARENT_PR
|
||||
@@ -36,6 +38,9 @@ task-start:
|
||||
@if [ -z "$(NAME)" ]; then echo "usage: make task-start NAME=feature/<slug>"; exit 2; fi
|
||||
@bash $(TASK) start "$(NAME)"
|
||||
|
||||
task-resume:
|
||||
@bash $(TASK) resume
|
||||
|
||||
task-sync:
|
||||
@bash $(TASK) sync
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ U2 起实施与部署分别评审。
|
||||
|
||||
## 验证
|
||||
|
||||
规范修订:[最终验证与合入调度 r1](policy/merge-queue.md) 及[U1–U6 实施计划](modules/documents/docs/proposals/2026-10-13-merge-queue-implementation-plan.md)已批准。通用规范与计划已分别经 PR 合入,本候选组合固定实际合入 SHA,见[采纳记录](policy/adoption.md)。同步幂等化、显式局部恢复和源码外证据基础已实施;Dev 最新组合验收及平台调度模式启用仍按各自门禁办理。
|
||||
|
||||
`make test` 先核对子模块工作树与固定指针,再运行治理脚本、公开边界、跨仓指针、库及各模块
|
||||
测试。实际目标以根 Makefile 为准。`make test-governance` 可独立检查开发门禁,使用临时
|
||||
真实仓库,不访问生产数据;操作与证据要求见 [开发门禁](policy/governance.md)。
|
||||
|
||||
Vendored
+1
-1
Submodule deps/development-guidelines updated: af199eeaa2...2f5b19872b
+1
-1
Submodule modules/documents updated: 047d362c72...129cc3579d
+5
-2
@@ -6,12 +6,14 @@
|
||||
- 过程文档位置:modules/documents(docs/records/)
|
||||
- 版本权威:父仓 gitlink
|
||||
- 批准/生效依据:Dev PR#1(19/19 正负例 + make test);guidelines PR#1
|
||||
- 本次升级候选:`0.1.0-draft.11`,实际合入提交 `2f5b19872be2a4597838a4131ad75d1be089ee5e`(Guidelines PR #5);本候选父仓固定该源码版本。基础门禁继续执行,合入调度流程单独记 approved/pending-activation;Dev 组合验收、平台正负例和全部写入者覆盖完成前不切换交付模式。
|
||||
- 过程计划固定:Documents PR #96 实际合入 `129cc3579d9c9e3edfb46c99270d65f3e1dad0a0`,包含既有主干文档及获批 U1–U6 计划;当前进展以任务/PR 记录为准。
|
||||
|
||||
| 规范/工作流 | 适用条件与范围 | 执行方式/证据 |
|
||||
|---|---|---|
|
||||
| core/baseline.md | 日常 | manual |
|
||||
| workflows/delivery.md(含启动自检 §1) | 交付 | 本地命令检查;首次收据与 BUSY/SYNC/REMOTE_UNVERIFIED 语义见 policy/governance.md |
|
||||
| standards/collaboration.md | 并行/同步节点 | **enforced(本地)**:pre-commit 拒绝集成分支直提与坏分支名(负例:hook-main/badbranch 拒绝输出);节点同步由 task-sync 落卡 |
|
||||
| standards/collaboration.md | 并行/同步节点 | **enforced(本地命令)**:分支、现场、组件检查;task-sync 实际合并时记录,同基线只核验。最终平台与阶段授权另验 |
|
||||
| 提交格式(主题行/四段 body) | 全部提交 | 主题行 enforced(local),body manual;任务工具生成四段 body,历史 PR 仅作旧版本证据 |
|
||||
| standards/verification.md | 验收 | 本地 make test-governance + 组合测试;当前证据与历史 scratch 记录分开 |
|
||||
| standards/multi-repo.md | 跨仓/gitlink | manual;受控历史迁移通道已于 2026-09-30 行使一次(documents/records/2026-09-30-history-migration.md) |
|
||||
@@ -20,6 +22,7 @@
|
||||
| standards/tool-adaptation.md(draft.8) | 为工具创建规则/钩子实例 | 本组合 CLI/git 级实例已登记:policy/tool-adaptation-matrix.md(A1–A7 逐行定级+正负例证据) |
|
||||
| 同上(zcode 实例) | zcode 客户端写路径 | policy/tool-adaptation-zcode.md:PreToolUse 写守卫;脚本负例与客户端接线分开,客户端 pending-live |
|
||||
| core/baseline.md「规范层级」+ documentation.md「记忆分层/机制化」(draft.9) | 层级效力、记忆权威、经验机制化 | manual:本仓 policy/ 仅收紧不放宽;记忆四层按条文执行(任务卡=任务记忆,documents/records=项目记忆,Dev 仓 AGENTS.md 指针已就位) |
|
||||
| draft.11 合入调度候选 | approved/pending-activation;本候选组合固定实际合入 SHA,Dev 验收待完成 | CLI/文档已有 SELF、独立 REPLAY 和历史固定对象 GATE;当前新组合须另验。Issue #518 已建且协调者已指定,平台正负例、所有写入者覆盖及真实试行未完成;现行准入条件保留 |
|
||||
|
||||
## 项目差异
|
||||
|
||||
@@ -29,4 +32,4 @@
|
||||
| delivery.md §2、design-verification.md 的轻量流程默认做法 | 按 [policy/delivery.md](delivery.md) 保留需求、方案、实施与验收的人工节点;代码 Review 与 PR 合入交由智能体,冲突解决且当前 HEAD 的 CI 通过后合入;小任务简化篇幅,阶段内可迭代 | 2026-10-12 用户明确要求智能体 Review 后合入,合入前解决冲突且 CI 通过;其他人工决策与高后果双钥匙边界保留 | weichen;Review、CI 或平台准入条件变化时复查 |
|
||||
|
||||
- 历史记录(2026-09-30)报告 11 个组合仓 main 禁直推、拒绝陈旧候选;当前平台能力须独立重验,本地脚本测试不替代远端证据。
|
||||
- 当前规范内容以 gitlink 及其 VERSION 为准。治理可靠性修复的命令与证据约定见 [开发门禁](governance.md),本地与客户端/平台能力分别登记;本轮不改变分支保护、发布授权或通用库版本。
|
||||
- 规范源码以 gitlink 及其 VERSION 为准,流程启用以本采纳状态及项目条件为准;固定源码版本不代替实际能力证明。治理可靠性修复的命令与证据约定见 [开发门禁](governance.md),本地与客户端/平台能力分别登记;本轮不改变分支保护或发布授权。
|
||||
|
||||
@@ -46,6 +46,11 @@
|
||||
正式测试和最终 Review 的顺序不限制设计阶段试验、实施期测试或提前审阅。
|
||||
文本合并无冲突不证明组合兼容;合入后仍核验真实内容与被测候选是否一致。
|
||||
|
||||
多个就绪任务的最终验证与合入按 [合入调度](merge-queue.md) 的采纳状态执行。
|
||||
新模式生效后,阶段 7–9 在同一目标交付窗口内完成;对象未变时各节点重复核验,
|
||||
不因记录检查再创建提交或重跑有效 CI。窗口领取不代替人工结论、独立 Review 或平台准入;
|
||||
未生效时继续既有同步规则。固定后的证据按 [源码外收据流程](governance.md#候选固定前后) 保存。
|
||||
|
||||
## 长期方向与任务衔接
|
||||
|
||||
用户提出大方向、长期目标或跨任务规划时,必须在项目中留存可接续的主记录,不能仅保留
|
||||
@@ -169,6 +174,8 @@
|
||||
|
||||
本流程由智能体执行,任务卡模板和本地提示不构成平台强制门禁。平台能力按
|
||||
[能力矩阵](tool-adaptation-matrix.md) 核验;分支保护变更仍由用户按双钥匙边界执行。
|
||||
交付窗口覆盖普通 PR、闭合 PR、机器人及紧急通道;coordination 仍为 manual 时如实登记,
|
||||
当前目标在窗口内被外部推进则建立新尝试,不继续使用旧候选的绿色结果。
|
||||
|
||||
### 清理与恢复
|
||||
|
||||
|
||||
+34
-2
@@ -20,10 +20,23 @@ make task-start NAME=feature/my-task PRECHECK_RECEIPT=/tmp/my-task-precheck.json
|
||||
同一收据不可覆盖。首次检查失败时处理原因,使用新路径重新启动。主干在两步间前进时,
|
||||
重新确认新的启动基线,不能把旧收据移花接木到新对象。
|
||||
|
||||
恢复已有任务仍先捕获本次启动检查,再在原任务工作区用
|
||||
`IMPORT_RECEIPT=/tmp/my-task-resume.json make evidence` 导入;不重新建分支。
|
||||
恢复已有登记任务先捕获本次启动检查,再在原任务 worktree 用
|
||||
`make task-resume PRECHECK_RECEIPT=/tmp/my-task-resume.json` 验证并原样导入;不重新建分支。
|
||||
默认 `TASK_WORK_MODE=delivery`,继续要求严格 READY 和当前目标一致。
|
||||
工作区忙或落后时先由 owner 保存现场并同步;远端不可达时只继续不依赖新鲜基线的阅读和分析。
|
||||
|
||||
局部恢复接口已具备,流程启用仍以 [合入调度](merge-queue.md#采纳顺序与停止条件) 的采纳条件为准。
|
||||
启用后,既有任务可以显式选择 local,保持原 R0,标记落后,继续已授权局部工作:
|
||||
|
||||
```bash
|
||||
TASK_WORK_MODE=local RECEIPT=/tmp/my-local-resume.json CMD="make precheck" make evidence
|
||||
TASK_WORK_MODE=local make task-resume PRECHECK_RECEIPT=/tmp/my-local-resume.json
|
||||
```
|
||||
|
||||
LOCAL_READY 只用于局部恢复;新任务入口始终校验严格 READY,正式验收与合入另行同步。
|
||||
恢复核验实际 worktree/卡片/分支、R0 祖先关系及源码外原始收据;脏现场、Git 操作、组件漂移和未知远端仍拒绝。
|
||||
旧收据允许作为历史证据原样导入,不自动成为当前恢复或交付准入;格式继续为 schema 1。
|
||||
|
||||
堆叠任务使用 `PARENT=feature/parent PARENT_PR=<父PR链接>`。父分支须包含本批主干 R0;
|
||||
卡片同时记录父分支、父 SHA 和父 PR。父先合入,子同步最新 main 并改回 main 为 PR base。
|
||||
|
||||
@@ -37,6 +50,9 @@ make task-start NAME=feature/my-task PRECHECK_RECEIPT=/tmp/my-task-precheck.json
|
||||
它是组合测试和构建的前置检查。父仓未提交改动可以局部测试,但证据必须记录实际对象状态;
|
||||
正式验收使用已提交的固定对象,不能仅写一个与工作树不同的 HEAD。
|
||||
|
||||
`task-sync` 对同一基线重复调用只核验,保留 HEAD、索引和卡片;实际合并新目标才追加一次记录。
|
||||
输出完整 sync_base、记录完成后的 tested_head、checked_at 及 changed;changed 表示是否实际纳入新目标,组件仍须核对。
|
||||
|
||||
## 本地记录与独立证据
|
||||
|
||||
本地 `make evidence` 一律生成 SELF。`SRC=GATE/REPLAY` 不可把本地执行升级为权威来源。
|
||||
@@ -51,6 +67,22 @@ PR 合入由智能体执行:先完成有效 Review,再同步并解决冲突
|
||||
命令含敏感值时展示脱敏形式,并记录原命令摘要。执行前后对象分别记录;命令改变 HEAD
|
||||
时不把执行前 SHA 冒充执行后对象。不能读取执行后状态时明确记录缺项。
|
||||
|
||||
### 候选固定前后
|
||||
|
||||
固定前,完成同步、必要检查、任务卡及本地收据提交,核对干净的组件组合,再读取和推送最终 HEAD。
|
||||
将这个 head、base、完整组件及相关配置引用到调度尝试;任务卡表内“记录前对象”不当作最终候选。
|
||||
|
||||
固定后,本地命令使用源码外唯一 RECEIPT 保存完整输出,平台 CI/Review/合入结果写调度记录:
|
||||
|
||||
```bash
|
||||
RECEIPT=/tmp/my-attempt-worktree-check.json CMD="make test-worktree" make evidence
|
||||
```
|
||||
|
||||
执行者选择位于当前 checkout 和 Git 元数据之外的未用绝对路径,工具保留 before/after、原命令退出码和 SELF 来源。
|
||||
这是 manual 流程,工具不提供工作树冻结或路径范围的 OS 强制隔离。命令改变源码、组件或相关配置时,
|
||||
保留实际记录,重新建立尝试及受影响验证;新增提交必须核验新 HEAD 的必需 CI。
|
||||
后续实际合入/清理后按授权闭合任务,再原样导入已保存收据;不向仍冻结的候选追加记录提交或补写未来结果。
|
||||
|
||||
凭据从环境或受保护文件引用,避免出现在命令字面量。脱敏是补充防护,不是允许输出秘密。
|
||||
执行脚本不通过 shell tracing 暴露环境;管道任何一段失败应传回非零退出码。
|
||||
工具写入失败本身也必须报错,不能只保留一个看似成功的退出状态。
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# 最终验证与合入调度(候选条文 r1)
|
||||
|
||||
状态:approved(r1 条文作为实施计划基线);执行模式:未切换;日期:2026-10-13(Asia/Shanghai);规范采纳负责人:weichen。
|
||||
r1 条文来源提交为 913b214f2826a50b3dab65f79d9ebf83e84f2519;Documents 实施与测试计划 r1@ba4044d57e38d0887dd425d052abf26b20a28bf4 已于 2026-10-13 获用户「批准」。阶段结论与执行进展按 [迭代交付](delivery.md) 记录在任务主记录,候选固定后的结论保存在关联 PR 与源码外收据中;规范及计划批准不等于模式已启用。
|
||||
现行规则仍以根 AGENTS.md、[迭代交付](delivery.md)、[开发门禁](governance.md) 和已采纳 gitlink 为准。
|
||||
本分支已实施 CLI 同步/恢复及源码外证据基础,自检依据见任务主记录及固定后记录;通用条文和实施计划已经各自 PR 合入,本候选组合固定实际合入版本。线上队列、CI 分流与平台保护尚未切换,不承诺自动调度能力已就位。
|
||||
|
||||
## 通用规范与项目职责
|
||||
|
||||
通用正文的唯一候选来源是 Development-Guidelines 的 `standards/merge-queue.md`,
|
||||
配套模板为该仓 `templates/merge-queue.md`,候选内容版本 `0.1.0-draft.11`。
|
||||
本仓仅细化目标、入口、阶段、预算和采纳条件,不复制通用调度正文。
|
||||
通用候选已经 [Guidelines PR #5](https://git-home.u1680832.nyat.app:30319/weichen/Development-Guidelines/pulls/5) 合入为 `2f5b19872be2a4597838a4131ad75d1be089ee5e`;本候选 `deps/development-guidelines` 已物化该固定版本及上述文件。Dev 组合验收、平台正负例、全部正常写入者覆盖及模式切换条件满足前,本文件保持 approved/pending-activation,不标 active。
|
||||
|
||||
Dev 目标身份使用实际托管实例、仓库 ID 与 `refs/heads/main`;其他组件仓各有自己的目标队列。
|
||||
跨仓组合按任务中的依赖关系先交付组件,再固定父仓组合;保留其他任务已经合入的 gitlink 能力。
|
||||
队列仅安排工作,受保护分支最终准入继续由 Gitea 或等价受信入口负责。
|
||||
|
||||
## 项目调度记录
|
||||
|
||||
采纳后的首版使用每个目标一份 Gitea 调度 Issue,由一个明确协调者维护,标为 manual。
|
||||
任务卡引用 Issue 条目及尝试标识,原任务仍是授权、需求和阶段的状态权威。
|
||||
共享记录已建立:[Dev main 调度记录 #518](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/issues/518),包含实际托管实例、仓库 ID 和目标引用。用户已指定当前交付智能体负责后续人工协调,weichen 保留阶段评审和平台保护操作;具体任期与接管约束以该记录为准。
|
||||
记录仍为准备中,不开放领取,不自动向其他任务分配工作;角色分工不代替启用条件。
|
||||
|
||||
必须记录目标、条目、执行者、源 HEAD、依赖、顺序、尝试、最终 base/head、组件清单、
|
||||
检查定义及相关配置、实际 CI 链接/身份、Review 对象、真实合入结果和暂停/恢复原因。
|
||||
平台结果先保存于调度记录;本地命令仍用 `make evidence` 保存完整 SELF 收据。
|
||||
候选固定后新增命令收据使用源码之外的唯一绝对 `RECEIPT` 路径,不立即提交进被测分支。
|
||||
收据之后按已授权闭合流程原样导入;不能手抄、补写或自行提升来源。
|
||||
|
||||
候选固定前完成任务卡、必要收据和其他需提交内容,再推送最终 HEAD。
|
||||
固定后如果必须提交新内容,即使是任务卡,也建立新尝试并核验新 HEAD 的必需 CI;不复用旧提交状态放行。
|
||||
合入 SHA 和清理结果在实际发生后回填,不能提前将未来合入或清理标为已完成。
|
||||
闭合 PR 进入同一队列;可在授权范围内批量闭合已合入任务,保留每个真实主 PR 和结果。
|
||||
|
||||
## 拟替换的启动与同步要求
|
||||
|
||||
正式采纳时须一起修订根 AGENTS.md、`policy/governance.md` 与 `scripts/task.sh` 的对应入口;
|
||||
不能仅新增本文就宣布以下能力有效。
|
||||
|
||||
| 场景 | 拟采用动作 | 当前实现边界 |
|
||||
|---|---|---|
|
||||
| 新任务启动 | 保留唯一 precheck 收据与 `task-start`,从最新权威 main 建独立工作区 | 现有命令保留 |
|
||||
| 恢复落后任务的局部工作 | 核对现场、授权和落后状态,记录待最终同步;允许已授权局部编辑 | 已实现显式 local/LOCAL_READY 及 task-resume;默认仍严格,流程待采纳启用 |
|
||||
| 其他任务合入、排队等待 | 更新落后/依赖提示,不自动同步各分支或触发完整 CI | 无自动调度能力,不能宣称已接线 |
|
||||
| 领取最终交付窗口 | 成功 fetch,运行 `make task-sync`,实际合并最新 main、处置冲突、物化固定组件 | 使用真实同步及祖先检查,保留脏状态/远端失败拒绝 |
|
||||
| 窗口内再次到达验收、审核、合入节点 | 再查最新目标和候选;未变只核验,不创建元数据提交 | 已实现同基线不提交,保留组件核对和完整最终 SHA |
|
||||
| base/head、组件或相关配置变化 | 保留旧结果对象范围,重新建立尝试并按影响补测/复审 | 当前 HEAD 的全部必需 CI 仍必须实际通过 |
|
||||
|
||||
SYNC_REQUIRED 不整体降级为 READY;local 与 delivery 用独立结果和验证用途区分。
|
||||
已同步启动收据验证、恢复入口及回归,写守卫保留原分支检查,不宣称已校验阶段批准。
|
||||
共享工作区忙、未知远端和活动 Git 操作继续拒绝相应写入。
|
||||
|
||||
幂等化的判据是:成功 fetch、目标已包含、递归组件检查通过时,重复核验不改变 HEAD、索引和提交内容;
|
||||
仍产生可复核的实际输出。任务真正需要纳入新目标时保留 merge 与对应对象记录。
|
||||
所有候选身份使用必要记录提交完成后的最终 HEAD,不能把记录提交前的 SHA 当作最终被测对象。
|
||||
|
||||
## 拟调整的交付阶段
|
||||
|
||||
人工评审和独立 Review 要求不变;提前审阅可以并行,最终有效性核验和受影响重放在窗口内完成。
|
||||
入队与领取不构成人工批准,也不授权跳过缺失阶段。
|
||||
|
||||
| 原阶段 | 采纳后的调度衔接 |
|
||||
|---|---|
|
||||
| 1–6 需求、设计、计划、实施 | 各任务并行推进,在既有授权内局部检查;内容就绪时登记交付条目 |
|
||||
| 7 正式测试与验收 | 领取窗口后实际同步最新 main,固定候选,完成正式验证并取得必要人工结论 |
|
||||
| 8 代码 Review | 对同一固定候选核验有效 Review、独立重放和问题闭合;同步或整改后按影响复审 |
|
||||
| 9 合入 | 保持窗口,核验最新 base/head、全部必需 CI 和平台准入,经 PR 合入并查询真实结果 |
|
||||
| 10 核验与收口 | 核对合入内容和组件后释放窗口;部署、清理另按原授权,闭合 PR 单独排队 |
|
||||
|
||||
等待人工结论超过预算时进入 blocked 并安全释放窗口;再次领取时判断原结论及证据是否仍适用。
|
||||
窗口不得长期持有等待新的需求或方案批准;必要前置安排应在入队时说明。
|
||||
普通失败和需要决策只阻塞当前条目及依赖项;未知合入结果则保持该目标在 reconciling,先对账。
|
||||
|
||||
## CI 资源与检查
|
||||
|
||||
本草案只定义后续分配要求;`.gitea/workflows/ci.yaml` 当前行为保持原样。
|
||||
后续将局部反馈与最终准入的触发和资源安排区分,不通过跳过必需作业返回绿色来节省成本。
|
||||
当前 workflow 中的 `test`、`test-pg`、`svc-realcombo` 等作业须按最终已批准集合核验;
|
||||
平台状态上下文的完整名称、运行身份和被测 SHA 从当次实际运行核对,不从历史文档直接照抄。
|
||||
|
||||
建议首轮人工试行预算,待本文件及试行计划获批后使用:
|
||||
|
||||
- 每个目标一个最终候选;其独立作业可在实际 runner 能力内并行。
|
||||
- 最大无进展时间 20 分钟,单次窗口预算 90 分钟;有效进展、延长及原因记入尝试。
|
||||
- 已知基础设施失败最多额外重试一次;代码失败直接退出整改,不自动重复跑完整 CI。
|
||||
- 等待队列每 30 分钟复查一次;最终准入优先,但不无限延后其他目标、main 验证或发布检查。
|
||||
- 全局 runner 并发预算在试行前读取实际容量后登记;未核验容量时按现有资源安排,不凭建议数值调整平台。
|
||||
|
||||
预算是试行参数,长期值由真实耗时和等待情况复盘决定。
|
||||
容量不足时先等待 runner,再领取目标窗口;不得持有另一个仓库窗口去等待组件窗口。
|
||||
取消仅针对失效且归属明确的当前尝试,其他任务有效的局部检查继续运行。
|
||||
|
||||
## 平台与接管边界
|
||||
|
||||
现有 [平台准入所有者操作](platform-admission.md) 是历史准备材料,执行前仍需当场重验。
|
||||
最新只读配置观察、命令和对象由本任务 evidence 收据保存;配置读取属于 SELF,不证明拒绝路径已经有效。
|
||||
本草案不修改分支保护、批准数或白名单。
|
||||
|
||||
人工协调期间,全部正常合入者按窗口约定行动。没有执行端覆盖全部写入者及原子准入的正负例时,能力上限为 manual。
|
||||
发现外部目标推进,立即判定本轮候选失效,保留结果并建立新尝试;不能以目标锁的本地存在绕过平台拒绝。
|
||||
自动模式未来必须具备原子持久领取和合入入口的旧执行者拒绝;租约到期不直接授权接管。
|
||||
合入请求超时先核对 PR 和目标真实状态,旧执行者退出后才能安全重试或释放。
|
||||
|
||||
需要调整平台保护时,由 Agent 准备具体配置、回退和隔离验证命令,用户按根 AGENTS.md 双钥匙要求确认并亲手执行。
|
||||
至少验证合法候选可合入,以及缺检查、失败检查、陈旧候选和无权限推进被拒。
|
||||
人工首版验证旧执行者确认退出后交接及未知结果对账;自动接管启用前另须验证实际合入入口拒绝旧代次。
|
||||
当前不运行这些平台试验,也不影响其他进行中任务。
|
||||
|
||||
## 采纳顺序与停止条件
|
||||
|
||||
1. 人工审阅通用候选、Dev 条文及记录模板;确认同步时机、阶段衔接、预算和恢复边界。
|
||||
2. 在通用仓完成候选修订 Review 与其文档验证,按授权经 PR 合入;宿主采纳完整固定 SHA。
|
||||
3. 另行提交实施计划,覆盖 task-sync 幂等化、恢复/写守卫兼容、源码外收据、CI 分配和能力矩阵。
|
||||
4. 在隔离场景完成治理脚本正负例;涉及脚本必须运行 `make test-governance`。
|
||||
5. 跨仓 gitlink 正式推进前运行 Dev `make test`;最终对象的 CI/构建及平台准入遵循现行规则。
|
||||
6. 完成所有者平台操作和对应验证,登记真实协调者与调度 Issue,才切换新交付模式。
|
||||
7. 用三个就绪任务试行,记录同步轮数、CI 次数/耗时、废弃运行、排队和恢复,复盘后决定扩大。
|
||||
|
||||
第 2 步规范文本合入或第 5 步指针推进均不自动将新模式标 active/enforced。
|
||||
迁移期保留现行准入规则,直到新条文、命令和平台条件一致;未满足时暂停模式切换,已授权局部开发可继续。
|
||||
回退先停止新领取并对账活动合入,再恢复旧同步流程,保留尝试和证据历史。
|
||||
|
||||
| 验收 ID | 需验证的行为 | 本轮状态 |
|
||||
|---|---|---|
|
||||
| MQ1 | A/B/C 正常顺序交付;各任务轮到后最终同步一次,无外部推进时不因其他正常合入反复跑最终 CI | NOT_RUN(后续隔离试行) |
|
||||
| MQ2 | 同目标重复领取被拒,不同目标可在资源预算内并行 | NOT_RUN(后续机制验证) |
|
||||
| MQ3 | 同步后目标未变,重复核验不改 HEAD;固定后记录不改变候选 | 本地 CLI/证据回归已执行(SELF,原始输出见任务卡);平台试行 NOT_RUN |
|
||||
| MQ4 | 新 HEAD、base 或组合不能使用旧 CI/Review 直接放行 | NOT_RUN(后续正负例) |
|
||||
| MQ5 | 冲突、代码失败、等待人工决定超时释放窗口,不阻塞无依赖任务 | NOT_RUN(后续试行) |
|
||||
| MQ6 | 紧急插队有依据,受影响候选失效;跨仓依赖和组件能力不回退 | NOT_RUN(后续试行) |
|
||||
| MQ7 | 请求超时、协调者退出先对账;不能仅因租约过期让旧新执行者同时合入 | NOT_RUN(后续恢复验证) |
|
||||
| MQ8 | 平台拒绝未满足准入者,合法固定候选可合入;manual 能力如实登记,自动接管另验证旧代次拒绝 | NOT_RUN(所有者操作后验证) |
|
||||
| MQ9 | 文档链接、入口、版本、单一来源、状态与模板字段一致 | 本轮执行;原始输出见任务卡 SELF 收据 |
|
||||
|
||||
这些验收 ID 用于实施与验收追溯;本分支已完成可独立实施的 CLI/证据基础、规则与模板准备及临时真实 Git 场景自检。固定规范采纳、实际模式切换、平台正负例和真实 CI/三任务试行仍待各自门禁,未执行项保持 NOT_RUN;当前进展与原始证据统一见任务卡。
|
||||
@@ -5,6 +5,22 @@
|
||||
|
||||
## 已核实的差距
|
||||
|
||||
本轮读取结果见 `.task/feature-merge-queue-policy-draft.md` 的原始 SELF 收据(2026-10-13;
|
||||
命令 `python3 /tmp/mq-read-platform-20261013.py`)。观察对象 main 为
|
||||
`ff3fb4a7ebe432ab21c6b9668abc9823aca2fdd1`:禁直推/强推、陈旧分支阻断开启,
|
||||
必需状态检查关闭,管理员强制合入覆盖未阻断。此为配置观察,不是拒绝路径通过证明。
|
||||
PR #513 的固定 head `9a3c205b0b93185172cb6b31f6793b3a4d71ea58` 状态列表实际包含以下三个上下文;
|
||||
该次记录未核验它们的最终通过结果,不能据此合入该 PR。
|
||||
|
||||
```text
|
||||
ci / test (pull_request)
|
||||
ci / test-pg (pull_request)
|
||||
ci / svc-realcombo (pull_request)
|
||||
```
|
||||
|
||||
仓级 runner 查询为空,不代表实例/组织共享 runner 不存在;总体容量与预算仍待核验。
|
||||
执行以下操作前重查 main、保护配置和当次 PR/运行,不把本次读配置当长期授权或能力保证。
|
||||
|
||||
2026-10-05 读取 Dev 的 main 分支保护 API,观察到禁直推与陈旧分支拦截开启,
|
||||
必需状态检查关闭、批准数为 0。读取历史 PR head 的状态 API,实际检查名为
|
||||
`ci / test (pull_request)`。完整命令与输出由 evidence 工具记录在
|
||||
@@ -15,8 +31,9 @@
|
||||
|
||||
1. 在 Gitea 的 Agent-System-Dev 仓库设置中打开 main 的分支保护,先保留当前配置截图或导出。
|
||||
2. 确认本次修复 PR 的固定 head 已实际运行 CI,并在提交状态中核对完整检查名。
|
||||
3. 启用必需状态检查,选择实际的 `ci / test (pull_request)`;保持禁直推及陈旧分支拦截。
|
||||
3. 启用必需状态检查,逐项选择当次核验的 test、test-pg、svc-realcombo 三个完整 PR 上下文;保持禁直推、禁强推及陈旧分支拦截。
|
||||
不用仅在合入后运行的 `ci / test (push)` 替代 PR 检查。
|
||||
建议同时启用“管理员遵守分支保护”,关闭管理员强制合入覆盖;该配置也由所有者亲手执行。
|
||||
4. 批准数暂不随此修复自动调整。若另行启用非作者批准,先确认有可用且符合权限要求的
|
||||
独立审核账号;不能把切换 Agent 角色当独立账号,也不能盲设阈值使单账号仓库无法交付。
|
||||
5. 在隔离的测试 PR 上验证:无检查、检查失败和陈旧候选均不可合入;合法且检查通过的
|
||||
@@ -26,6 +43,38 @@
|
||||
任一判据未满足时暂停合入并保留测试对象。配置调整失败由所有者对照保存的配置恢复;
|
||||
不把“配置 API 返回成功”写成“门禁已有效”。其他组合仓分别核验,不外推 Dev 的结果。
|
||||
|
||||
## 可评审配置与恢复
|
||||
|
||||
以下是待所有者执行的最小字段变更,不是已经执行的 API 请求;批准数、白名单和其他策略保持原值。
|
||||
在 Gitea 仓库设置 → Branches → main 对照这些字段保存即可。
|
||||
API 使用 PATCH `/api/v1/repos/weichen/Agent-System-Dev/branch_protections/main`,只改变指定字段,
|
||||
语义依据 [官方 API](https://docs.gitea.com/api/operations/repo-edit-branch-protection/) 和
|
||||
[分支保护说明](https://docs.gitea.com/1.27/usage/access-control/protected-branches/)。
|
||||
|
||||
```json
|
||||
{
|
||||
"enable_push": false,
|
||||
"enable_force_push": false,
|
||||
"block_on_outdated_branch": true,
|
||||
"enable_status_check": true,
|
||||
"status_check_contexts": [
|
||||
"ci / test (pull_request)",
|
||||
"ci / test-pg (pull_request)",
|
||||
"ci / svc-realcombo (pull_request)"
|
||||
],
|
||||
"block_admin_merge_override": true
|
||||
}
|
||||
```
|
||||
|
||||
所有者先通过 GET 同一资源保存完整旧配置到仓外受保护文件;恢复只写回本次涉及的这六类字段,
|
||||
使用保存时的实际值,避免覆盖同时发生的其他配置调整。根 AGENTS.md 的双钥匙边界对恢复同样适用,Agent 不自行恢复保护。
|
||||
凭据只从环境或受保护文件引用,不放请求 URL、日志或提交;不生成带 token 字面量的 curl 示例。
|
||||
|
||||
验证对象由所有者和协调者在动作前指定:优先隔离保护规则/测试 PR,记录实际 head/base 和预期副作用。
|
||||
需要作用于 Dev 时单独确认该测试对象及合入/撤回安排;不为构造测试直推 main、发布或改写历史。
|
||||
临时仓的 fixture CI 只证明保护机制,Dev workflow 的仓库 allowlist 和完整业务 CI 仍需在实际 Dev 候选核验。
|
||||
缺检查/失败/陈旧/无权推进的拒绝与合法路径全部有 GATE/REPLAY 后,再更新能力矩阵并判断是否可切换调度模式。
|
||||
|
||||
## CI 信任前提
|
||||
|
||||
本次 workflow 修复减少 checkout token 对候选代码的暴露;仍须由平台维护者核验
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
适配对象:通过 shell/git 操作本仓的执行者。要求来自固定的 Development-Guidelines
|
||||
`standards/tool-adaptation.md` 与根 AGENTS.md;本表登记机制范围,不复制另一套规则。
|
||||
当前修订为治理可靠性修复候选;实际验证记录见 `.task/feature-governance-reliability.md`
|
||||
及其关联 PR。历史 19/19 临时测试不代替本次回归。
|
||||
历史治理可靠性记录见 `.task/feature-governance-reliability.md`;本轮调度与恢复候选的
|
||||
实际 SELF 命令记录见 `.task/feature-merge-queue-policy-draft.md`。历史测试不代替当前对象回归。
|
||||
|
||||
| 要求 | 执行能力及范围 | 机制 | 复验入口 |
|
||||
|---|---|---|---|
|
||||
| A1 启动 | 本地命令阻断;执行者首次调用仍属 manual | precheck 区分忙/落后/远端未核实;start 验证首次收据 | `tests/test_task.py`、`tests/test_evidence.py` |
|
||||
| A2 同步 | 本地命令阻断;最终准入由平台承担 | 合并父仓、保护组件分支、物化固定指针、递归核对 | `tests/test_task.py`、`make test-worktree` |
|
||||
| A1 启动/恢复 | 本地命令阻断;首次调用及阶段授权仍属 manual | 默认严格 READY;显式 local 验证既有任务/R0及 LOCAL_READY;start 固定严格用途,resume 验证后导入 | `tests/test_task.py`、`tests/test_task_recovery.py`、`tests/test_evidence.py` |
|
||||
| A2 同步 | 本地命令阻断;最终准入由平台承担 | 合并父仓、保护组件分支、物化固定指针、同基线幂等、完整最终 SHA | `tests/test_task.py`、`make test-worktree` |
|
||||
| A3 集成分支 | 本地提交闸;平台状态须独立核验 | pre-commit 拒绝 main/master 与坏分支名 | 本地 hook 负例;远端缺 CI/陈旧候选/直推等负例另验 |
|
||||
| A4 提交格式 | 主题行 enforced(local),body manual | commit-msg 校验宽度/句点;任务工具生成四段 body | `tests/test_task.py`;人工核对 PR/卡片同源 |
|
||||
| A5 回收 | 本地命令检查;写者退出由 owner 确认 | 登记根目录、锁/操作状态、父子仓与唯一文件、合入核验 | `tests/test_task.py` |
|
||||
@@ -16,6 +16,8 @@
|
||||
| A7 证据 | 本地 SELF 收据;外部来源独立核验 | 首次捕获、完整脱敏输出/摘要、对象记录、原样导入 | `tests/test_evidence.py`;CI/审阅链接另核 |
|
||||
| A8 阶段评审与迭代 | manual(人工/Agent 执行);前序人工决策,智能体 Review/合入;模板和提示不构成批准身份校验或全流程阻断 | policy/delivery.md;任务卡分别记录阶段结论、Review、冲突、当前 HEAD 的 CI 和合入;post-merge 默认仅提示 | `tests/test_task.py` 核对模板及钩子不自动写入;智能体核对 Review、冲突及匹配 HEAD 的 CI/平台准入;人工批准范围仍须核验 |
|
||||
| A9 长期方向与后续任务 | manual;模板不校验规划有效性或自动调度任务 | policy/delivery.md;PROJECT.md 提供规划入口,任务卡关联规划/里程碑并记录下一步建议 | `tests/test_task.py` 核对生成字段与未授权默认值;人工核对当前规划、进展、重复任务和建议依据 |
|
||||
| A10 交付窗口 | manual(待启用);无自动互斥或接管 | policy/merge-queue.md;Issue #518 与协调者已登记,本候选固定实际合入规范版本;Dev 组合验收、平台正负例及全部写入者覆盖待完成 | tests/test_merge_queue_trial.py 临时 Git 场景 SELF;平台/真实 CI 三任务试行 NOT_RUN |
|
||||
| A11 固定后证据 | manual 选择源码外位置;工具捕获绝对未用路径/原始输出;无 OS 冻结 | policy/governance.md;RECEIPT 捕获,后续授权闭合再原样导入 | `tests/test_evidence.py` 固定对象、权限、覆盖拒绝、原样导入与幂等 |
|
||||
|
||||
## 失败处理
|
||||
|
||||
|
||||
+74
-6
@@ -197,19 +197,80 @@ def read_receipt(filename):
|
||||
return value
|
||||
|
||||
|
||||
def validate_precheck(receipt):
|
||||
def task_context(current=None):
|
||||
"""Verify an existing task checkout; this is not an approval identity."""
|
||||
current = snapshot() if current is None else current
|
||||
root = Path(git_text("rev-parse", "--show-toplevel")).resolve()
|
||||
branch = current["branch"]
|
||||
if not re.fullmatch(r"(?:feature|fix|hotfix|release)/[A-Za-z0-9._-]+", branch):
|
||||
raise EvidenceError("Local recovery requires a task branch")
|
||||
records = [row.split(b"\0") for row in git("worktree", "list", "--porcelain", "-z").split(b"\0\0") if row]
|
||||
matches = [row for row in records if row[0].startswith(b"worktree ")
|
||||
and Path(os.fsdecode(row[0][9:])).resolve() == root]
|
||||
if len(matches) != 1 or not records or matches[0] == records[0]:
|
||||
raise EvidenceError("Recovery requires a registered linked task worktree")
|
||||
entry = matches[0]
|
||||
if any(field.startswith((b"bare", b"detached", b"prunable")) for field in entry) or (
|
||||
b"branch refs/heads/" + branch.encode() not in entry):
|
||||
raise EvidenceError("Task worktree registration is not valid")
|
||||
if (root / ".task").is_symlink():
|
||||
raise EvidenceError("Task registration must belong to the current checkout")
|
||||
card = find_card()
|
||||
if card.is_symlink() or card.resolve().parent != root / ".task":
|
||||
raise EvidenceError("Task registration must belong to the current checkout")
|
||||
content = card.read_text()
|
||||
locations = re.findall(r"^- 工作区:(.+?)\s*[((]worktree[))];分支:([^\s]+)\s*$", content, re.M)
|
||||
bases = re.findall(r"^- 权威仓:.*;目标分支:([^;]+);R0(initial_base_sha)= ([0-9a-f]+)\s*$", content, re.M)
|
||||
if len(locations) != 1 or Path(locations[0][0]).resolve() != root or locations[0][1] != branch:
|
||||
raise EvidenceError("Task card belongs to a different worktree or branch")
|
||||
if len(bases) != 1 or bases[0][0] != current["target_branch"] or not GIT_SHA.fullmatch(bases[0][1]):
|
||||
raise EvidenceError("Task card has no valid target and initial baseline")
|
||||
r0 = bases[0][1]
|
||||
if not current["target_head"]:
|
||||
raise EvidenceError("Task target is unverified; fetch it first")
|
||||
for tip in (current["head"], current["target_head"]):
|
||||
git("merge-base", "--is-ancestor", r0, tip)
|
||||
check = subprocess.run([sys.executable, str(Path(__file__).with_name("check-worktree.py")), "before-sync"],
|
||||
capture_output=True, text=True)
|
||||
if check.returncode:
|
||||
raise EvidenceError("Task workspace is not ready: " + (check.stdout + check.stderr).strip())
|
||||
return {"root": root, "r0": r0}
|
||||
|
||||
|
||||
def validate_precheck(receipt, purpose="start"):
|
||||
if purpose not in ("start", "resume-delivery", "resume-local"):
|
||||
raise EvidenceError("Unknown precheck validation purpose")
|
||||
after = receipt["after"]
|
||||
current = snapshot()
|
||||
if receipt["command"] not in ("make precheck", "bash scripts/task.sh precheck"):
|
||||
raise EvidenceError("Task start requires a precheck receipt")
|
||||
if receipt["exit_code"] != 0 or not re.search(r"^\[verdict\] READY(?:\s|$)", receipt["output"], re.M):
|
||||
raise EvidenceError("Precheck receipt is not READY")
|
||||
expected = "LOCAL_READY" if purpose == "resume-local" else "READY"
|
||||
verdicts = re.findall(r"^\[verdict\] ([A-Z_]+)(?:\s|$)", receipt["output"], re.M)
|
||||
if receipt["exit_code"] != 0 or verdicts != [expected]:
|
||||
raise EvidenceError("Precheck receipt is not " + expected)
|
||||
if after is None or receipt["after_error"] is not None:
|
||||
raise EvidenceError("Precheck object capture is incomplete")
|
||||
if receipt["before"]["head"] != current["head"] or after["head"] != current["head"]:
|
||||
raise EvidenceError("Precheck source HEAD has changed")
|
||||
if after["target_branch"] != current["target_branch"] or not after["target_head"] or after["target_head"] != current["target_head"]:
|
||||
if after["target_branch"] != current["target_branch"] or not after["target_head"]:
|
||||
raise EvidenceError("Precheck authoritative target has changed")
|
||||
if purpose != "resume-local" and after["target_head"] != current["target_head"]:
|
||||
raise EvidenceError("Precheck authoritative target has changed")
|
||||
if purpose.startswith("resume-"):
|
||||
context = task_context(current)
|
||||
try:
|
||||
Path(receipt["cwd"]).resolve().relative_to(context["root"])
|
||||
except ValueError:
|
||||
raise EvidenceError("Precheck belongs to a different task worktree")
|
||||
if any(obj["branch"] != current["branch"] or obj["target_branch"] != current["target_branch"]
|
||||
for obj in (receipt["before"], after)):
|
||||
raise EvidenceError("Precheck belongs to a different task branch")
|
||||
git("merge-base", "--is-ancestor", context["r0"], after["target_head"])
|
||||
if purpose == "resume-local":
|
||||
try:
|
||||
git("merge-base", "--is-ancestor", after["target_head"], current["target_head"])
|
||||
except EvidenceError:
|
||||
raise EvidenceError("Precheck target history has rewound or diverged") from None
|
||||
|
||||
|
||||
def find_card():
|
||||
@@ -261,6 +322,12 @@ def main():
|
||||
try:
|
||||
if os.environ.get("SRC", "SELF") not in ("", "SELF"):
|
||||
raise EvidenceError("Local evidence is SELF; GATE/REPLAY require external execution identity")
|
||||
if sys.argv[1:] == ["--local-context"]:
|
||||
context = task_context()
|
||||
print("[task-state] registered=" + str(context["root"]) + " initial_base=" + context["r0"])
|
||||
return 0
|
||||
if sys.argv[1:]:
|
||||
raise EvidenceError("Unknown evidence arguments")
|
||||
capture, imported, validated = (os.environ.get(key) for key in
|
||||
("RECEIPT", "IMPORT_RECEIPT", "VALIDATE_RECEIPT"))
|
||||
command = os.environ.get("CMD", "")
|
||||
@@ -269,7 +336,7 @@ def main():
|
||||
if imported or validated:
|
||||
receipt = read_receipt(imported or validated)
|
||||
if validated:
|
||||
validate_precheck(receipt)
|
||||
validate_precheck(receipt, os.environ.get("VALIDATE_RECEIPT_PURPOSE", "start"))
|
||||
report(receipt, "precheck validated")
|
||||
else:
|
||||
card = find_card()
|
||||
@@ -286,7 +353,8 @@ def main():
|
||||
source_cwd, source_common = str(Path.cwd().resolve()), common_dir()
|
||||
timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
child_env = os.environ.copy()
|
||||
for key in ("CMD", "SRC", "RECEIPT", "IMPORT_RECEIPT", "VALIDATE_RECEIPT", "TAIL"):
|
||||
for key in ("CMD", "SRC", "RECEIPT", "IMPORT_RECEIPT", "VALIDATE_RECEIPT", "TAIL",
|
||||
"VALIDATE_RECEIPT_PURPOSE"):
|
||||
child_env.pop(key, None)
|
||||
result = subprocess.run(["bash", "-o", "pipefail", "-c", command],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=child_env)
|
||||
|
||||
+70
-20
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# 任务启动门禁:precheck / start / sync / close / finish(配套 Makefile 同名目标)。
|
||||
# 任务启动门禁:precheck / start / resume / sync / close / finish(配套 Makefile 同名目标)。
|
||||
# 规范依据:collaboration.md 共同基线与同步节点、治理清理;delivery.md §1/§5。
|
||||
set -euo pipefail
|
||||
|
||||
@@ -20,9 +20,17 @@ fetch_target() {
|
||||
}
|
||||
check_worktree() { python3 "${SCRIPT_DIR}/check-worktree.py" "$1"; }
|
||||
main_worktree_root() { git worktree list --porcelain -z | python3 -c 'import os,sys; print(os.fsdecode(sys.stdin.buffer.read().split(bytes([0]),1)[0][9:]))'; }
|
||||
work_mode() {
|
||||
case "${TASK_WORK_MODE:-delivery}" in
|
||||
delivery|local) printf '%s\n' "${TASK_WORK_MODE:-delivery}" ;;
|
||||
*) die "TASK_WORK_MODE must be delivery or local" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_precheck() {
|
||||
need_repo
|
||||
local mode
|
||||
mode=$(work_mode)
|
||||
fetch_target
|
||||
local branch head rmain dirty sub_drift sub_uninit
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
@@ -100,11 +108,20 @@ cmd_precheck() {
|
||||
echo "[facts] WARN policy/facts.md 不存在——外部事实登记表缺失(AGENTS.md 证据与验证门禁)"
|
||||
fi
|
||||
echo "[required-reading] core/baseline.md, workflows/delivery.md §1$([ -f policy/delivery.md ] && echo ", policy/delivery.md")$([ "$dirty" -gt 0 ] && echo ", standards/collaboration.md(树脏=他人或未收尾现场,不得写入)")"
|
||||
echo "[R0] $(echo "$rmain" | cut -c1-10)(完整:$rmain)"
|
||||
if [ "$mode" = local ]; then
|
||||
echo "[target] ${rmain}(observed; task initial baseline is reported separately)"
|
||||
else
|
||||
echo "[R0] $(echo "$rmain" | cut -c1-10)(完整:$rmain)"
|
||||
fi
|
||||
if [ "$dirty" -gt 0 ] || [ "${sub_drift:-0}" -gt 0 ]; then
|
||||
echo "[verdict] SHARED_WORKSPACE_BUSY — 工作树不干净,不得写入;先收尾或经 owner 确认"
|
||||
exit 3
|
||||
fi
|
||||
if [ "$mode" = local ]; then
|
||||
python3 "${SCRIPT_DIR}/evidence.py" --local-context || return 2
|
||||
echo "[verdict] LOCAL_READY — existing task may continue authorized local work; final delivery still requires latest-target sync and checks"
|
||||
return 0
|
||||
fi
|
||||
if ! check_worktree before-sync; then
|
||||
echo "[verdict] SHARED_WORKSPACE_BUSY — component or active operation check failed; preserve the workspace"
|
||||
exit 3
|
||||
@@ -123,7 +140,7 @@ cmd_start() {
|
||||
fetch_target
|
||||
[ -n "${PRECHECK_RECEIPT:-}" ] || die "PRECHECK_RECEIPT required: capture RECEIPT=/tmp/task-precheck.json CMD='make precheck' make evidence first"
|
||||
PRECHECK_RECEIPT=$(realpath -- "$PRECHECK_RECEIPT")
|
||||
VALIDATE_RECEIPT="$PRECHECK_RECEIPT" bash "${SCRIPT_DIR}/evidence.sh"
|
||||
VALIDATE_RECEIPT="$PRECHECK_RECEIPT" VALIDATE_RECEIPT_PURPOSE=start bash "${SCRIPT_DIR}/evidence.sh"
|
||||
check_worktree before-sync
|
||||
# 已合入的同名分支不得重建(2026-10-05 实测:合入后再 task-start 同名会从新 main 长出
|
||||
# 第二段历史,空卡覆盖已合入任务卡,两轮证据差点丢失)。复核/追加沿用原分支或换新 slug。
|
||||
@@ -239,6 +256,14 @@ ${parent_note}
|
||||
| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
## 交付调度
|
||||
|
||||
- 调度状态:未入队;调度记录:无
|
||||
- entry_id:无;attempt_id:无;窗口执行者:无
|
||||
- 固定候选:无(base/head、组件与相关配置引用实际尝试)
|
||||
- 调度依据:policy/merge-queue.md;新模式启用前按既有交付门禁
|
||||
- 固定后证据:源码外 RECEIPT;实际结果后按授权原样导入和闭合
|
||||
|
||||
## 结果与验证(完成后填写)
|
||||
|
||||
- 结果:
|
||||
@@ -274,22 +299,43 @@ EOF
|
||||
echo "[started] branch=${name} worktree=${wt} R0=$(echo "$rmain" | cut -c1-10)"
|
||||
}
|
||||
|
||||
cmd_resume() {
|
||||
need_repo
|
||||
local mode
|
||||
mode=$(work_mode)
|
||||
fetch_target
|
||||
[ -n "${PRECHECK_RECEIPT:-}" ] || die "PRECHECK_RECEIPT required: capture the current task precheck before resume"
|
||||
local receipt
|
||||
receipt=$(realpath -- "$PRECHECK_RECEIPT")
|
||||
VALIDATE_RECEIPT="$receipt" VALIDATE_RECEIPT_PURPOSE="resume-${mode}" bash "${SCRIPT_DIR}/evidence.sh"
|
||||
IMPORT_RECEIPT="$receipt" bash "${SCRIPT_DIR}/evidence.sh"
|
||||
echo "[resumed] work_mode=${mode} source_head=$(git rev-parse HEAD) observed_target=$(git rev-parse "origin/${TARGET_BRANCH}")"
|
||||
}
|
||||
|
||||
cmd_sync() {
|
||||
need_repo
|
||||
fetch_target
|
||||
check_worktree before-sync
|
||||
git merge --no-edit "origin/${TARGET_BRANCH}" \
|
||||
-m "Merge authoritative ${TARGET_BRANCH} into the task branch" \
|
||||
-m "Why: include the current integration baseline before verification." \
|
||||
-m "Evidence: fetched origin/${TARGET_BRANCH}=$(git rev-parse "origin/${TARGET_BRANCH}")." \
|
||||
-m "Impact: integrate upstream changes without rewriting task history." \
|
||||
-m "Refs: task-sync"
|
||||
git merge-base --is-ancestor "origin/${TARGET_BRANCH}" HEAD || die "sync incomplete"
|
||||
local base head card branch slug pc changed=0 ancestry_status
|
||||
base=$(git rev-parse "origin/${TARGET_BRANCH}")
|
||||
if git merge-base --is-ancestor "$base" HEAD; then
|
||||
:
|
||||
else
|
||||
ancestry_status=$?
|
||||
[ "$ancestry_status" -eq 1 ] || die "cannot verify target ancestry"
|
||||
git merge --no-edit "$base" \
|
||||
-m "Merge authoritative ${TARGET_BRANCH} into the task branch" \
|
||||
-m "Why: include the current integration baseline before verification." \
|
||||
-m "Evidence: fetched origin/${TARGET_BRANCH}=${base}." \
|
||||
-m "Impact: integrate upstream changes without rewriting task history." \
|
||||
-m "Refs: task-sync"
|
||||
changed=1
|
||||
fi
|
||||
git merge-base --is-ancestor "$base" HEAD || die "sync incomplete"
|
||||
check_worktree before-update
|
||||
check_worktree materialize
|
||||
check_worktree after-sync
|
||||
local base head card branch slug pc
|
||||
base=$(git rev-parse "origin/${TARGET_BRANCH}"); head=$(git rev-parse HEAD)
|
||||
head=$(git rev-parse HEAD)
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
slug=$(echo "$branch" | tr '/' '-')
|
||||
card=".task/${slug}.md"
|
||||
@@ -304,18 +350,21 @@ cmd_sync() {
|
||||
done
|
||||
fi
|
||||
if [ -n "$card" ] && [ -f "$card" ]; then
|
||||
printf '| 同步前验收 | %s | %s | %s |\n' "$base" "$head" "$(date -u +%FT%TZ)" >> "$card"
|
||||
git add "$card"
|
||||
git commit -q -m "Record the sync checkpoint" \
|
||||
-m "Why: bind the task validation checkpoint to the latest integration baseline." \
|
||||
-m "Evidence: SELF recursive component checks; sync_base=${base}; candidate=${head}." \
|
||||
-m "Impact: task metadata only." -m "Refs: ${card}"
|
||||
if [ "$changed" -eq 1 ]; then
|
||||
printf '| 同步(记录前对象) | %s | %s | %s |\n' "$base" "$head" "$(date -u +%FT%TZ)" >> "$card"
|
||||
git add "$card"
|
||||
git commit -q -m "Record the sync checkpoint" \
|
||||
-m "Why: bind the task validation checkpoint to the integration baseline." \
|
||||
-m "Evidence: SELF recursive component checks; sync_base=${base}; before_record=${head}." \
|
||||
-m "Impact: task metadata only." -m "Refs: ${card}" -- "$card"
|
||||
fi
|
||||
# JSON receipts carry before/after state; older table rows remain readable.
|
||||
python3 "${SCRIPT_DIR}/check-evidence.py" "$card"
|
||||
else
|
||||
echo "[warn] 找不到分支 ${branch} 的任务卡,同步记录未写入" >&2
|
||||
fi
|
||||
echo "[synced] sync_base=$(echo "$base" | cut -c1-10) tested_head=$(echo "$head" | cut -c1-10) checked_at=$(date -u +%FT%TZ)"
|
||||
head=$(git rev-parse HEAD)
|
||||
echo "[synced] sync_base=${base} tested_head=${head} checked_at=$(date -u +%FT%TZ) changed=${changed}"
|
||||
echo "[next] 跑正式验收(make test 等),证据写入任务卡"
|
||||
}
|
||||
|
||||
@@ -531,8 +580,9 @@ cmd_finish() {
|
||||
case "${1:-}" in
|
||||
precheck) shift; cmd_precheck "$@" ;;
|
||||
start) shift; cmd_start "$@" ;;
|
||||
resume) shift; cmd_resume "$@" ;;
|
||||
sync) shift; cmd_sync "$@" ;;
|
||||
close) shift; cmd_close "$@" ;;
|
||||
finish) shift; cmd_finish "$@" ;;
|
||||
*) echo "usage: task.sh <precheck|start|sync|close|finish> [args]"; exit 2 ;;
|
||||
*) echo "usage: task.sh <precheck|start|resume|sync|close|finish> [args]"; exit 2 ;;
|
||||
esac
|
||||
|
||||
+23
-1
@@ -68,7 +68,8 @@ class EvidenceTests(unittest.TestCase):
|
||||
|
||||
def run_evidence(self, command=None, cwd=None, **values):
|
||||
env = os.environ.copy()
|
||||
for key in ("CMD", "SRC", "RECEIPT", "IMPORT_RECEIPT", "VALIDATE_RECEIPT"):
|
||||
for key in ("CMD", "SRC", "RECEIPT", "IMPORT_RECEIPT", "VALIDATE_RECEIPT",
|
||||
"VALIDATE_RECEIPT_PURPOSE", "TASK_WORK_MODE"):
|
||||
env.pop(key, None)
|
||||
if command is not None:
|
||||
env["CMD"] = command
|
||||
@@ -103,6 +104,27 @@ class EvidenceTests(unittest.TestCase):
|
||||
self.assertEqual(result.returncode, 7)
|
||||
self.assertIn('"exit_code": 7', self.card.read_text())
|
||||
|
||||
def test_validation_purpose_is_not_injected_into_measured_command(self):
|
||||
data = self.capture("printf '%s' \"${VALIDATE_RECEIPT_PURPOSE-unset}\"",
|
||||
VALIDATE_RECEIPT_PURPOSE="resume-local")
|
||||
self.assertEqual(data["output"], "unset")
|
||||
|
||||
def test_fixed_candidate_capture_preserves_checkout(self):
|
||||
self.git("add", ".task/main.md")
|
||||
self.git("commit", "-qm", "Save fixture card before validation")
|
||||
head = self.git("rev-parse", "HEAD")
|
||||
card = self.card.read_bytes()
|
||||
index = self.git("ls-files", "--stage")
|
||||
data = self.capture("printf 'fixed candidate check\\n'")
|
||||
self.assertEqual(data["before"]["head"], head)
|
||||
self.assertEqual(data["after"]["head"], head)
|
||||
self.assertEqual(data["output"], "fixed candidate check\n")
|
||||
self.assertEqual(self.git("rev-parse", "HEAD"), head)
|
||||
self.assertEqual(self.git("ls-files", "--stage"), index)
|
||||
self.assertEqual(self.card.read_bytes(), card)
|
||||
self.assertEqual(self.git("status", "--porcelain"), "")
|
||||
self.assertEqual(self.receipt.stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_capture_without_card_and_import_without_rerun(self):
|
||||
self.card.unlink()
|
||||
data = self.capture("printf 'once\\n' >> invocation; cat invocation")
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Real disposable Git scenarios; these do not claim CI or automatic queue locking."""
|
||||
import unittest
|
||||
|
||||
import test_task as lifecycle
|
||||
|
||||
|
||||
class QueueTrialTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fixture = lifecycle.TaskLifecycleTests(methodName="runTest")
|
||||
self.fixture.setUp()
|
||||
self.addCleanup(self.fixture.doCleanups)
|
||||
|
||||
def create_task(self, branch):
|
||||
f = self.fixture
|
||||
wt = f.root / branch.replace("/", "-")
|
||||
f.git(f.repo, "worktree", "add", "-b", branch, str(wt), "origin/main")
|
||||
f.record_task_card(wt)
|
||||
name = branch.split("/")[1]
|
||||
(wt / name).write_text(name + "\n")
|
||||
f.git(wt, "add", name)
|
||||
f.git(wt, "commit", "-m", "Add independent fixture change")
|
||||
return wt
|
||||
|
||||
def test_sequential_delivery_does_not_sync_waiting_branches(self):
|
||||
f = self.fixture
|
||||
branches = ["feature/a", "feature/b", "feature/c"]
|
||||
worktrees = {branch: self.create_task(branch) for branch in branches}
|
||||
waiting = {branch: f.git(wt, "rev-parse", "HEAD").stdout for branch, wt in worktrees.items()}
|
||||
f.advance_remote()
|
||||
for position, branch in enumerate(branches):
|
||||
wt = worktrees[branch]
|
||||
f.task(wt, "sync")
|
||||
candidate = f.git(wt, "rev-parse", "HEAD").stdout
|
||||
second = f.task(wt, "sync")
|
||||
self.assertIn("changed=0", second.stdout)
|
||||
self.assertEqual(f.git(wt, "rev-parse", "HEAD").stdout, candidate)
|
||||
self.assertEqual(f.git(wt, "status", "--porcelain").stdout, "")
|
||||
f.git(f.repo, "merge", "--no-ff", branch, "-m", "Merge fixture candidate")
|
||||
f.git(f.repo, "push", "origin", "main")
|
||||
for pending in branches[position + 1:]:
|
||||
self.assertEqual(f.git(worktrees[pending], "rev-parse", "HEAD").stdout, waiting[pending])
|
||||
for name in ("a", "b", "c"):
|
||||
self.assertEqual((f.repo / name).read_text(), name + "\n")
|
||||
|
||||
def test_conflicted_task_does_not_block_an_independent_healthy_task(self):
|
||||
f = self.fixture
|
||||
conflict = self.create_task("feature/conflict")
|
||||
healthy = self.create_task("feature/healthy")
|
||||
(conflict / "tracked").write_text("task choice\n")
|
||||
f.git(conflict, "commit", "-am", "Conflicting task edit")
|
||||
(f.repo / "tracked").write_text("target choice\n")
|
||||
f.git(f.repo, "commit", "-am", "Target edit")
|
||||
f.git(f.repo, "push", "origin", "main")
|
||||
failed = f.task(conflict, "sync", check=False)
|
||||
self.assertNotEqual(failed.returncode, 0)
|
||||
self.assertNotIn("[synced]", failed.stdout)
|
||||
self.assertIn("UU tracked", f.git(conflict, "status", "--porcelain").stdout)
|
||||
succeeded = f.task(healthy, "sync")
|
||||
self.assertIn("[synced]", succeeded.stdout)
|
||||
self.assertEqual((healthy / "tracked").read_text(), "target choice\n")
|
||||
self.assertEqual((healthy / "healthy").read_text(), "healthy\n")
|
||||
|
||||
def test_external_target_advance_requires_a_new_candidate(self):
|
||||
f = self.fixture
|
||||
task = self.create_task("feature/a")
|
||||
f.task(task, "sync")
|
||||
original = f.git(task, "rev-parse", "HEAD").stdout
|
||||
f.advance_remote()
|
||||
target = f.git(f.repo, "rev-parse", "HEAD").stdout.strip()
|
||||
result = f.task(task, "sync")
|
||||
current = f.git(task, "rev-parse", "HEAD").stdout
|
||||
self.assertNotEqual(current, original)
|
||||
self.assertIn("changed=1", result.stdout)
|
||||
f.git(task, "merge-base", "--is-ancestor", target, "HEAD")
|
||||
self.assertEqual((task / "a").read_text(), "a\n")
|
||||
self.assertEqual((task / "new-main").read_text(), "new main\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+92
-1
@@ -26,7 +26,8 @@ class TaskLifecycleTests(unittest.TestCase):
|
||||
"GIT_AUTHOR_NAME": "Lifecycle Test", "GIT_AUTHOR_EMAIL": "test@example.invalid",
|
||||
"GIT_COMMITTER_NAME": "Lifecycle Test", "GIT_COMMITTER_EMAIL": "test@example.invalid"})
|
||||
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "PRECHECK_RECEIPT",
|
||||
"TASK_PARENT_BRANCH", "TASK_PARENT_PR", "FORCE_ABANDON", "TASK_TARGET_BRANCH"):
|
||||
"TASK_PARENT_BRANCH", "TASK_PARENT_PR", "FORCE_ABANDON", "TASK_TARGET_BRANCH",
|
||||
"TASK_WORK_MODE", "VALIDATE_RECEIPT_PURPOSE"):
|
||||
self.env.pop(key, None)
|
||||
self.git(self.root, "init", "--bare", "--initial-branch=main", str(self.remote))
|
||||
# receive-pack 默认在后台跑 gc --auto,与 TemporaryDirectory 清理竞态
|
||||
@@ -69,6 +70,20 @@ class TaskLifecycleTests(unittest.TestCase):
|
||||
self.git(self.repo, "commit", "-m", "Advance main")
|
||||
self.git(self.repo, "push", "origin", "main")
|
||||
|
||||
def record_task_card(self, wt):
|
||||
branch = self.git(wt, "branch", "--show-current").stdout.strip()
|
||||
r0 = self.git(wt, "rev-parse", "origin/main").stdout.strip()
|
||||
card = wt / ".task" / (branch.replace("/", "-") + ".md")
|
||||
card.parent.mkdir(exist_ok=True)
|
||||
card.write_text(
|
||||
f"# {branch}\n任务主记录:fixture;状态:进行中;owner:Lifecycle Test\n"
|
||||
f"- 权威仓:fixture;目标分支:main;R0(initial_base_sha)= {r0}\n"
|
||||
f"- 工作区:{wt}(worktree);分支:{branch}\n"
|
||||
)
|
||||
self.git(wt, "add", str(card.relative_to(wt)))
|
||||
self.git(wt, "commit", "-m", "Record fixture task")
|
||||
return card
|
||||
|
||||
def add_submodule(self):
|
||||
child = self.root / "component"
|
||||
self.git(self.root, "init", "--initial-branch=main", str(child))
|
||||
@@ -105,6 +120,76 @@ class TaskLifecycleTests(unittest.TestCase):
|
||||
reading = next(line for line in result.stdout.splitlines() if line.startswith("[required-reading]"))
|
||||
self.assertIn("policy/delivery.md", reading)
|
||||
|
||||
def test_sync_same_base_preserves_head_and_card(self):
|
||||
wt = self.worktree()
|
||||
card = self.record_task_card(wt)
|
||||
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
|
||||
content = card.read_bytes()
|
||||
index = self.git(wt, "ls-files", "--stage").stdout
|
||||
for _ in range(2):
|
||||
result = self.task(wt, "sync")
|
||||
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
|
||||
self.assertEqual(card.read_bytes(), content)
|
||||
self.assertEqual(self.git(wt, "ls-files", "--stage").stdout, index)
|
||||
self.assertEqual(self.git(wt, "status", "--porcelain").stdout, "")
|
||||
self.assertIn("changed=0", result.stdout)
|
||||
self.assertIn(f"tested_head={head}", result.stdout)
|
||||
|
||||
def test_sync_new_base_reports_actual_final_head(self):
|
||||
wt = self.worktree()
|
||||
card = self.record_task_card(wt)
|
||||
self.advance_remote()
|
||||
base = self.git(self.repo, "rev-parse", "HEAD").stdout.strip()
|
||||
result = self.task(wt, "sync")
|
||||
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
|
||||
self.git(wt, "merge-base", "--is-ancestor", base, head)
|
||||
self.assertIn(f"sync_base={base}", result.stdout)
|
||||
self.assertIn(f"tested_head={head}", result.stdout)
|
||||
self.assertIn("changed=1", result.stdout)
|
||||
content = card.read_bytes()
|
||||
self.task(wt, "sync")
|
||||
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
|
||||
self.assertEqual(card.read_bytes(), content)
|
||||
|
||||
def test_sync_preserves_dirty_parent_and_staged_changes(self):
|
||||
for staged in (False, True):
|
||||
with self.subTest(staged=staged):
|
||||
wt = self.worktree(name="feature/staged" if staged else "feature/dirty")
|
||||
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
|
||||
(wt / "tracked").write_text("keep owner work\n")
|
||||
if staged:
|
||||
self.git(wt, "add", "tracked")
|
||||
index = self.git(wt, "ls-files", "--stage").stdout
|
||||
result = self.task(wt, "sync", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
|
||||
self.assertEqual(self.git(wt, "ls-files", "--stage").stdout, index)
|
||||
self.assertEqual((wt / "tracked").read_text(), "keep owner work\n")
|
||||
self.git(self.repo, "worktree", "remove", "--force", str(wt))
|
||||
|
||||
def test_sync_rejects_unreachable_target_without_changing_head(self):
|
||||
wt = self.worktree()
|
||||
self.record_task_card(wt)
|
||||
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
|
||||
self.git(wt, "remote", "set-url", "origin", str(self.root / "missing.git"))
|
||||
result = self.task(wt, "sync", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("REMOTE_UNVERIFIED", result.stdout)
|
||||
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
|
||||
|
||||
def test_sync_conflict_is_not_reported_as_synced(self):
|
||||
wt = self.worktree()
|
||||
self.record_task_card(wt)
|
||||
(wt / "tracked").write_text("task choice\n")
|
||||
self.git(wt, "commit", "-am", "Task edit")
|
||||
(self.repo / "tracked").write_text("target choice\n")
|
||||
self.git(self.repo, "commit", "-am", "Target edit")
|
||||
self.git(self.repo, "push", "origin", "main")
|
||||
result = self.task(wt, "sync", check=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertNotIn("[synced]", result.stdout)
|
||||
self.assertIn("UU tracked", self.git(wt, "status", "--porcelain").stdout)
|
||||
|
||||
def test_sync_materializes_updated_component(self):
|
||||
child = self.add_submodule()
|
||||
wt = self.worktree()
|
||||
@@ -396,6 +481,7 @@ class TaskLifecycleTests(unittest.TestCase):
|
||||
(wt / ".task/feature-test.md").write_text("# Task\n")
|
||||
self.git(wt, "add", ".task")
|
||||
self.git(wt, "commit", "-m", "Create task card")
|
||||
self.advance_remote()
|
||||
hook = self.repo / ".git/hooks/pre-commit"
|
||||
hook.write_text("#!/bin/sh\nexit 1\n")
|
||||
hook.chmod(0o755)
|
||||
@@ -611,6 +697,11 @@ class TaskLifecycleTests(unittest.TestCase):
|
||||
self.assertIn(f"{field}:无", card)
|
||||
for field in ("当前阶段", "当前增量", "方案引用", "实施计划引用", "测试计划引用"):
|
||||
self.assertIn(field + ":", card)
|
||||
self.assertIn("## 交付调度\n", card)
|
||||
queue = card.split("## 交付调度\n", 1)[1].split("\n## ", 1)[0]
|
||||
self.assertIn("调度状态:未入队", queue)
|
||||
for field in ("调度记录", "entry_id", "attempt_id", "窗口执行者", "固定候选"):
|
||||
self.assertIn(field + ":无", queue)
|
||||
self.assertIn("## 关联方向与规划\n", card)
|
||||
planning = card.split("## 关联方向与规划\n", 1)[1].split("\n## ", 1)[0]
|
||||
for field in ("适用状态", "主记录路径/版本", "对应里程碑"):
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Local recovery is task-scoped and cannot grant delivery readiness."""
|
||||
import json
|
||||
import unittest
|
||||
|
||||
import test_task as lifecycle
|
||||
|
||||
|
||||
class TaskRecoveryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fixture = lifecycle.TaskLifecycleTests(methodName="runTest")
|
||||
self.fixture.setUp()
|
||||
self.addCleanup(self.fixture.doCleanups)
|
||||
self.wt = self.fixture.worktree()
|
||||
self.card = self.fixture.record_task_card(self.wt)
|
||||
self.receipt_number = 0
|
||||
|
||||
def precheck(self, mode="local", cwd=None):
|
||||
f = self.fixture
|
||||
return f.task(cwd or self.wt, "precheck", check=False,
|
||||
env=f.env | {"TASK_WORK_MODE": mode})
|
||||
|
||||
def capture(self, mode="local"):
|
||||
f = self.fixture
|
||||
self.receipt_number += 1
|
||||
path = f.root / f"recovery-{self.receipt_number}.json"
|
||||
result = f.run_cmd(self.wt, "bash", "scripts/evidence.sh", check=False,
|
||||
env=f.env | {"TASK_WORK_MODE": mode, "RECEIPT": str(path),
|
||||
"CMD": "make precheck"})
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
return path
|
||||
|
||||
def resume(self, receipt, mode="local", cwd=None):
|
||||
f = self.fixture
|
||||
return f.task(cwd or self.wt, "resume", check=False,
|
||||
env=f.env | {"TASK_WORK_MODE": mode, "PRECHECK_RECEIPT": str(receipt)})
|
||||
|
||||
def save_card_change(self):
|
||||
f = self.fixture
|
||||
f.git(self.wt, "add", str(self.card.relative_to(self.wt)))
|
||||
f.git(self.wt, "commit", "-m", "Update fixture registration")
|
||||
|
||||
def test_local_precheck_allows_known_lag_without_changing_head(self):
|
||||
f = self.fixture
|
||||
head = f.git(self.wt, "rev-parse", "HEAD").stdout
|
||||
f.advance_remote()
|
||||
strict = self.precheck(mode="delivery")
|
||||
self.assertNotEqual(strict.returncode, 0)
|
||||
self.assertIn("SYNC_REQUIRED", strict.stdout)
|
||||
local = self.precheck()
|
||||
self.assertEqual(local.returncode, 0, local.stdout)
|
||||
self.assertIn("[verdict] LOCAL_READY", local.stdout)
|
||||
self.assertNotIn("[verdict] READY", local.stdout)
|
||||
self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head)
|
||||
|
||||
def test_local_precheck_never_advertises_delivery_even_when_current(self):
|
||||
result = self.precheck()
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("[verdict] LOCAL_READY", result.stdout)
|
||||
self.assertNotIn("[verdict] READY", result.stdout)
|
||||
|
||||
def test_local_precheck_rejects_main_and_missing_registration(self):
|
||||
result = self.precheck(cwd=self.fixture.repo)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.card.unlink()
|
||||
self.save_card_change()
|
||||
result = self.precheck()
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
|
||||
def test_local_precheck_rejects_foreign_registration(self):
|
||||
self.card.write_text(self.card.read_text().replace(str(self.wt), str(self.fixture.repo)))
|
||||
self.save_card_change()
|
||||
result = self.precheck()
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
|
||||
def test_local_precheck_rejects_wrong_branch_registration(self):
|
||||
self.card.write_text(self.card.read_text().replace("分支:feature/test", "分支:feature/other"))
|
||||
self.save_card_change()
|
||||
self.assertNotEqual(self.precheck().returncode, 0)
|
||||
|
||||
def test_local_precheck_rejects_invalid_baseline(self):
|
||||
f = self.fixture
|
||||
r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip()
|
||||
self.card.write_text(self.card.read_text().replace(r0, "0" * 40))
|
||||
self.save_card_change()
|
||||
self.assertNotEqual(self.precheck().returncode, 0)
|
||||
|
||||
def test_local_precheck_rejects_dirty_state_and_unknown_remote(self):
|
||||
f = self.fixture
|
||||
(self.wt / "tracked").write_text("preserve current edit\n")
|
||||
result = self.precheck()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("SHARED_WORKSPACE_BUSY", result.stdout)
|
||||
f.git(self.wt, "commit", "-am", "Save owner edit")
|
||||
f.git(self.wt, "remote", "set-url", "origin", str(f.root / "missing.git"))
|
||||
result = self.precheck()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("REMOTE_UNVERIFIED", result.stdout)
|
||||
|
||||
def test_unknown_work_mode_is_rejected(self):
|
||||
result = self.precheck(mode="anything")
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
|
||||
def test_local_resume_imports_original_receipt_without_sync(self):
|
||||
f = self.fixture
|
||||
receipt = self.capture()
|
||||
original = receipt.read_bytes()
|
||||
head = f.git(self.wt, "rev-parse", "HEAD").stdout
|
||||
f.advance_remote()
|
||||
result = self.resume(receipt)
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("[resumed]", result.stdout)
|
||||
self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head)
|
||||
self.assertEqual(receipt.read_bytes(), original)
|
||||
self.assertIn(json.loads(original)["receipt_sha256"], self.card.read_text())
|
||||
current = f.git(self.wt, "rev-parse", "origin/main").stdout.strip()
|
||||
self.assertNotEqual(json.loads(original)["after"]["target_head"], current)
|
||||
|
||||
def assert_history_change_rejected(self, receipt):
|
||||
f = self.fixture
|
||||
before = self.card.read_bytes()
|
||||
original = receipt.read_bytes()
|
||||
head = f.git(self.wt, "rev-parse", "HEAD").stdout
|
||||
result = self.resume(receipt)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("Precheck target history has rewound or diverged", result.stdout)
|
||||
self.assertNotIn("[resumed]", result.stdout)
|
||||
self.assertEqual(self.card.read_bytes(), before)
|
||||
self.assertEqual(receipt.read_bytes(), original)
|
||||
self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head)
|
||||
|
||||
def test_local_resume_rejects_target_rewind_before_import(self):
|
||||
f = self.fixture
|
||||
r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip()
|
||||
f.advance_remote()
|
||||
receipt = self.capture()
|
||||
observed = json.loads(receipt.read_text())["after"]["target_head"]
|
||||
self.assertNotEqual(observed, r0)
|
||||
# Only this test's disposable bare remote is rewritten.
|
||||
f.git(f.remote, "update-ref", "refs/heads/main", r0, observed)
|
||||
# Reproduce recovery after another client operation refreshed the target.
|
||||
f.git(self.wt, "fetch", "origin", "+refs/heads/main:refs/remotes/origin/main")
|
||||
self.assert_history_change_rejected(receipt)
|
||||
|
||||
def test_local_resume_rejects_divergent_target_before_import(self):
|
||||
f = self.fixture
|
||||
r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip()
|
||||
f.advance_remote()
|
||||
receipt = self.capture()
|
||||
observed = json.loads(receipt.read_text())["after"]["target_head"]
|
||||
f.git(f.repo, "switch", "--detach", r0)
|
||||
(f.repo / "divergent-main").write_text("different target history\n")
|
||||
f.git(f.repo, "add", "divergent-main")
|
||||
f.git(f.repo, "commit", "-m", "Create divergent fixture history")
|
||||
divergent = f.git(f.repo, "rev-parse", "HEAD").stdout.strip()
|
||||
f.git(f.repo, "push", "origin", "HEAD:refs/heads/divergent")
|
||||
self.assertEqual(f.git(f.repo, "merge-base", "--is-ancestor", observed,
|
||||
divergent, check=False).returncode, 1)
|
||||
# Both targets retain R0; only normal ancestry may permit recovery.
|
||||
f.git(f.remote, "update-ref", "refs/heads/main", divergent, observed)
|
||||
f.git(self.wt, "fetch", "origin", "+refs/heads/main:refs/remotes/origin/main")
|
||||
self.assert_history_change_rejected(receipt)
|
||||
|
||||
def test_make_resume_exports_the_selected_mode_and_receipt(self):
|
||||
f = self.fixture
|
||||
receipt = self.capture()
|
||||
result = f.run_cmd(self.wt, "make", "-f", str(lifecycle.SOURCE / "Makefile"),
|
||||
"task-resume", check=False,
|
||||
env=f.env | {"TASK_WORK_MODE": "local", "PRECHECK_RECEIPT": str(receipt)})
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("[resumed]", result.stdout)
|
||||
|
||||
def test_local_receipt_cannot_start_a_task_or_resume_for_delivery(self):
|
||||
f = self.fixture
|
||||
receipt = self.capture()
|
||||
before = self.card.read_bytes()
|
||||
delivery = self.resume(receipt, mode="delivery")
|
||||
self.assertNotEqual(delivery.returncode, 0, delivery.stdout)
|
||||
self.assertEqual(self.card.read_bytes(), before)
|
||||
start = f.task(self.wt, "start", "feature/bypass", check=False,
|
||||
env=f.env | {"PRECHECK_RECEIPT": str(receipt), "TASK_WORK_MODE": "local",
|
||||
"VALIDATE_RECEIPT_PURPOSE": "resume-local"})
|
||||
self.assertNotEqual(start.returncode, 0, start.stdout)
|
||||
self.assertFalse((f.root / "repo-wt-feature-bypass").exists())
|
||||
|
||||
def test_strict_resume_rejects_target_advance(self):
|
||||
f = self.fixture
|
||||
receipt = self.capture(mode="delivery")
|
||||
head = f.git(self.wt, "rev-parse", "HEAD").stdout
|
||||
f.advance_remote()
|
||||
rejected = self.resume(receipt, mode="delivery")
|
||||
self.assertNotEqual(rejected.returncode, 0)
|
||||
self.assertNotIn("[resumed]", rejected.stdout)
|
||||
self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head)
|
||||
|
||||
def test_strict_resume_imports_current_ready_receipt(self):
|
||||
receipt = self.capture(mode="delivery")
|
||||
result = self.resume(receipt, mode="delivery")
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
|
||||
def test_resume_rejects_changed_source_head(self):
|
||||
receipt = self.capture()
|
||||
f = self.fixture
|
||||
(self.wt / "tracked").write_text("new task revision\n")
|
||||
f.git(self.wt, "commit", "-am", "Change source after precheck")
|
||||
before = self.card.read_bytes()
|
||||
result = self.resume(receipt)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.card.read_bytes(), before)
|
||||
|
||||
def test_resume_rejects_another_worktree_with_same_head(self):
|
||||
f = self.fixture
|
||||
other = f.root / "other-worktree"
|
||||
r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip()
|
||||
other_card = self.wt / ".task/feature-other.md"
|
||||
other_card.write_text(
|
||||
"# Other task\n任务主记录:fixture;状态:进行中;owner:Lifecycle Test\n"
|
||||
f"- 权威仓:fixture;目标分支:main;R0(initial_base_sha)= {r0}\n"
|
||||
f"- 工作区:{other}(worktree);分支:feature/other\n")
|
||||
f.git(self.wt, "add", ".task/feature-other.md")
|
||||
f.git(self.wt, "commit", "-m", "Register second fixture task")
|
||||
f.git(f.repo, "worktree", "add", "-b", "feature/other", str(other), "HEAD")
|
||||
# The main checkout has an older HEAD; align this owned fixture branch to the task HEAD.
|
||||
f.git(other, "merge", "--ff-only", "feature/test")
|
||||
receipt = self.capture(mode="delivery")
|
||||
self.assertEqual(f.git(other, "rev-parse", "HEAD").stdout,
|
||||
f.git(self.wt, "rev-parse", "HEAD").stdout)
|
||||
result = self.resume(receipt, mode="delivery", cwd=other)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
|
||||
def test_unknown_validation_purpose_is_rejected(self):
|
||||
receipt = self.capture(mode="delivery")
|
||||
f = self.fixture
|
||||
result = f.run_cmd(self.wt, "bash", "scripts/evidence.sh", check=False,
|
||||
env=f.env | {"VALIDATE_RECEIPT": str(receipt),
|
||||
"VALIDATE_RECEIPT_PURPOSE": "ignore"})
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user