Merge pull request 'Allow safe local task continuation (U2)' (#618) from feature/norm-remediation-u2 into feature/norm-remediation
ci / svc-realcombo (pull_request) Successful in 2m14s
ci / test (pull_request) Successful in 7m27s
ci / test-pg (pull_request) Successful in 8m6s

This commit was merged in pull request #618.
This commit is contained in:
2026-10-18 16:43:37 +00:00
6 changed files with 1804 additions and 42 deletions
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+210
View File
@@ -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()
File diff suppressed because it is too large Load Diff