Merge pull request 'Allow safe local task continuation (U2)' (#618) from feature/norm-remediation-u2 into feature/norm-remediation
This commit was merged in pull request #618.
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
# feature/norm-remediation-u2 任务卡
|
||||
|
||||
任务主记录:PR #618 https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/618;状态:已完成(合入前闭合);owner:weichen
|
||||
|
||||
- 权威仓:ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Dev.git;目标分支:main;R0(initial_base_sha)= b5e23daed2f0ea46261979de1d62e4de0cfa88f9
|
||||
- 工作区:/home/weichen/projects/Agent-System-Dev-wt-feature-norm-remediation-u2(worktree);分支:feature/norm-remediation-u2
|
||||
- 分支类型:child;initiative_id:feature-norm-remediation;root_target:main;immediate_target:feature/norm-remediation;accepting_requirements:false;parent_branch:feature/norm-remediation;parent_head_sha:01d9f50ee8aba1c40cb9b1769598698c91c30c4b;parent_pr:无;parent_source:origin
|
||||
- 需求分流:免(父 initiative 子任务登记已列出 feature/norm-remediation-u2;parent_head_sha=01d9f50ee8aba1c40cb9b1769598698c91c30c4b)
|
||||
|
||||
- 授权与交付终点:用户于 2026-10-18 授权按规范整改计划实施 U2。仅允许安全既有任务的本地续作,并保留新任务启动及正式交付门禁。未授权扩展:改平台保护、host fallback、worker 权限、凭据轮换或其他 U-ID。
|
||||
- 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)
|
||||
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)
|
||||
- 合入执行:agent(已授权交付范围内,Review 通过、冲突解决且当前 HEAD 的 CI 通过后合入)
|
||||
- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位)
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
目标:当远端暂时不可用或落后时,已有且仍获授权的任务只有在身份、记录基线、本地工作区和请求范围均可核验时,才能进入 `LOCAL_READY` 继续有界本地工作;本地收据不得被解释为远端新鲜或正式交付证据。
|
||||
|
||||
范围:`scripts/task.sh`、`scripts/evidence.py`、`policy/governance.md`、`tests/test_task.py`、`tests/test_task_recovery.py`、`tests/test_evidence.py`。测试可按现有代码模式作必要的最小补充,但不得改动 `task-pr-check`、`task-sync`、CI 或平台准入语义。
|
||||
|
||||
硬停止:关闭/放弃任务、授权或身份缺失/冲突、请求超范围、R0 本地对象缺失、dirty/共享工作区状态不安全、活动 Git 操作、未知外部副作用或未对账状态均拒绝续作。
|
||||
|
||||
验收:有效既有任务可在远端失败时继续本地工作;新任务仍需新鲜远端 triage;`LOCAL_READY` 不能成为 `READY`、`REPLAY`、`GATE` 或 merge check 的依据;远端恢复后 Review/merge 仍需同步权威当前目标。
|
||||
|
||||
## Agent 实施收据
|
||||
|
||||
路由与 fail-stop 规则见根目录 AGENT-EXECUTION.md 与 policy/delivery.md;每次尝试一行,原始日志留在源码外收据路径。
|
||||
|
||||
| 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| U2 original | local recovery behavior | caefcb72 | implementation | cursor, agy, pi | 1 | cursor-agent-stable / auto | blocked | none | 0 | /tmp/norm-remediation-u2-receipt.json |
|
||||
| U2 test-only | offline local precheck regression | caefcb72 | test | pi, agy, cursor | 1 | pi / MiniMax-M3 | success | 30a582372b4c | 0 | /tmp/norm-remediation-u2-offline-test-receipt.json |
|
||||
| U2 task precheck | local precheck fallback | caefcb72 | implementation | cursor, agy, pi | 1 | cursor-agent-stable / auto | success | d6df427e39e2 | 0 | /tmp/norm-remediation-u2-precheck-impl-receipt.json |
|
||||
| U2 local receipt | resume-local receipt validation | caefcb72 | implementation | cursor, agy, pi | 1 | cursor-agent-stable / auto | success | 210bb031d4ac | 0 | /tmp/norm-remediation-u2-evidence-receipt-2.json |
|
||||
| U2 missing-ref test | resume-local with missing tracking ref | caefcb72 | test | pi, agy, cursor | 1 | pi / MiniMax-M3 | success | dcbd36656bb3 | 0 | /tmp/norm-remediation-u2-no-tracking-test-receipt.json |
|
||||
| U2 missing-ref fix | local receipt with missing tracking ref | caefcb72 | repair | cursor, agy, pi | 1 | cursor-agent-stable / auto | success | a43aa171ea7f | 0 | /tmp/norm-remediation-u2-no-tracking-fix-receipt.json |
|
||||
| U2 offline-capture test | capture LOCAL_READY without tracking ref | caefcb72 | test | pi, agy, cursor | 1 | pi / MiniMax-M3 | success | eb6be71cfc34 | 0 | /tmp/norm-remediation-u2-no-tracking-capture-test-receipt.json |
|
||||
| U2 offline-capture fix | capture and resume without tracking ref | caefcb72 | repair | cursor, agy, pi | 1 | cursor-agent-stable / auto | success | d2986f1f5fee | 0 | /tmp/norm-remediation-u2-offline-capture-fix-receipt.json |
|
||||
| U2 offline-recovery docs | clarify missing-ref local recovery | caefcb72 | implementation | cursor, agy, pi | 1 | cursor-agent-stable / auto | success | 74e4b06e5e8b | 0 | /tmp/norm-remediation-u2-offline-doc-receipt.json |
|
||||
| U2 review regression tests | closed task, reachable rewind, reappearing target | caefcb72 | test | pi, agy, cursor | 1 | pi / MiniMax-M3 | success | d5b8e74c4306 | 0 | /tmp/norm-remediation-u2-review-fix-tests-receipt.json |
|
||||
| U2 review fixes | reject closed task and known target rewind/divergence | caefcb72 | repair | cursor, agy, pi | 1 | cursor-agent-stable / auto | success | 05a9a3bc89b6 | 0 | /tmp/norm-remediation-u2-review-fixes-receipt.json |
|
||||
| U2 final edge tests | target divergence and receipt target branch mismatch | caefcb72 | test | pi, agy, cursor | 1 | pi / MiniMax-M3 | success | 2e0da0dadb73 | 0 | /tmp/norm-remediation-u2-final-edge-tests-receipt.json |
|
||||
| U2 uncomparable-target test | reachable advertised SHA missing locally | caefcb72 | test | pi, agy, cursor | 1 | pi / MiniMax-M3 | success | 95d17188585c | 0 | /tmp/norm-remediation-u2-uncomparable-target-test-receipt.json |
|
||||
| U2 target-history fix | block known but locally uncomparable history | caefcb72 | repair | cursor, agy, pi | 1 | cursor-agent-stable / auto | success | 9c0e76570832 | 0 | /tmp/norm-remediation-u2-uncomparable-target-fix-receipt.json |
|
||||
| U2 no-cache target tests | target reachable/missing or forward with no tracking ref | caefcb72 | test | pi, agy, cursor | 1 | pi / MiniMax-M3 | success | fbb2aacb71ba | 0 | /tmp/norm-remediation-u2-no-cache-history-tests-receipt.json |
|
||||
| U2 offline resume tests | standalone/child local resume during origin outage | caefcb72 | test | pi, agy, cursor | 1 | pi / MiniMax-M3 | success | 8d8585afec66 | 0 | /tmp/norm-remediation-u2-offline-resume-tests-receipt.json |
|
||||
| U2 offline resume fix | local-only receipt resume with target history and parent R0 checks | caefcb72 | repair | cursor, agy, pi | 1 | cursor-agent-stable / auto | success | f81326bd0836 | 0 | /tmp/norm-remediation-u2-offline-resume-fix-receipt.json |
|
||||
| U2 no-cache R0 fix | compare reachable targets to registered R0 without cached ref | caefcb72 | repair | cursor, agy, pi | 1 | cursor-agent-stable / auto | success | 1ff6444d3352 | 0 | /tmp/norm-remediation-u2-precheck-r0-fix-receipt.json |
|
||||
| U2 reachable-missing-parent test | reject known-absent remote child target | caefcb72 | test | pi, agy, cursor | 1 | pi / MiniMax-M3 | success | 5b5537aa0fc4 | 0 | /tmp/norm-remediation-u2-child-missing-parent-test-receipt-2.json |
|
||||
| U2 reachable-missing-parent fix | local child resume only falls back on remote outage | caefcb72 | repair | cursor, agy, pi | 1 | cursor-agent-stable / auto | success | 105901595805 | 0 | /tmp/norm-remediation-u2-child-missing-parent-fix-receipt.json |
|
||||
| U2 offline resume tests | root/child receipt import during origin outage | caefcb72 | test | pi, agy, cursor | 1 | pi / MiniMax-M3 | success | 8d8585afec66 | 0 | /tmp/norm-remediation-u2-offline-resume-tests-receipt.json |
|
||||
| U2 offline resume fix | local resume on R0/local registered parent | caefcb72 | repair | cursor, agy, pi | 1 | cursor-agent-stable / auto | success | f81326bd0836 | 0 | /tmp/norm-remediation-u2-offline-resume-fix-receipt.json |
|
||||
| U2 review-proof tests | no-ref paths, auth failure, and parent identity | caefcb72 | test | pi, agy, cursor | 1 | pi / MiniMax-M3 | success | bc304111a47f | 0 | /tmp/norm-remediation-u2-final-review-tests-receipt.json |
|
||||
| U2 final offline guards | missing cache, auth failures, and parent SHA checks | caefcb72 | repair | cursor, agy, pi | 1 | cursor-agent-stable / auto | success | 471948c59cfa | 0 | /tmp/norm-remediation-u2-final-guards-receipt.json |
|
||||
| U2 precheck R0 implementation | compare remote advertisement without cached tracking ref | caefcb72 | repair | cursor, agy, pi | 1 | cursor-agent-stable / auto | success | 1ff6444d3352 | 0 | /tmp/norm-remediation-u2-precheck-r0-fix-receipt.json |
|
||||
| U2 cached-target outage R0 regression/fix | reject cached target known to diverge from registered R0 during origin outage | caefcb72 | repair | cursor, agy, pi | 1 | cursor-agent-stable / auto | success | a06209ff0a2b | 0 | /tmp/norm-u2-cached-target-outage-fix-2.json |
|
||||
| U2 cached-target independent review | independent review of exact implementation diff | caefcb72 | review | pi, antigravity, cursor, codex | 1 | pi / MiniMax-M3 | failed-unknown | none | 0 | /tmp/norm-u2-cached-target-outage-review.json |
|
||||
|
||||
## 同步记录
|
||||
|
||||
| 节点 | sync_base_sha | tested_head_sha | checked_at |
|
||||
|---|---|---|---|
|
||||
| 启动 | b5e23daed2f0ea46261979de1d62e4de0cfa88f9 | 01d9f50ee8aba1c40cb9b1769598698c91c30c4b | 2026-10-18T09:51:35Z |
|
||||
|
||||
## 证据(Evidence)
|
||||
|
||||
<!-- CMD="<命令>" make evidence 写入;手抄/转写输出视为无效证据(AGENTS.md 证据与验证门禁)。 -->
|
||||
| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-10-18T11:24Z | `python3 -m unittest tests.test_evidence tests.test_task_recovery` | 210bb031d4acc5b9bde6cde66e7fa0f735bcf9f8 | 0 | SELF | d196e73da7f33421 |
|
||||
| 2026-10-18T12:07Z | `python3 -m unittest tests.test_evidence tests.test_task_recovery` | d2986f1f5fee0e0ad151fd813b9306873131c805 | 0 | SELF | 85197f351f8458a8 |
|
||||
| 2026-10-18T12:03Z | `python3 -m unittest tests.test_task tests.test_task_recovery tests.test_evidence` | d2986f1f5fee0e0ad151fd813b9306873131c805 | 1 | SELF | 9d19d33489e15d4d |
|
||||
| 2026-10-18T12:42Z | `python3 -m unittest tests.test_evidence tests.test_task_recovery` | 05a9a3bc89b65eebd92cac6f82c3e4f28e13ab71 | 0 | SELF | 2d19085b0725fc46 |
|
||||
| 2026-10-18T12:43Z | `make test-governance` | 05a9a3bc89b65eebd92cac6f82c3e4f28e13ab71 | 0 | SELF | 018194c0a804d1b9 |
|
||||
| 2026-10-18T13:01Z | `make test-governance` | 2e0da0dadb73db1b6a70f8545dbfd5df3a8e4d00 | 0 | SELF | a44db826c4be80fb |
|
||||
| 2026-10-18T13:40Z | `bash -n scripts/task.sh && make test-governance` | 9c0e7657083259759f5bd1bed6921471411dba2c | 0 | SELF | 872bbc3437ae83e3 |
|
||||
| 2026-10-18T14:41Z | `python3 -m unittest tests.test_evidence tests.test_task_recovery` | 1ff6444d3352c7e13c2b75cefe7d65c631181454 | 0 | SELF | bf12f030894e1530 |
|
||||
| 2026-10-18T14:40Z | `bash -n scripts/task.sh && make test-governance` | 1ff6444d3352c7e13c2b75cefe7d65c631181454 | 0 | SELF | 8e81c5353849fbd5 |
|
||||
| 2026-10-18T15:47Z | `python3 -m unittest tests.test_evidence tests.test_task_recovery` | 471948c59cfabff36c018f219998139fea3757fe | 0 | SELF | 7620bd82fed319e7 |
|
||||
| 2026-10-18T15:48Z | `bash -n scripts/task.sh && make test-governance` | 471948c59cfabff36c018f219998139fea3757fe | 0 | SELF | 5a3346fd73b80243 |
|
||||
|
||||
## 结果与验证
|
||||
|
||||
- 结果:U2 允许既有安全任务在真正远端不可用或目标跟踪 ref 缺失时继续有界本地工作;闭合任务和已证实的远端 target 回退/分叉均拒绝 `LOCAL_READY`。新任务和正式交付仍要求严格 READY/权威同步。
|
||||
- 验证:host `python3 -m unittest tests.test_evidence tests.test_task_recovery` 79/79 通过(SELF,HEAD `1ff6444d3352c7e13c2b75cefe7d65c631181454`),覆盖 root/child 离线 task-resume 与严格 delivery 拒绝;host `bash -n scripts/task.sh && make test-governance` 490 项通过、18 项 skip(SELF,同 HEAD)。不含后续主脚本改动的 transport。local precheck no-cache 的 2 个红灯回归已由紧随其后的 `1ff6444d` 修复,三条 no-cache 状态单测 3/3 通过;最终候选完整测试仍待重跑。
|
||||
- Review 发现与闭合:正确性 reviewer 发现闭合任务仍可在 precheck 收到 LOCAL_READY、以及可达但不可比较的 target 仍被当作离线;可靠性 reviewer 发现缓存 target 回退/分叉问题。已分别加入 test-first 回归并修复。测试 reviewer 建议的目标 ref 重现、receipt target branch 错配、远端分叉和不可比较 target 场景均已覆盖;项目规范/可维护性审阅无 finding。旧 CI run 3884 曾因变更外 Go 测试 `TestToolCallDeltaBeforeExecution` 失败,后续 `b492bea...` CI 通过;当前最终 head 的 GATE 与新候选完整 Review 待执行。
|
||||
- 执行偏差:原 U2 Cursor attempt 对 `tests/test_task_recovery.py` 的两个 edit 调用重叠约 449 ms,并被 fail-stop;新的 evidence 单元明确要求串行编辑,但 worker 仍在同一批次向 `scripts/evidence.py` 发出两次编辑调用(未重叠,最终 diff 已审阅且验证通过)。不能据此声称提示已强制串行;后续规范计划须区分提示、可观测证据与工具级并发防护。
|
||||
- 新增执行偏差:修复闭合任务与目标回退时,worker 在同一批次向 `scripts/evidence.py` 和 `scripts/task.sh` 发出两次互不重叠编辑,违反了任务包的串行编辑要求;最终 diff 已逐项审阅,定向与完整治理测试均通过,但该结果不能证明提示具备工具级强制力。
|
||||
- Review 路由记录:第一次 PR 对抗性 cross-model job 因 orchestrator 使用了不匹配的约束文件名,在 provider egress 前跳过,未把代码发送给外部模型;job 目录已清理。该审阅不计入 Review 覆盖,最终 head 使用本地 adversarial reviewer。
|
||||
- 后续 Review 补充(覆盖前述状态):关闭任务 precheck、reachable-but-uncomparable/缺失 target、无 tracking ref 时需查询 advertised target、以及离线 `cmd_resume` 的 fetch-first 缺口,均已补 test-first 回归与修复。root 离线 resume 使用登记 R0;child resume 仅使用已登记、包含 `parent_head_sha` 的本地 parent 作为局部参考,delivery 仍 fetch-first。最终候选完整 Review 与 exact-head CI 待完成。
|
||||
- 最新 child 边界修复:origin 可达但已登记 parent ref 缺失时,local resume 不再回退本地 parent;origin 确实不可查询时仍可使用本地 parent,并验证 `parent_head_sha`。
|
||||
- 最新代码验证:host `tests.test_evidence tests.test_task_recovery` 80/80 通过,`bash -n scripts/task.sh && make test-governance` 491 项通过、18 项 skip,测试对象为 `105901595805cc5fa5ce19f1a6d48317b950d6af`。此前 CI green head 尚早于这些离线恢复修复,须以最终 PR head 重跑 GATE。
|
||||
- 最新验证覆盖 80 个 evidence/recovery 测试与 491 项治理测试(18 项 skip),以上命令都成功;测试对象为当前实现代码 `105901595805cc5fa5ce19f1a6d48317b950d6af`。若后续只改任务卡,代码测试可复用但 CI head 会变,须在最终提交 head 重新等待 GATE。
|
||||
- child 父目标补充:若 origin 可达但已登记 parent branch 不存在,local resume 返回 REMOTE_UNVERIFIED,不再使用本地 parent;只有远端确实不可查询时才回退本地 parent,并仍核验 `parent_head_sha`。delivery 仍保持 fetch-first。
|
||||
- 最终边界修复及验证:`target_history` 仅将已识别的 DNS/连接类传输错误视为 outage;认证/权限/未知错误不再降级。无 tracking ref 时 precheck 以 active task R0 比较 advertised target;reachable target/parent 缺失或不可比均拒绝。child local resume 要求有效且本地可用的 `parent_head_sha`。去掉了无 tracking-ref 时会触发 strict-shell 退出的 merged-branch 查询。
|
||||
- 最终安全复审阻塞修复:当 origin 传输不可达但本地缓存 `origin/<target>` 已知分叉于登记 R0 时,`target_history` 现在返回 `rewound`;缓存存在但 ancestry 无法比较时返回 `uncomparable`,仅兼容缓存或无缓存才允许继续判断。新增回归覆盖 local precheck 拒绝 `LOCAL_READY`、resume 不导入 receipt,且 HEAD、卡片和 receipt 保持不变。修复经 agent-exec transport `a06209ff0a2b23fb750528a4bec4e7e64b3f2d6c` 集成。
|
||||
- 修复验证(SELF,HEAD `a06209ff0a2b23fb750528a4bec4e7e64b3f2d6c`):`python3 -m unittest tests.test_task_recovery tests.test_evidence` 84/84 通过;`bash -n scripts/task.sh && make test-governance` 495 项通过、18 项 skip。对应完整脱敏收据由 `IMPORT_RECEIPT` 附于本卡。
|
||||
- Review:PI 独立 Review attempt `a44c5c3e758a08f3ebe5d8a59bd241fd` 以 `failed-unknown` 结束,未形成可核验结论且未产生改动;当前 host 对 `398d12141c84453fb6db3dba2eaf9a208342e29f..a06209ff0a2b23fb750528a4bec4e7e64b3f2d6c` 的实际 diff 进行独立代码审查,未发现阻塞问题。旧 head `398d12141c84453fb6db3dba2eaf9a208342e29f` 的 CI run 3959 `ci / test` 失败;原因尚未从旧 run 日志确认,修复 head 的 exact CI 尚待执行。
|
||||
- 最终已测生产代码 head `471948c59cfabff36c018f219998139fea3757fe`:host evidence+recovery 83/83 通过;`bash -n scripts/task.sh && make test-governance` 494 项通过、18 项 skip。随后 `105901595805cc5fa5ce19f1a6d48317b950d6af` 又通过 targeted unreachable-vs-reachable missing-parent 对照测试;其后未再改生产逻辑。exact final-head CI 与当前候选 Review 尚待重跑。
|
||||
- 离线 resume 已端到端验证:root 与 child 任务在捕获有效 LOCAL_READY 收据后、origin 不可达时可用 `TASK_WORK_MODE=local make task-resume` 导入;child 仅使用本地已登记 parent 并核对 `parent_head_sha`。同一环境下 delivery resume 仍以 REMOTE_UNVERIFIED 拒绝。
|
||||
- 验收补充:对照计划 U2 的“remote-tracking ref 不存在但 R0 对象本地可用”场景,host Review 发现首个修复只覆盖 receipt 捕获后 ref 消失,未覆盖离线 precheck 捕获时 ref 已缺失;随后新增 test-first 回归覆盖两种时序,并修复 `--local-context` 与 `resume-local` 的有界验证。`start` 与 `resume-delivery` 继续要求当前权威目标。
|
||||
- 影响与剩余项:原失败 receipt `/tmp/norm-remediation-u2-receipt.json`,run_id `af20482c3e24cc32f3873d1451043481`;其 worktree 保留为诊断证据。该 run `blocked`、`transport_ref=null`、`host_fallback_authorizations=0`。Development-Manager PR #26 已合入 `b178dec`;Dev gitlink adoption 仍待 initiative 集成,不在本 U2 单元内。
|
||||
|
||||
## 合入与清理
|
||||
|
||||
- Review 记录:host Review 完成(base `398d12141c84453fb6db3dba2eaf9a208342e29f`,head `a06209ff0a2b23fb750528a4bec4e7e64b3f2d6c`;结论:无阻塞 finding;独立 PI attempt failed-unknown,不计为通过)
|
||||
- 冲突记录:NOT_RUN(最新目标同步、冲突处置、组合兼容核验及复审/补测)
|
||||
- CI 记录:NOT_RUN(当前 HEAD 完整 SHA、全部必需检查、运行链接、执行身份及 GATE 结果)
|
||||
- 合入记录:NOT_RUN(交付授权范围、PR、被核验 HEAD、合入 SHA、目标分支及内容核验)
|
||||
- 清理记录:NOT_RUN(授权依据、写者退出确认、worktree/分支/任务卡处置)
|
||||
|
||||
<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->
|
||||
```json
|
||||
{
|
||||
"summary_schema": 1,
|
||||
"source": "SELF",
|
||||
"timestamp": "2026-10-18T09:51:00.301997+00:00",
|
||||
"command": "make precheck",
|
||||
"exit_code": 0,
|
||||
"cwd": "/home/weichen/projects/Agent-System-Dev",
|
||||
"before_head": "b5e23daed2f0ea46261979de1d62e4de0cfa88f9",
|
||||
"after_head": "b5e23daed2f0ea46261979de1d62e4de0cfa88f9",
|
||||
"branch": "main",
|
||||
"target_branch": "main",
|
||||
"target_head": "b5e23daed2f0ea46261979de1d62e4de0cfa88f9",
|
||||
"root_target": "main",
|
||||
"immediate_target": "main",
|
||||
"before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"output_tail": [
|
||||
"make[1]: Entering directory '/home/weichen/projects/Agent-System-Dev'",
|
||||
"[repo] branch=main head=b5e23daed2",
|
||||
"[remote] origin/main=b5e23daed2 ahead=0 behind=0",
|
||||
"[tree] clean submodules_drift=0 uninit=3",
|
||||
"[warn] 3 个子模块未初始化——构建/测试前需物化(不阻断写入)",
|
||||
"[worktrees] linked=11",
|
||||
"[recycle] merged un-recycled branches: feature/layered-python-env",
|
||||
"[initiatives] local cards (not authoritative): none",
|
||||
"[hooks] core.hooksPath=scripts/hooks(pre-commit/post-merge 实际接线)",
|
||||
"[facts] policy/facts.md 6 条事实均在有效期内",
|
||||
"[required-reading] core/baseline.md, workflows/delivery.md §1, policy/delivery.md",
|
||||
"[R0] b5e23daed2(完整:b5e23daed2f0ea46261979de1d62e4de0cfa88f9)",
|
||||
"[worktree] before-sync: recursive component checks passed",
|
||||
"[verdict] READY — 保留本次 precheck receipt 后建任务工作区:PRECHECK_RECEIPT=/tmp/task-precheck.json make task-start NAME=feature/your-branch",
|
||||
"make[1]: Leaving directory '/home/weichen/projects/Agent-System-Dev'"
|
||||
],
|
||||
"output_sha256": "d33d43216a047c642360e2afa99b4d72781f81698ca933e8687cc8127afde332",
|
||||
"receipt_sha256": "57db39b3f66caf60040427f50f45423bb2be546f4930be3696b8218f1c29f148",
|
||||
"receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/57db39b3f66caf60040427f50f45423bb2be546f4930be3696b8218f1c29f148.json"
|
||||
}
|
||||
```
|
||||
| 同步(记录前对象) | 74bd05a876136ed8506332c76d535a6eb6356433 | f65d0169f61addfc0b2a21ef3813f38b55deb687 | 2026-10-18T10:58:22Z |
|
||||
|
||||
<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->
|
||||
```json
|
||||
{
|
||||
"summary_schema": 1,
|
||||
"source": "SELF",
|
||||
"timestamp": "2026-10-18T11:14:41.060001+00:00",
|
||||
"command": "make precheck",
|
||||
"exit_code": 0,
|
||||
"cwd": "/home/weichen/projects/Agent-System-Dev-wt-feature-norm-remediation-u2",
|
||||
"before_head": "d6df427e39e2dd8f57d51d04318dedffff96523a",
|
||||
"after_head": "d6df427e39e2dd8f57d51d04318dedffff96523a",
|
||||
"branch": "feature/norm-remediation-u2",
|
||||
"target_branch": "main",
|
||||
"target_head": "ba81d638a6c845d0acde6896f4001bed096ba26c",
|
||||
"root_target": "main",
|
||||
"immediate_target": "feature/norm-remediation",
|
||||
"before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"output_tail": [
|
||||
"make[1]: Entering directory '/home/weichen/projects/Agent-System-Dev-wt-feature-norm-remediation-u2'",
|
||||
"[repo] branch=feature/norm-remediation-u2 head=d6df427e39",
|
||||
"[remote] origin/main=ba81d638a6 ahead=10 behind=12",
|
||||
"[tree] clean submodules_drift=0 uninit=0",
|
||||
"[worktrees] linked=11",
|
||||
"[recycle] merged un-recycled branches: feature/layered-python-env",
|
||||
"[initiatives] local cards (not authoritative): none",
|
||||
"[hooks] core.hooksPath=scripts/hooks(pre-commit/post-merge 实际接线)",
|
||||
"[facts] policy/facts.md 6 条事实均在有效期内",
|
||||
"[required-reading] core/baseline.md, workflows/delivery.md §1, policy/delivery.md",
|
||||
"[target] ba81d638a6c845d0acde6896f4001bed096ba26c(observed; task initial baseline is reported separately)",
|
||||
"[task-state] registered=/home/weichen/projects/Agent-System-Dev-wt-feature-norm-remediation-u2 initial_base=b5e23daed2f0ea46261979de1d62e4de0cfa88f9",
|
||||
"[verdict] LOCAL_READY — existing task may continue authorized local work; final delivery still requires latest-target sync and checks",
|
||||
"make[1]: Leaving directory '/home/weichen/projects/Agent-System-Dev-wt-feature-norm-remediation-u2'"
|
||||
],
|
||||
"output_sha256": "9875968d327058ec22a07453a7c748f5c7b3557fdc8d05355b907b8458d8fa6d",
|
||||
"receipt_sha256": "00e543451c4435f6669c74d51a6a678db2cd40a0498a56ae377447044adce7f8",
|
||||
"receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/00e543451c4435f6669c74d51a6a678db2cd40a0498a56ae377447044adce7f8.json"
|
||||
}
|
||||
```
|
||||
|
||||
<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->
|
||||
```json
|
||||
{
|
||||
"summary_schema": 1,
|
||||
"source": "SELF",
|
||||
"timestamp": "2026-10-18T11:39:55.756192+00:00",
|
||||
"command": "python3 -m unittest tests.test_evidence tests.test_task_recovery",
|
||||
"exit_code": 0,
|
||||
"cwd": "/home/weichen/projects/Agent-System-Dev-wt-feature-norm-remediation-u2",
|
||||
"before_head": "1e26b1cccdcfe8ac83a004915cad591d27eed91c",
|
||||
"after_head": "1e26b1cccdcfe8ac83a004915cad591d27eed91c",
|
||||
"branch": "feature/norm-remediation-u2",
|
||||
"target_branch": "main",
|
||||
"target_head": "ba81d638a6c845d0acde6896f4001bed096ba26c",
|
||||
"root_target": "main",
|
||||
"immediate_target": "feature/norm-remediation",
|
||||
"before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"output_tail": [
|
||||
"..................................................................",
|
||||
"----------------------------------------------------------------------",
|
||||
"Ran 66 tests in 18.749s",
|
||||
"",
|
||||
"OK"
|
||||
],
|
||||
"output_sha256": "ed93f5670caa3576929bab841735c1ad873975985fcf1a6575ce88bad5510aa7",
|
||||
"receipt_sha256": "d08508edc20bdd35b340355c5b1a9295ddce5f866d1706f34afc8f76dcf1d939",
|
||||
"receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/d08508edc20bdd35b340355c5b1a9295ddce5f866d1706f34afc8f76dcf1d939.json"
|
||||
}
|
||||
```
|
||||
|
||||
## 闭合记录(合入前折叠)
|
||||
|
||||
- 本卡在 PR #618(https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/618)合入前闭合并归档,随该 PR 一同合入。
|
||||
- 该 PR 的合并提交即本任务的落地记录。
|
||||
- 合入后无需回填,也无需单独的闭合 PR。
|
||||
|
||||
<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->
|
||||
```json
|
||||
{
|
||||
"summary_schema": 1,
|
||||
"source": "SELF",
|
||||
"timestamp": "2026-10-18T16:13:57.369094+00:00",
|
||||
"command": "python3 -m unittest tests.test_task_recovery tests.test_evidence",
|
||||
"exit_code": 0,
|
||||
"cwd": "/home/weichen/projects/Agent-System-Dev-wt-feature-norm-remediation-u2",
|
||||
"before_head": "a06209ff0a2b23fb750528a4bec4e7e64b3f2d6c",
|
||||
"after_head": "a06209ff0a2b23fb750528a4bec4e7e64b3f2d6c",
|
||||
"branch": "feature/norm-remediation-u2",
|
||||
"target_branch": "main",
|
||||
"target_head": "eef7e5e778cabcb6216f74b74213a3d7511f383b",
|
||||
"root_target": "main",
|
||||
"immediate_target": "feature/norm-remediation",
|
||||
"before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"output_tail": [
|
||||
"....................................................................................",
|
||||
"----------------------------------------------------------------------",
|
||||
"Ran 84 tests in 31.295s",
|
||||
"",
|
||||
"OK"
|
||||
],
|
||||
"output_sha256": "7cf18f380130a11f0494ce26e3e331cb9c6532d790a869414b68b3cb5a8161f1",
|
||||
"receipt_sha256": "4054bd2917a76495eec89a929dea0929ed7ab7af633ca99424950baf300a56b4",
|
||||
"receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/4054bd2917a76495eec89a929dea0929ed7ab7af633ca99424950baf300a56b4.json"
|
||||
}
|
||||
```
|
||||
|
||||
<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->
|
||||
```json
|
||||
{
|
||||
"summary_schema": 1,
|
||||
"source": "SELF",
|
||||
"timestamp": "2026-10-18T16:14:30.451262+00:00",
|
||||
"command": "bash -n scripts/task.sh && make test-governance",
|
||||
"exit_code": 0,
|
||||
"cwd": "/home/weichen/projects/Agent-System-Dev-wt-feature-norm-remediation-u2",
|
||||
"before_head": "a06209ff0a2b23fb750528a4bec4e7e64b3f2d6c",
|
||||
"after_head": "a06209ff0a2b23fb750528a4bec4e7e64b3f2d6c",
|
||||
"branch": "feature/norm-remediation-u2",
|
||||
"target_branch": "main",
|
||||
"target_head": "eef7e5e778cabcb6216f74b74213a3d7511f383b",
|
||||
"root_target": "main",
|
||||
"immediate_target": "feature/norm-remediation",
|
||||
"before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"output_tail": [
|
||||
"test_feature_missing_parent_is_allowed (test_write_guard.WriteGuardTests.test_feature_missing_parent_is_allowed) ... ok",
|
||||
"test_invalid_json_is_denied (test_write_guard.WriteGuardTests.test_invalid_json_is_denied) ... ok",
|
||||
"test_missing_parent_on_main_is_denied (test_write_guard.WriteGuardTests.test_missing_parent_on_main_is_denied) ... ok",
|
||||
"test_missing_unknown_and_malformed_write_input_are_denied (test_write_guard.WriteGuardTests.test_missing_unknown_and_malformed_write_input_are_denied) ... ok",
|
||||
"test_non_git_destination_is_explicitly_outside_scope (test_write_guard.WriteGuardTests.test_non_git_destination_is_explicitly_outside_scope) ... ok",
|
||||
"test_nonwrite_tools_are_outside_scope (test_write_guard.WriteGuardTests.test_nonwrite_tools_are_outside_scope) ... ok",
|
||||
"test_relative_path_uses_hook_cwd_not_process_cwd (test_write_guard.WriteGuardTests.test_relative_path_uses_hook_cwd_not_process_cwd) ... ok",
|
||||
"test_symlink_into_main_with_missing_parents_is_denied (test_write_guard.WriteGuardTests.test_symlink_into_main_with_missing_parents_is_denied) ... ok",
|
||||
"test_valid_multi_file_patch_on_feature_is_allowed (test_write_guard.WriteGuardTests.test_valid_multi_file_patch_on_feature_is_allowed) ... ok",
|
||||
"",
|
||||
"----------------------------------------------------------------------",
|
||||
"Ran 495 tests in 364.208s",
|
||||
"",
|
||||
"OK (skipped=18)",
|
||||
"make[1]: Leaving directory '/home/weichen/projects/Agent-System-Dev-wt-feature-norm-remediation-u2'"
|
||||
],
|
||||
"output_sha256": "3edcb54df736b840127f6b466cac1f4046632e81f38fa02fe96cdb95cee0b87f",
|
||||
"receipt_sha256": "ae37da5e99243a166bafa46ff6bf95cb704de194338a669d929f409cb540768f",
|
||||
"receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/ae37da5e99243a166bafa46ff6bf95cb704de194338a669d929f409cb540768f.json"
|
||||
}
|
||||
```
|
||||
+15
-2
@@ -42,7 +42,10 @@ make task-start NAME=feature/my-task TRIAGE_RECEIPT=/tmp/my-task-triage.json PRE
|
||||
恢复已有登记任务先捕获本次启动检查,再在原任务 worktree 用
|
||||
`make task-resume PRECHECK_RECEIPT=/tmp/my-task-resume.json` 验证并原样导入;不重新建分支。
|
||||
默认 `TASK_WORK_MODE=delivery`,继续要求严格 READY 和当前目标一致。
|
||||
工作区忙或落后时先由 owner 保存现场并同步;远端不可达时只继续不依赖新鲜基线的阅读和分析。
|
||||
工作区忙或落后时先由 owner 保存现场并同步。远端不可达时,delivery 恢复仍以 REMOTE_UNVERIFIED 拒绝;
|
||||
显式 `TASK_WORK_MODE=local` 可在先核验收据、活动任务、原 R0/HEAD/工作区范围后导入 LOCAL_READY 收据,
|
||||
继续已授权局部工作(子任务仅在远端不可查询时以已登记的本地父分支为参考,且须包含记录的 `parent_head_sha`)。
|
||||
远端可达但目标或已登记父分支缺失(即使本地同名分支存在)、回退/分叉或无法比较时仍以 REMOTE_UNVERIFIED 拒绝,不导入收据。离线不得建任务、同步、PR 检查、Review/合入或交付,本地参考不视为最新或可交付。
|
||||
|
||||
局部恢复接口已具备,正式交付仍按[合入调度](delivery.md#合入调度)的单一最终窗口执行。
|
||||
启用后,既有任务可以显式选择 local,保持原 R0,标记落后,继续已授权局部工作:
|
||||
@@ -53,7 +56,17 @@ TASK_WORK_MODE=local make task-resume PRECHECK_RECEIPT=/tmp/my-local-resume.json
|
||||
```
|
||||
|
||||
LOCAL_READY 只用于局部恢复;新任务入口始终校验严格 READY,正式验收与合入另行同步。
|
||||
恢复核验实际 worktree/卡片/分支、R0 祖先关系及源码外原始收据;脏现场、Git 操作、组件漂移和未知远端仍拒绝。
|
||||
恢复核验实际 worktree/卡片/分支、R0 祖先关系及源码外原始收据;脏现场、Git 操作和组件漂移仍拒绝。
|
||||
`resume-local` 只接受 LOCAL_READY 收据,且须同时满足:卡片位于本 worktree 的 `.task/` 且状态未以“已”开头
|
||||
(已完成/已放弃等闭合任务拒绝);收据内 `[task-state]` 的登记路径与 R0 等于当前登记任务;收据前后与当前 HEAD
|
||||
一致、工作区干净且组件状态未变;收据 `root_target/immediate_target` 等于卡片范围;目标只允许快进落后,回退或分叉拒绝。
|
||||
远端跟踪目标 ref 缺失时,local 模式的 precheck 与 `resume-local` 只在当前登记任务身份、本地 R0 对象、R0 为本地 HEAD 祖先、
|
||||
工作区干净、分支/worktree、组件与范围全部核验通过时继续。缺失或过期的远端 ref 从不证明新鲜度;只要有任一目标历史可用,
|
||||
其祖先关系须一致。local precheck 刷新失败时,远端不可查询按上述本地核验继续;远端可查询时,以缓存目标(无缓存时为活动任务卡
|
||||
登记的 R0)为基线,通告提交须本地存在且为基线后代才继续,目标 ref 缺失、回退/分叉或通告提交本地缺失无法比较均判 REMOTE_UNVERIFIED。
|
||||
正式 Review 与合入仍须先完成新鲜的权威同步。
|
||||
`resume-delivery` 仍要求 READY 且收据目标等于当前权威目标。LOCAL_READY 始终是 SELF 本地工作记录,
|
||||
不能用于建任务、正式交付、Review、合入、CI、GATE 或 REPLAY。
|
||||
旧收据允许作为历史证据原样导入,不自动成为当前恢复或交付准入;格式继续为 schema 1。
|
||||
|
||||
堆叠(standalone)任务使用 `PARENT=feature/parent PARENT_PR=<父PR链接>`;子任务改用 `KIND=child`。父分支须包含本批主干 R0;
|
||||
|
||||
+47
-14
@@ -27,6 +27,7 @@ BRANCH = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,199}\Z")
|
||||
# 可选的分支目标字段:两者须同时出现或同时缺省(旧收据没有)。
|
||||
TARGET_FIELDS = ("root_target", "immediate_target")
|
||||
PRE_MERGE_CLOSED = "已完成(合入前闭合)"
|
||||
TASK_STATE = re.compile(r"^\[task-state\] registered=(.+) initial_base=([0-9a-f]+)$", re.M)
|
||||
BASE_FIELDS = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir",
|
||||
"before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"}
|
||||
|
||||
@@ -240,7 +241,7 @@ def read_receipt(filename):
|
||||
return value
|
||||
|
||||
|
||||
def task_context(current=None):
|
||||
def task_context(current=None, require_target=True):
|
||||
"""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()
|
||||
@@ -269,10 +270,11 @@ def task_context(current=None):
|
||||
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"]:
|
||||
if not current["target_head"] and require_target:
|
||||
raise EvidenceError("Task target is unverified; fetch it first")
|
||||
for tip in (current["head"], current["target_head"]):
|
||||
git("merge-base", "--is-ancestor", r0, tip)
|
||||
if tip:
|
||||
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:
|
||||
@@ -295,12 +297,13 @@ def validate_precheck(receipt, purpose="start"):
|
||||
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"]:
|
||||
if after["target_branch"] != current["target_branch"] or (
|
||||
not after["target_head"] and purpose != "resume-local"):
|
||||
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)
|
||||
context = task_context(current, require_target=purpose != "resume-local")
|
||||
try:
|
||||
Path(receipt["cwd"]).resolve().relative_to(context["root"])
|
||||
except ValueError:
|
||||
@@ -308,12 +311,38 @@ def validate_precheck(receipt, purpose="start"):
|
||||
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 after["target_head"]:
|
||||
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
|
||||
if after["target_head"] and current["target_head"]:
|
||||
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
|
||||
validate_local_scope(receipt, current, context)
|
||||
|
||||
|
||||
def require_active_task(root):
|
||||
"""Closed, archived or abandoned cards cannot authorize local continuation."""
|
||||
card = find_card()
|
||||
status = re.search(r";状态:\s*([^;\s][^;\n]*)", card.read_text(encoding="utf-8"))
|
||||
if card.resolve().parent != root / ".task" or not status or status.group(1).startswith("已"):
|
||||
raise EvidenceError("Local continuation requires an active registered task")
|
||||
|
||||
|
||||
def validate_local_scope(receipt, current, context):
|
||||
"""LOCAL_READY continues one active registered task; it remains SELF local-work evidence only."""
|
||||
require_active_task(context["root"])
|
||||
states = TASK_STATE.findall(receipt["output"])
|
||||
if len(states) != 1 or Path(states[0][0]) != context["root"] or states[0][1] != context["r0"]:
|
||||
raise EvidenceError("Precheck belongs to a different task identity or initial baseline")
|
||||
for obj in (receipt["before"], receipt["after"]):
|
||||
if obj["status"] or obj["untracked"] or obj["tracked_diff_sha256"] != digest(b""):
|
||||
raise EvidenceError("Precheck workspace was not clean")
|
||||
if obj["submodules"] != current["submodules"]:
|
||||
raise EvidenceError("Precheck component state has changed")
|
||||
if tuple(receipt.get(key) for key in TARGET_FIELDS) != task_targets():
|
||||
raise EvidenceError("Precheck authorization scope differs from the registered task")
|
||||
|
||||
|
||||
def find_card():
|
||||
@@ -443,8 +472,10 @@ 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()
|
||||
if sys.argv[1:] in (["--local-context"], ["--local-context", "--active"]):
|
||||
context = task_context(require_target=False)
|
||||
if sys.argv[2:]:
|
||||
require_active_task(context["root"])
|
||||
print("[task-state] registered=" + str(context["root"]) + " initial_base=" + context["r0"])
|
||||
return 0
|
||||
if sys.argv[1:]:
|
||||
@@ -457,8 +488,10 @@ def main():
|
||||
if imported or validated:
|
||||
receipt = read_receipt(imported or validated)
|
||||
if validated:
|
||||
validate_precheck(receipt, os.environ.get("VALIDATE_RECEIPT_PURPOSE", "start"))
|
||||
report(receipt, "precheck validated")
|
||||
purpose = os.environ.get("VALIDATE_RECEIPT_PURPOSE", "start")
|
||||
validate_precheck(receipt, purpose)
|
||||
report(receipt, "local continuation validated (local work only; not delivery evidence)"
|
||||
if purpose == "resume-local" else "precheck validated")
|
||||
else:
|
||||
card = find_card()
|
||||
append_card(card, receipt)
|
||||
|
||||
+142
-19
@@ -18,6 +18,44 @@ fetch_target() {
|
||||
git rev-parse --verify "refs/remotes/origin/${TARGET_BRANCH}^{commit}" >/dev/null \
|
||||
|| { echo "[verdict] REMOTE_UNVERIFIED — target commit missing" >&2; return 6; }
|
||||
}
|
||||
origin_transport_unavailable() { # $1=失败的远端命令 stderr → 仅明确的传输失败(DNS/连接超时/拒绝/重置/无路由,或本地缺失仓库路径)返回 0;认证/授权/权限与未知错误返回 1
|
||||
local err=$1 url path
|
||||
if printf '%s\n' "$err" | grep -qiE 'authentication failed|permission denied|unauthori[sz]ed|forbidden|HTTP[^0-9]*40[13]|\b40[13]\b|could not read (username|password)|terminal prompts disabled|invalid credentials|access denied|host key verification failed'; then
|
||||
return 1
|
||||
fi
|
||||
if printf '%s\n' "$err" | grep -qiE 'could not resolve (host|hostname|proxy)|name or service not known|temporary failure in name resolution|nodename nor servname|connection timed out|operation timed out|timed out after|connection refused|connection reset|no route to host|network is unreachable|couldn.t connect to server'; then
|
||||
return 0
|
||||
fi
|
||||
printf '%s\n' "$err" | grep -qF 'does not appear to be a git repository' || return 1
|
||||
url=$(git remote get-url origin 2>/dev/null) || return 1
|
||||
case "$url" in
|
||||
file://*) path=${url#file://} ;;
|
||||
/*|./*|../*) path=$url ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
[ ! -e "$path" ]
|
||||
}
|
||||
target_history() { # $1=可选基线(如任务 R0;无缓存目标时代替比较)→ 输出 ok(通告提交为基线后代;传输不可达时缓存目标为基线后代或无缓存目标)、rewound(回退或分叉)或 uncomparable(认证/授权/未知错误、目标 ref 缺失、无基线、提交本地缺失或无法比较)
|
||||
local base=${1:-} cached listing advertised rc=0
|
||||
cached=$(git rev-parse -q --verify "refs/remotes/origin/${TARGET_BRANCH}^{commit}" 2>/dev/null) || cached=
|
||||
if ! listing=$(git ls-remote origin "refs/heads/${TARGET_BRANCH}" 2>&1); then
|
||||
if ! origin_transport_unavailable "$listing"; then echo uncomparable
|
||||
elif [ -z "$cached" ]; then echo ok
|
||||
elif [ -z "$base" ]; then echo uncomparable
|
||||
else
|
||||
git merge-base --is-ancestor "$base" "$cached" 2>/dev/null || rc=$?
|
||||
case "$rc" in 0) echo ok ;; 1) echo rewound ;; *) echo uncomparable ;; esac
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
[ -n "$cached" ] || cached=$base
|
||||
advertised=$(printf '%s\n' "$listing" \
|
||||
| awk -v ref="refs/heads/${TARGET_BRANCH}" '$2 == ref && !seen { print $1; seen = 1 }')
|
||||
[ -n "$advertised" ] && [ -n "$cached" ] || { echo uncomparable; return 0; }
|
||||
git cat-file -e "${advertised}^{commit}" 2>/dev/null || { echo uncomparable; return 0; }
|
||||
git merge-base --is-ancestor "$cached" "$advertised" 2>/dev/null || rc=$?
|
||||
case "$rc" in 0) echo ok ;; 1) echo rewound ;; *) echo uncomparable ;; esac
|
||||
}
|
||||
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() {
|
||||
@@ -31,23 +69,38 @@ cmd_precheck() {
|
||||
need_repo
|
||||
local mode
|
||||
mode=$(work_mode)
|
||||
fetch_target
|
||||
local branch head rmain dirty sub_drift sub_uninit
|
||||
# local 模式刷新失败只标记远端新鲜度不可用,不拿缓存远端引用冒充最新;delivery 保持 REMOTE_UNVERIFIED。
|
||||
local remote_fresh=1 fetch_err=""
|
||||
if [ "$mode" = local ]; then
|
||||
git check-ref-format "refs/heads/${TARGET_BRANCH}" >/dev/null || die "invalid target branch"
|
||||
fetch_err=$(fetch_target 2>&1 >/dev/null) || remote_fresh=0
|
||||
else
|
||||
fetch_target
|
||||
fi
|
||||
local branch head rmain="" dirty sub_drift sub_uninit
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
head=$(git rev-parse HEAD)
|
||||
rmain=$(git rev-parse --verify "origin/${TARGET_BRANCH}")
|
||||
[ "$remote_fresh" -eq 1 ] && rmain=$(git rev-parse --verify "origin/${TARGET_BRANCH}")
|
||||
dirty=$(git status --porcelain | { grep -v '^.. \.zcode/' || true; } | wc -l)
|
||||
sub_drift=$(git submodule status --recursive 2>/dev/null | { grep -cE '^\+|^U' || true; })
|
||||
sub_uninit=$(git submodule status --recursive 2>/dev/null | { grep -cE '^-' || true; })
|
||||
local behind ahead
|
||||
behind=$(git rev-list --count "HEAD..origin/${TARGET_BRANCH}")
|
||||
ahead=$(git rev-list --count "origin/${TARGET_BRANCH}..HEAD")
|
||||
local behind=0 ahead=0
|
||||
echo "[repo] branch=${branch} head=$(echo "$head" | cut -c1-10)"
|
||||
echo "[remote] origin/${TARGET_BRANCH}=$(echo "$rmain" | cut -c1-10) ahead=${ahead} behind=${behind}"
|
||||
if [ "$remote_fresh" -eq 1 ]; then
|
||||
behind=$(git rev-list --count "HEAD..origin/${TARGET_BRANCH}")
|
||||
ahead=$(git rev-list --count "origin/${TARGET_BRANCH}..HEAD")
|
||||
echo "[remote] origin/${TARGET_BRANCH}=$(echo "$rmain" | cut -c1-10) ahead=${ahead} behind=${behind}"
|
||||
else
|
||||
echo "[remote] origin/${TARGET_BRANCH}=unavailable — refresh failed; remote freshness unverified (cached ref not used)"
|
||||
[ -n "$fetch_err" ] && printf '%s\n' "$fetch_err" | sed -e '/^\[verdict\]/d' -e 's/^/[remote] /'
|
||||
fi
|
||||
echo "[tree] $([ "$dirty" -eq 0 ] && echo clean || echo "dirty(${dirty})") submodules_drift=${sub_drift} uninit=${sub_uninit}"
|
||||
local wt merged
|
||||
wt=$(git worktree list | tail -n +2 | wc -l)
|
||||
merged=$(git branch --merged "origin/${TARGET_BRANCH}" 2>/dev/null | { grep -vE 'main|master|\*' || true; } | tr -d ' +')
|
||||
merged=""
|
||||
if git rev-parse -q --verify "refs/remotes/origin/${TARGET_BRANCH}^{commit}" >/dev/null 2>&1; then
|
||||
merged=$(git branch --merged "origin/${TARGET_BRANCH}" 2>/dev/null | { grep -vE 'main|master|\*' || true; } | tr -d ' +')
|
||||
fi
|
||||
[ "${sub_uninit:-0}" -gt 0 ] && echo "[warn] ${sub_uninit} 个子模块未初始化——构建/测试前需物化(不阻断写入)"
|
||||
echo "[worktrees] linked=${wt}"
|
||||
[ -n "$merged" ] && echo "[recycle] merged un-recycled branches: $(echo $merged)"
|
||||
@@ -116,7 +169,9 @@ 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(树脏=他人或未收尾现场,不得写入)")"
|
||||
if [ "$mode" = local ]; then
|
||||
if [ "$mode" = local ] && [ "$remote_fresh" -eq 0 ]; then
|
||||
echo "[target] unavailable(remote freshness unverified; task initial baseline is reported separately)"
|
||||
elif [ "$mode" = local ]; then
|
||||
echo "[target] ${rmain}(observed; task initial baseline is reported separately)"
|
||||
else
|
||||
echo "[R0] $(echo "$rmain" | cut -c1-10)(完整:$rmain)"
|
||||
@@ -126,7 +181,19 @@ cmd_precheck() {
|
||||
exit 3
|
||||
fi
|
||||
if [ "$mode" = local ]; then
|
||||
python3 "${SCRIPT_DIR}/evidence.py" --local-context || return 2
|
||||
if [ "$remote_fresh" -eq 0 ]; then
|
||||
# 以活动任务卡登记的 R0 为比较基线(不把它当作远端最新值);先于任务上下文核验,使缓存目标与 R0 矛盾时给出明确判定。
|
||||
case "$(target_history "$(card_r0 "$(branch_card "$branch")")")" in
|
||||
rewound)
|
||||
echo "[verdict] REMOTE_UNVERIFIED — origin/${TARGET_BRANCH} rewound or diverged from the known target; preserve local work and reconcile the target history"
|
||||
return 6 ;;
|
||||
uncomparable)
|
||||
echo "[verdict] REMOTE_UNVERIFIED — origin/${TARGET_BRANCH} is missing or its advertised commit cannot be compared with the known target; preserve local work and retry the refresh"
|
||||
return 6 ;;
|
||||
esac
|
||||
fi
|
||||
python3 "${SCRIPT_DIR}/evidence.py" --local-context --active || return 2
|
||||
[ "$remote_fresh" -eq 1 ] || echo "[remote-freshness] unavailable — LOCAL_READY rests on local task context only"
|
||||
echo "[verdict] LOCAL_READY — existing task may continue authorized local work; final delivery still requires latest-target sync and checks"
|
||||
return 0
|
||||
fi
|
||||
@@ -442,9 +509,9 @@ cmd_resume() {
|
||||
need_repo
|
||||
local mode
|
||||
mode=$(work_mode)
|
||||
fetch_target
|
||||
[ "$mode" = local ] || fetch_target
|
||||
[ -n "${PRECHECK_RECEIPT:-}" ] || die "PRECHECK_RECEIPT required: capture the current task precheck before resume"
|
||||
local receipt
|
||||
local receipt remote_fresh=1
|
||||
receipt=$(realpath -- "$PRECHECK_RECEIPT")
|
||||
VALIDATE_RECEIPT="$receipt" VALIDATE_RECEIPT_PURPOSE="resume-${mode}" bash "${SCRIPT_DIR}/evidence.sh"
|
||||
# 立即目标:子任务为其父 initiative(delivery 下取远端头),其余为根目标。
|
||||
@@ -452,14 +519,54 @@ cmd_resume() {
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
card=$(branch_card "$branch")
|
||||
[ -n "$card" ] && immediate=$(card_immediate_target "$card")
|
||||
if [ "$mode" = local ]; then
|
||||
# local 先核验收据与活动任务再刷新:刷新成功按新目标复核;失败只在远端确实不可达时以登记 R0 继续,
|
||||
# 远端可达而目标缺失、回退/分叉或无法比较时保持 REMOTE_UNVERIFIED,不导入收据。
|
||||
git check-ref-format "refs/heads/${TARGET_BRANCH}" >/dev/null || die "invalid target branch"
|
||||
if fetch_target 2>/dev/null; then
|
||||
VALIDATE_RECEIPT="$receipt" VALIDATE_RECEIPT_PURPOSE=resume-local bash "${SCRIPT_DIR}/evidence.sh"
|
||||
else
|
||||
remote_fresh=0
|
||||
local r0 ls_rc=0
|
||||
[ -n "$card" ] || die "no registered task card for ${branch}; receipt not imported"
|
||||
r0=$(card_r0 "$card")
|
||||
[ -n "$r0" ] || die "task card has no initial baseline; receipt not imported"
|
||||
git ls-remote --exit-code --heads origin "refs/heads/${TARGET_BRANCH}" >/dev/null 2>&1 || ls_rc=$?
|
||||
if [ "$ls_rc" -eq 2 ]; then
|
||||
echo "[verdict] REMOTE_UNVERIFIED — origin is reachable but ${TARGET_BRANCH} is missing; preserve local work, receipt not imported" >&2
|
||||
return 6
|
||||
fi
|
||||
case "$(target_history "$r0")" in
|
||||
rewound)
|
||||
echo "[verdict] REMOTE_UNVERIFIED — origin/${TARGET_BRANCH} rewound or diverged from the known target; preserve local work, receipt not imported" >&2
|
||||
return 6 ;;
|
||||
uncomparable)
|
||||
echo "[verdict] REMOTE_UNVERIFIED — advertised origin/${TARGET_BRANCH} cannot be compared with the known target; preserve local work, receipt not imported" >&2
|
||||
return 6 ;;
|
||||
esac
|
||||
echo "[remote-freshness] unavailable — resuming on registered R0=${r0} for authorized local work only; delivery still requires a fresh target"
|
||||
fi
|
||||
fi
|
||||
if [ "$immediate" != "${TARGET_BRANCH}" ]; then
|
||||
observed=$(resolve_parent_ref "$immediate") || die "immediate target ${immediate} unavailable; receipt not imported"
|
||||
recorded=$(card_field "$card" parent_head_sha)
|
||||
if [ "$mode" = local ]; then
|
||||
# local 续作只以登记的父头为比较基准:缺失、非完整 SHA 或本地无此提交时不得回落到本地父分支。
|
||||
local sha_len=40
|
||||
[ "$(git rev-parse --show-object-format 2>/dev/null)" = sha256 ] && sha_len=64
|
||||
if ! [[ "$recorded" =~ ^[0-9a-f]+$ ]] || [ "${#recorded}" -ne "$sha_len" ] \
|
||||
|| ! git cat-file -e "${recorded}^{commit}" 2>/dev/null; then
|
||||
echo "[verdict] REMOTE_UNVERIFIED — registered parent_head_sha for ${immediate} is missing, malformed or not available locally; preserve local work, receipt not imported" >&2
|
||||
return 6
|
||||
fi
|
||||
fi
|
||||
observed=$(resolve_parent_ref "$immediate") || die "immediate target ${immediate} unavailable; receipt not imported"
|
||||
if [ -n "$recorded" ] && ! git merge-base --is-ancestor "$recorded" "$observed"; then
|
||||
die "immediate target ${immediate} no longer contains recorded parent head ${recorded}; receipt not imported"
|
||||
fi
|
||||
else
|
||||
elif [ "$remote_fresh" -eq 1 ]; then
|
||||
observed=$(git rev-parse "origin/${TARGET_BRANCH}")
|
||||
else
|
||||
observed=unavailable
|
||||
fi
|
||||
IMPORT_RECEIPT="$receipt" bash "${SCRIPT_DIR}/evidence.sh"
|
||||
echo "[resumed] work_mode=${mode} source_head=$(git rev-parse HEAD) observed_target=${observed} immediate_target=${immediate}"
|
||||
@@ -538,6 +645,7 @@ card_status() { sed -n 's/^.*;状态:\([^;]*\).*/\1/p' "$1" | head -1; }
|
||||
# 两级分支元数据行:'- 分支类型:<kind>;initiative_id:…;root_target:…;immediate_target:…;accepting_requirements:…'。
|
||||
# 旧卡没有该行:按 standalone、root/immediate 均为目标分支、不接收新需求处理。
|
||||
card_field() { sed -n "s/^- 分支类型:.*;$2:\([^;]*\).*/\1/p" "$1" 2>/dev/null | head -1; }
|
||||
card_r0() { sed -n 's/^- 权威仓:.*;R0(initial_base_sha)= \([0-9a-f]\+\)[[:space:]]*$/\1/p' "$1" 2>/dev/null | head -1; }
|
||||
card_branch_kind() { local v; v=$(sed -n 's/^- 分支类型:\([^;]*\).*/\1/p' "$1" 2>/dev/null | head -1); printf '%s\n' "${v:-standalone}"; }
|
||||
card_initiative_id() { card_field "$1" initiative_id; }
|
||||
card_root_target() { local v; v=$(card_field "$1" root_target); printf '%s\n' "${v:-${TARGET_BRANCH}}"; }
|
||||
@@ -595,7 +703,7 @@ except Stop as error:
|
||||
sys.exit("ERROR: " + error.message)
|
||||
sys.exit(0 if any(row["子任务分支"].strip("`").strip() == sys.argv[2] for row in rows) else 1)' "$SCRIPT_DIR" "$1"
|
||||
}
|
||||
resolve_parent_ref() { # $1=父 initiative 分支 → 打印用于同步/核验的提交(delivery 仅远端;local 取远端与本地包含对方的一侧)
|
||||
resolve_parent_ref() { # $1=父 initiative 分支 → 打印用于同步/核验的提交(delivery 仅远端;local 取远端与本地包含对方的一侧,远端可达但父分支缺失返回 6,仅远端不可查询时取本地)
|
||||
git check-ref-format "refs/heads/$1" >/dev/null || die "invalid parent branch $1"
|
||||
if [ "$(work_mode)" = delivery ]; then
|
||||
# delivery 只认 origin 上刚取回的父分支头:本地领先/分叉意味着未准入的提交,不得被同步或合入。
|
||||
@@ -608,17 +716,32 @@ resolve_parent_ref() { # $1=父 initiative 分支 → 打印用于同步/核验
|
||||
printf '%s\n' "$remote_head"
|
||||
return 0
|
||||
fi
|
||||
local remote="" local_ref="" rc=0
|
||||
git ls-remote --exit-code --heads origin "refs/heads/$1" >/dev/null 2>&1 || rc=$?
|
||||
local remote="" local_ref="" rc=0 ls_err
|
||||
ls_err=$(git ls-remote --exit-code --heads origin "refs/heads/$1" 2>&1 >/dev/null) || rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
if ! git fetch -q origin "refs/heads/$1:refs/remotes/origin/$1"; then
|
||||
echo "[verdict] REMOTE_UNVERIFIED — cannot refresh origin/$1; preserve local work and retry" >&2
|
||||
return 6
|
||||
fi
|
||||
remote=$(git rev-parse --verify "refs/remotes/origin/$1^{commit}")
|
||||
elif [ "$rc" -ne 2 ]; then
|
||||
echo "[verdict] REMOTE_UNVERIFIED — cannot query origin/$1; preserve local work and retry" >&2
|
||||
elif [ "$rc" -eq 2 ]; then
|
||||
# 远端可达而登记的父分支缺失:权威侧已不认该父分支,本地同名分支不得替代。
|
||||
echo "[verdict] REMOTE_UNVERIFIED — origin is reachable but $1 is missing; preserve local work and reconcile the parent initiative" >&2
|
||||
return 6
|
||||
elif ! origin_transport_unavailable "$ls_err"; then
|
||||
# 认证/授权/权限或未知错误不等于远端不可达:权威侧状态未知,不得以本地父分支替代。
|
||||
echo "[verdict] REMOTE_UNVERIFIED — cannot verify origin/$1 (non-transport remote error); preserve local work and retry" >&2
|
||||
return 6
|
||||
else
|
||||
# 远端传输不可达时只把已登记的本地父分支作为局部续作参考;不声称其为最新或可交付,调用方仍核验 parent_head_sha。
|
||||
local_ref=$(git rev-parse -q --verify "refs/heads/$1^{commit}" 2>/dev/null || true)
|
||||
if [ -z "$local_ref" ]; then
|
||||
echo "[verdict] REMOTE_UNVERIFIED — cannot query origin/$1 and no local $1 exists; preserve local work and retry" >&2
|
||||
return 6
|
||||
fi
|
||||
echo "[remote-freshness] origin/$1 unavailable — using local $1 for authorized local continuation only; not current or delivery-ready" >&2
|
||||
printf '%s\n' "$local_ref"
|
||||
return 0
|
||||
fi
|
||||
local_ref=$(git rev-parse -q --verify "refs/heads/$1^{commit}" 2>/dev/null || true)
|
||||
if [ -n "$remote" ] && [ -n "$local_ref" ]; then
|
||||
|
||||
@@ -3,6 +3,7 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -477,6 +478,215 @@ class EvidenceTests(unittest.TestCase):
|
||||
self.assertNotEqual(self.import_receipt().returncode, 0)
|
||||
self.assertEqual(self.card.read_text(), "# Test task\n")
|
||||
|
||||
def local_task(self, status="进行中"):
|
||||
"""Register a clean linked task worktree whose precheck prints the real local task context."""
|
||||
wt = self.base.resolve() / "task-wt"
|
||||
self.git("worktree", "add", "-qb", "feature/local", str(wt), self.head)
|
||||
(wt / ".task").mkdir()
|
||||
(wt / ".task/feature-local.md").write_text(
|
||||
f"# Local task\n任务主记录:fixture;状态:{status};owner:Evidence Test\n"
|
||||
f"- 权威仓:fixture;目标分支:main;R0(initial_base_sha)= {self.head}\n"
|
||||
f"- 工作区:{wt}(worktree);分支:feature/local\n")
|
||||
(wt / "Makefile").write_text(
|
||||
f"VERDICT ?= LOCAL_READY\nprecheck:\n\t@python3 {SCRIPTS / 'evidence.py'} --local-context\n"
|
||||
"\t@echo '[verdict] $(VERDICT) — fixture'\n")
|
||||
self.git("add", ".task", "Makefile", cwd=wt)
|
||||
self.git("commit", "-qm", "Register local task", cwd=wt)
|
||||
return wt
|
||||
|
||||
def validate(self, wt, purpose="resume-local", **values):
|
||||
return self.run_evidence(cwd=wt, VALIDATE_RECEIPT=self.receipt,
|
||||
VALIDATE_RECEIPT_PURPOSE=purpose, **values)
|
||||
|
||||
def advance_target(self):
|
||||
head = self.git("commit-tree", "-p", self.head, "-m", "Advance target", self.head + "^{tree}")
|
||||
self.git("update-ref", "refs/remotes/origin/main", head)
|
||||
|
||||
def test_local_continuation_accepts_registered_task_with_known_lag(self):
|
||||
wt = self.local_task()
|
||||
self.capture("make precheck", cwd=wt)
|
||||
result = self.validate(wt)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("local continuation validated", result.stdout)
|
||||
self.assertIn("src=SELF", result.stdout)
|
||||
self.advance_target()
|
||||
lagging = self.validate(wt)
|
||||
self.assertEqual(lagging.returncode, 0, lagging.stderr)
|
||||
|
||||
def test_local_continuation_accepts_registered_task_without_remote_tracking_ref(self):
|
||||
wt = self.local_task()
|
||||
self.capture("make precheck", cwd=wt)
|
||||
task_head = self.git("rev-parse", "HEAD", cwd=wt)
|
||||
self.git("update-ref", "-d", "refs/remotes/origin/main")
|
||||
# The R0 commit recorded in the task card must remain reachable locally
|
||||
# even after the remote-tracking ref is removed.
|
||||
self.git("cat-file", "-e", self.head)
|
||||
result = self.validate(wt)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("local continuation validated", result.stdout)
|
||||
self.assertEqual(self.git("rev-parse", "HEAD", cwd=wt), task_head)
|
||||
|
||||
def test_local_continuation_accepts_when_target_ref_reappears(self):
|
||||
"""A resume-local receipt captured with no remote-tracking target ref
|
||||
validates after a forward target ref reappears, provided R0 stays locally
|
||||
reachable and the reappeared target is a forward descendant of R0.
|
||||
|
||||
The receipt records target_head=None; the rewind check is skipped because
|
||||
either side is missing, and the reappeared ref (a forward descendant of R0)
|
||||
must satisfy the ancestry guard that the registered task is still current."""
|
||||
wt = self.local_task()
|
||||
head_before = self.git("rev-parse", "HEAD", cwd=wt)
|
||||
# Drop refs/remotes/origin/main BEFORE capture so the receipt records
|
||||
# target_head=None. R0 (the recorded initial baseline) must remain
|
||||
# reachable as a local object.
|
||||
self.git("update-ref", "-d", "refs/remotes/origin/main")
|
||||
self.git("cat-file", "-e", self.head)
|
||||
self.capture("make precheck", cwd=wt)
|
||||
captured = json.loads(self.receipt.read_text())
|
||||
self.assertIsNone(captured["after"]["target_head"])
|
||||
# Recreate refs/remotes/origin/main as a forward descendant of R0.
|
||||
forward = self.git("commit-tree", "-p", self.head,
|
||||
"-m", "Forward target ref reappeared", self.head + "^{tree}")
|
||||
self.git("update-ref", "refs/remotes/origin/main", forward)
|
||||
# Validation must succeed: receipt has no target_head (rewind check is
|
||||
# skipped) and the reappeared target descends from R0 (ancestry guard
|
||||
# accepts a forward target ref).
|
||||
result = self.validate(wt)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("local continuation validated", result.stdout)
|
||||
self.assertEqual(self.git("rev-parse", "HEAD", cwd=wt), head_before)
|
||||
|
||||
def test_local_precheck_captures_without_remote_tracking_ref(self):
|
||||
wt = self.local_task()
|
||||
head_before = self.git("rev-parse", "HEAD", cwd=wt)
|
||||
# Remove the remote-tracking ref BEFORE capturing, so the precheck fixture
|
||||
# itself must tolerate the missing refs/remotes/origin/main. The existing
|
||||
# --local-context rejects this and the capture fails; recording that
|
||||
# expected red result here.
|
||||
self.git("update-ref", "-d", "refs/remotes/origin/main")
|
||||
# The R0 commit recorded on the task card must remain reachable locally
|
||||
# even after the remote-tracking ref is removed.
|
||||
self.git("cat-file", "-e", self.head)
|
||||
result = self.run_evidence("make precheck", cwd=wt, RECEIPT=self.receipt)
|
||||
self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
|
||||
self.assertTrue(self.receipt.exists(), "capture must write a receipt")
|
||||
data = json.loads(self.receipt.read_text())
|
||||
self.assertEqual(data["exit_code"], 0, data["output"])
|
||||
self.assertIn("[verdict] LOCAL_READY", data["output"])
|
||||
states = re.findall(r"^\[task-state\] registered=(.+) initial_base=([0-9a-f]+)$",
|
||||
data["output"], re.M)
|
||||
self.assertEqual(len(states), 1, data["output"])
|
||||
registered, initial_base = states[0]
|
||||
self.assertEqual(Path(registered).resolve(), wt.resolve())
|
||||
self.assertEqual(initial_base, self.head)
|
||||
self.assertEqual(self.git("rev-parse", "HEAD", cwd=wt), head_before)
|
||||
|
||||
def test_local_ready_never_authorizes_start_delivery_or_promoted_source(self):
|
||||
wt = self.local_task()
|
||||
self.capture("make precheck", cwd=wt)
|
||||
for purpose in ("start", "resume-delivery"):
|
||||
with self.subTest(purpose=purpose):
|
||||
result = self.validate(wt, purpose)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("Precheck receipt is not READY", result.stderr)
|
||||
for source in ("GATE", "REPLAY"):
|
||||
with self.subTest(source=source):
|
||||
result = self.validate(wt, SRC=source)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("Local evidence is SELF", result.stderr)
|
||||
|
||||
def test_delivery_resume_still_requires_ready_and_current_target(self):
|
||||
wt = self.local_task()
|
||||
self.capture("make precheck", cwd=wt, VERDICT="READY")
|
||||
local = self.validate(wt)
|
||||
self.assertNotEqual(local.returncode, 0)
|
||||
self.assertIn("Precheck receipt is not LOCAL_READY", local.stderr)
|
||||
current = self.validate(wt, "resume-delivery")
|
||||
self.assertEqual(current.returncode, 0, current.stderr)
|
||||
self.assertIn("precheck validated", current.stdout)
|
||||
self.advance_target()
|
||||
stale = self.validate(wt, "resume-delivery")
|
||||
self.assertNotEqual(stale.returncode, 0)
|
||||
self.assertIn("authoritative target has changed", stale.stderr)
|
||||
|
||||
def test_local_continuation_rejects_closed_task(self):
|
||||
wt = self.local_task(status="已完成(合入前闭合)")
|
||||
self.capture("make precheck", cwd=wt)
|
||||
result = self.validate(wt)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("active registered task", result.stderr)
|
||||
|
||||
def test_local_continuation_rejects_dirty_workspace_or_changed_head(self):
|
||||
wt = self.local_task()
|
||||
self.capture("make precheck", cwd=wt)
|
||||
(wt / "extra").write_text("unsaved\n")
|
||||
dirty = self.validate(wt)
|
||||
self.assertNotEqual(dirty.returncode, 0)
|
||||
self.assertIn("Task workspace is not ready", dirty.stderr)
|
||||
(wt / "extra").unlink()
|
||||
self.git("commit", "--allow-empty", "-qm", "Advance task", cwd=wt)
|
||||
moved = self.validate(wt)
|
||||
self.assertNotEqual(moved.returncode, 0)
|
||||
self.assertIn("source HEAD has changed", moved.stderr)
|
||||
|
||||
def test_local_continuation_rejects_mismatched_identity_baseline_workspace_or_scope(self):
|
||||
wt = self.local_task()
|
||||
original = self.capture("make precheck", cwd=wt)
|
||||
identity = "different task identity or initial baseline"
|
||||
cases = (
|
||||
("task identity", identity,
|
||||
lambda d: d.update(output=d["output"].replace(str(wt), str(self.base / "other")))),
|
||||
("initial baseline", identity,
|
||||
lambda d: d.update(output=d["output"].replace(self.head, "0" * 40))),
|
||||
("missing identity", identity,
|
||||
lambda d: d.update(output="[verdict] LOCAL_READY — fixture\n")),
|
||||
("dirty receipt", "workspace was not clean",
|
||||
lambda d: d["after"].update(status="?? extra\n", untracked={"extra": None})),
|
||||
("tracked diff", "workspace was not clean",
|
||||
lambda d: d["before"].update(tracked_diff_sha256="1" * 64)),
|
||||
("target scope", "authorization scope",
|
||||
lambda d: d.update(immediate_target="feature/other")),
|
||||
("missing scope", "authorization scope",
|
||||
lambda d: [d.pop(key) for key in ("root_target", "immediate_target")]),
|
||||
)
|
||||
for name, message, change in cases:
|
||||
with self.subTest(case=name):
|
||||
data = json.loads(json.dumps(original))
|
||||
change(data)
|
||||
data["output_sha256"] = hashlib.sha256(data["output"].encode()).hexdigest()
|
||||
self.resign(data)
|
||||
result = self.validate(wt)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn(message, result.stderr)
|
||||
|
||||
def test_local_continuation_rejects_receipt_target_branch_mismatch(self):
|
||||
"""Resume-local must refuse a receipt whose before.target_branch or
|
||||
after.target_branch points at a different branch than the registered
|
||||
task target. Re-sign the fixture receipt with the existing helper so
|
||||
the digest still validates, and assert the existing production
|
||||
rejection that flags the receipt as belonging to a different task
|
||||
branch (or, when the after snapshot already disagrees, the
|
||||
authoritative-target-changed guard that fires first)."""
|
||||
wt = self.local_task()
|
||||
original = self.capture("make precheck", cwd=wt)
|
||||
cases = (
|
||||
("before.target_branch",
|
||||
"Precheck belongs to a different task branch",
|
||||
lambda d: d["before"].update(target_branch="feature/other")),
|
||||
("after.target_branch",
|
||||
"Precheck authoritative target has changed",
|
||||
lambda d: d["after"].update(target_branch="feature/other")),
|
||||
)
|
||||
for name, message, change in cases:
|
||||
with self.subTest(case=name):
|
||||
data = json.loads(json.dumps(original))
|
||||
change(data)
|
||||
data["output_sha256"] = hashlib.sha256(data["output"].encode()).hexdigest()
|
||||
self.resign(data)
|
||||
result = self.validate(wt)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn(message, result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+1088
-7
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user