diff --git a/.task/archive/feature-norm-remediation-u2.md b/.task/archive/feature-norm-remediation-u2.md new file mode 100644 index 00000000..d49aa673 --- /dev/null +++ b/.task/archive/feature-norm-remediation-u2.md @@ -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) + + +| 时间(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/` 已知分叉于登记 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/分支/任务卡处置) + + +```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 | + + +```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" +} +``` + + +```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。 + + +```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" +} +``` + + +```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" +} +``` diff --git a/policy/governance.md b/policy/governance.md index 41904cbe..3fe29728 100644 --- a/policy/governance.md +++ b/policy/governance.md @@ -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; diff --git a/scripts/evidence.py b/scripts/evidence.py index a40a5e84..3e3d7e92 100644 --- a/scripts/evidence.py +++ b/scripts/evidence.py @@ -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) diff --git a/scripts/task.sh b/scripts/task.sh index a64e6d01..4cfdccea 100755 --- a/scripts/task.sh +++ b/scripts/task.sh @@ -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; } # 两级分支元数据行:'- 分支类型:;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 diff --git a/tests/test_evidence.py b/tests/test_evidence.py index 01fc018b..fac6bae3 100644 --- a/tests/test_evidence.py +++ b/tests/test_evidence.py @@ -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() diff --git a/tests/test_task_recovery.py b/tests/test_task_recovery.py index f37dc9c5..ab2966db 100644 --- a/tests/test_task_recovery.py +++ b/tests/test_task_recovery.py @@ -1,6 +1,8 @@ """Local recovery is task-scoped and cannot grant delivery readiness.""" import json import os +import shlex +import subprocess import sys import unittest @@ -43,6 +45,24 @@ class TaskRecoveryTests(unittest.TestCase): f.git(self.wt, "add", str(self.card.relative_to(self.wt))) f.git(self.wt, "commit", "-m", "Update fixture registration") + def local_authorization_scope(self): + """Capture the explicit registered in-progress task used as local-mode authorization. + + The card carries the task scope (worktree path, branch, target branch, and R0 + initial baseline) that authorize LOCAL_READY under a registered task, regardless + of whether the authoritative origin is currently reachable. This small synthetic + fixture exposes that scope as a single value so offline-local tests can name it.""" + f = self.fixture + branch = f.git(self.wt, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() + r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip() + return { + "wt": self.wt, + "card": self.card, + "branch": branch, + "target_branch": "main", + "r0": r0, + } + def test_local_precheck_allows_known_lag_without_changing_head(self): f = self.fixture head = f.git(self.wt, "rev-parse", "HEAD").stdout @@ -88,17 +108,148 @@ class TaskRecoveryTests(unittest.TestCase): self.save_card_change() self.assertNotEqual(self.precheck().returncode, 0) - def test_local_precheck_rejects_dirty_state_and_unknown_remote(self): + def test_local_precheck_distinguishes_dirty_local_ready_and_remote_unverified(self): + """A registered in-progress task with explicit authorization and scope distinguishes + SHARED_WORKSPACE_BUSY (dirty), LOCAL_READY (offline, registered, HEAD unchanged), + and REMOTE_UNVERIFIED (delivery, origin unavailable).""" f = self.fixture + scope = self.local_authorization_scope() + self.assertEqual(scope["branch"], "feature/test") + self.assertTrue(scope["card"].is_file()) + self.assertEqual(scope["target_branch"], "main") + + # (1) Dirty workspace still yields SHARED_WORKSPACE_BUSY under the registered scope + # even when the authoritative origin is reachable. Clean before (2). (self.wt / "tracked").write_text("preserve current edit\n") - result = self.precheck() - self.assertNotEqual(result.returncode, 0) - self.assertIn("SHARED_WORKSPACE_BUSY", result.stdout) + dirty = self.precheck() + self.assertNotEqual(dirty.returncode, 0, dirty.stdout) + self.assertIn("[verdict] SHARED_WORKSPACE_BUSY", dirty.stdout) + self.assertNotIn("[verdict] LOCAL_READY", dirty.stdout) f.git(self.wt, "commit", "-am", "Save owner edit") + + head_before = f.git(self.wt, "rev-parse", "HEAD").stdout f.git(self.wt, "remote", "set-url", "origin", str(f.root / "missing.git")) - result = self.precheck() - self.assertNotEqual(result.returncode, 0) - self.assertIn("REMOTE_UNVERIFIED", result.stdout) + + # (2) Same unavailable origin; local mode against a registered in-progress task + # returns LOCAL_READY and leaves HEAD untouched. + local = self.precheck(mode="local") + self.assertEqual(local.returncode, 0, local.stdout) + self.assertIn("[verdict] LOCAL_READY", local.stdout) + self.assertNotIn("[verdict] REMOTE_UNVERIFIED", local.stdout) + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before) + + # (3) Same unavailable origin; delivery mode continues to refuse with + # REMOTE_UNVERIFIED because no read has refreshed the authoritative target. + delivery = self.precheck(mode="delivery") + self.assertNotEqual(delivery.returncode, 0, delivery.stdout) + self.assertIn("[verdict] REMOTE_UNVERIFIED", delivery.stdout) + self.assertNotIn("[verdict] LOCAL_READY", delivery.stdout) + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before) + + def test_local_precheck_rejects_closed_task(self): + """A closed registered task (已完成(合入前闭合)) must not advertise LOCAL_READY. + + The current local precheck emits LOCAL_READY for any registered in-progress + card text and never inspects the card status, so this test records the + expected red result: the verdict is wrong and the exit code is wrong.""" + f = self.fixture + self.card.write_text(self.card.read_text().replace("状态:进行中", + "状态:已完成(合入前闭合)")) + self.save_card_change() + self.assertIn("已完成(合入前闭合)", self.card.read_text()) + head_before = f.git(self.wt, "rev-parse", "HEAD").stdout + local = self.precheck() + self.assertNotEqual(local.returncode, 0, local.stdout) + self.assertNotIn("[verdict] LOCAL_READY", local.stdout) + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before) + + def test_local_precheck_rejects_reachable_target_rewind(self): + """Origin is reachable but its target was rewound after the task cached a + newer descendant. Local precheck must not emit LOCAL_READY under that + divergence — the cached refs/remotes/origin/main is no longer trustworthy. + + The current implementation lets fetch_target() surface a non-FFREMOTE_UNVERIFIED + line, then unconditionally prints LOCAL_READY in local mode. The exit code + is wrong and the verdict is wrong; this test records both expected failures.""" + f = self.fixture + r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip() + # (1) Advance the remote and let local precheck refresh the cached origin/main. + f.advance_remote() + refresh = self.precheck() + self.assertEqual(refresh.returncode, 0, refresh.stdout) + forward = f.git(self.wt, "rev-parse", "origin/main").stdout.strip() + self.assertNotEqual(forward, r0) + # (2) Rewind the bare remote target back to R0; the worktree cache still + # points at the forward descendant because we have not fetched since. + f.git(f.remote, "update-ref", "refs/heads/main", r0, forward) + self.assertEqual(f.git(f.remote, "rev-parse", "refs/heads/main").stdout.strip(), r0) + self.assertEqual(f.git(self.wt, "rev-parse", "origin/main").stdout.strip(), forward) + head_before = f.git(self.wt, "rev-parse", "HEAD").stdout + # (3) Local precheck sees a reachable origin whose target is older than the + # cache. fetch_target() refuses the non-fast-forward update; the precheck + # must still refuse LOCAL_READY. + local = self.precheck() + self.assertNotEqual(local.returncode, 0, local.stdout) + self.assertNotIn("[verdict] LOCAL_READY", local.stdout) + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before) + # After the failed fetch the cache still points at the forward descendant + # so a follow-up run can repeat the same check. + self.assertEqual(f.git(self.wt, "rev-parse", "origin/main").stdout.strip(), forward) + + def test_local_precheck_rejects_reachable_target_divergence(self): + """Origin is reachable but its target was replaced by a divergent commit + that shares R0 with the cached forward target without descending from it. + Local precheck must refuse LOCAL_READY and surface the divergence so the + owner can reconcile the target history instead of trusting the cache. + + Both the cached forward target and the divergent commit are locally + available so target_rewound() can compare them via merge-base.""" + f = self.fixture + r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip() + # (1) Advance the remote and let local precheck refresh the cached origin/main. + f.advance_remote() + refresh = self.precheck() + self.assertEqual(refresh.returncode, 0, refresh.stdout) + forward = f.git(self.wt, "rev-parse", "origin/main").stdout.strip() + self.assertNotEqual(forward, r0) + # (2) Build a divergent commit on top of R0 in the main checkout; it shares + # R0 with the cached forward target but does not descend from it. + f.git(f.repo, "switch", "--detach", r0) + (f.repo / "divergent-main").write_text("different target history\n") + f.git(f.repo, "add", "divergent-main") + f.git(f.repo, "commit", "-m", "Create divergent fixture history") + divergent = f.git(f.repo, "rev-parse", "HEAD").stdout.strip() + # Both targets must share R0 (otherwise they would just be unrelated refs). + self.assertEqual(f.git(f.repo, "merge-base", forward, divergent).stdout.strip(), r0) + # The cached forward target must NOT be an ancestor of the divergent commit. + self.assertEqual(f.git(f.repo, "merge-base", "--is-ancestor", forward, + divergent, check=False).returncode, 1) + # Both commits must be locally available so target_rewound() can inspect them. + self.assertEqual(f.git(self.wt, "cat-file", "-e", forward + "^{commit}", + check=False).returncode, 0) + self.assertEqual(f.git(self.wt, "cat-file", "-e", divergent + "^{commit}", + check=False).returncode, 0) + # (3) Push the divergent commit so the bare remote's object database sees it, + # then replace refs/heads/main on the bare remote with the divergent commit. + f.git(f.repo, "push", "origin", "HEAD:refs/heads/divergent") + f.git(f.remote, "update-ref", "refs/heads/main", divergent, forward) + self.assertEqual(f.git(f.remote, "rev-parse", "refs/heads/main").stdout.strip(), divergent) + # The worktree cache still points at the forward descendant because we have + # not fetched since. + self.assertEqual(f.git(self.wt, "rev-parse", "origin/main").stdout.strip(), forward) + head_before = f.git(self.wt, "rev-parse", "HEAD").stdout + # (4) Local precheck: fetch_target() refuses the non-fast-forward update to + # the divergent commit, target_rewound() then surfaces the divergence, and + # the precheck must not advertise LOCAL_READY. + local = self.precheck() + self.assertNotEqual(local.returncode, 0, local.stdout) + self.assertNotIn("[verdict] LOCAL_READY", local.stdout) + self.assertIn("[verdict] REMOTE_UNVERIFIED", local.stdout) + self.assertIn("rewound or diverged", local.stdout) + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before) + # After the failed fetch the cache still points at the forward descendant + # so a follow-up run can repeat the same check. + self.assertEqual(f.git(self.wt, "rev-parse", "origin/main").stdout.strip(), forward) def test_unknown_work_mode_is_rejected(self): result = self.precheck(mode="anything") @@ -240,6 +391,936 @@ class TaskRecoveryTests(unittest.TestCase): "VALIDATE_RECEIPT_PURPOSE": "ignore"}) self.assertNotEqual(result.returncode, 0, result.stdout) + def test_local_resume_continues_when_origin_is_unreachable(self): + """Standalone: capture a valid TASK_WORK_MODE=local precheck receipt, + point the disposable worktree's origin URL at a nonexistent path, + then assert (a) `task resume` in local mode imports the receipt and + leaves HEAD unchanged and (b) the same receipt under delivery mode + rejects with REMOTE_UNVERIFIED and does not import. + + Currently RED on (a): cmd_resume calls fetch_target unconditionally, + so the unreachable origin aborts the resume before the receipt can + be imported (set -e trips, returncode != 0, no [resumed], receipt + bytes unchanged, card untouched). Post-fix the local path must + skip or survive the unreachable fetch and import the receipt on + the registered in-progress task context. + + Delivery must remain strict under the same offline origin — the + RED-capture only records what (a) requires; (b) stays correct.""" + f = self.fixture + receipt = self.capture() + original = receipt.read_bytes() + receipt_sha = json.loads(original)["receipt_sha256"] + head = f.git(self.wt, "rev-parse", "HEAD").stdout + # Break the disposable worktree's origin URL so the offline + # scenario is real (the fetch in cmd_resume loses its target). + # The bare-remote fixture and the registered HEAD remain + # identifiable on the worktree fs; only the authoritative URL + # is changed for the duration of this test. + f.git(self.wt, "remote", "set-url", "origin", str(f.root / "missing.git")) + # (1) Local mode must continue authorized work by importing the + # captured receipt under the registered in-progress task context. + local = self.resume(receipt, mode="local") + self.assertEqual(local.returncode, 0, + f"local resume should import the captured receipt under the " + f"registered task (RED: fetch_target aborts the resume before " + f"IMPORT_RECEIPT; returncode={local.returncode}); stdout={local.stdout}") + self.assertIn("[resumed]", local.stdout) + self.assertIn("work_mode=local", local.stdout) + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head) + self.assertEqual(receipt.read_bytes(), original) + self.assertIn(receipt_sha, self.card.read_text()) + # (2) Same receipt under delivery mode must stay strict under + # the same offline origin: reject with REMOTE_UNVERIFIED, no + # `[resumed]` line, the card is not updated to claim delivery + # authorization, and HEAD remains untouched. + card_before_delivery = self.card.read_bytes() + delivery = self.resume(receipt, mode="delivery") + self.assertNotEqual(delivery.returncode, 0, delivery.stdout) + self.assertIn("[verdict] REMOTE_UNVERIFIED", delivery.stdout) + self.assertNotIn("[resumed]", delivery.stdout) + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head) + self.assertEqual(self.card.read_bytes(), card_before_delivery) + + def test_local_child_resume_uses_local_parent_when_origin_is_unreachable(self): + """Child: configure the task card as child with + immediate_target=feature/parent, create that parent branch locally + at the recorded parent_head_sha, capture a local receipt, make + origin unreachable, and assert that `task resume` in local mode + imports the receipt using the authorized local parent reference + (the local refs/heads/feature/parent tip = parent_head_sha). + + Currently RED: cmd_resume reaches fetch_target before resolving + the parent reference, so the unreachable origin aborts the + resume before the receipt can be imported (returncode != 0, no + `[resumed]`, receipt bytes unchanged, child card untouched). + Post-fix the local path must skip or survive the unreachable + fetch AND let resolve_parent_ref fall back to the authorized + local parent branch when origin is unqueriable.""" + f = self.fixture + r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip() + # (1) Create the parent initiative branch in the main repo so + # the parent has a deterministic tip commit, push it to the + # bare remote so the worktree can fetch it once at the start, + # then create the child branch on top of the parent inside the + # worktree and switch the worktree to it. + f.git(f.repo, "checkout", "-b", "feature/parent") + (f.repo / "parent-tip").write_text("authorized parent initiative tip\n") + f.git(f.repo, "add", "parent-tip") + f.git(f.repo, "commit", "-m", "Parent initiative tip commit") + parent_head = f.git(f.repo, "rev-parse", "HEAD").stdout.strip() + f.git(f.repo, "push", "origin", "feature/parent") + # Bring the parent branch into the worktree, branch off the + # parent at the recorded tip, then check the worktree out on + # the child branch. + f.git(self.wt, "fetch", "origin", + "feature/parent:refs/remotes/origin/feature/parent") + f.git(self.wt, "branch", "feature/parent-child", parent_head) + f.git(self.wt, "checkout", "feature/parent-child") + # (2) Write the child card on the child branch so branch_card + # finds it (the file is named after the branch slug) and the + # resume's immediate_target + parent_head_sha fields resolve + # to feature/parent and the recorded parent tip. + child_card = self.wt / ".task" / "feature-parent-child.md" + self.assertFalse(child_card.exists()) + child_card.parent.mkdir(exist_ok=True) + child_card.write_text( + "# feature/parent-child\n" + "任务主记录:fixture;状态:进行中;owner:Lifecycle Test\n" + f"- 权威仓:fixture;目标分支:main;R0(initial_base_sha)= {r0}\n" + f"- 工作区:{self.wt}(worktree);分支:feature/parent-child\n" + "- 分支类型:child;initiative_id:feature-parent;root_target:main;" + "immediate_target:feature/parent;accepting_requirements:false;" + "parent_branch:feature/parent;" + f"parent_head_sha:{parent_head};" + "parent_pr:无;parent_source:local(非正式准入)\n" + ) + f.git(self.wt, "add", str(child_card.relative_to(self.wt))) + f.git(self.wt, "commit", "-m", "Register child fixture task") + # (3) Capture a local receipt on the child branch. + receipt = self.capture() + original = receipt.read_bytes() + receipt_sha = json.loads(original)["receipt_sha256"] + head = f.git(self.wt, "rev-parse", "HEAD").stdout + # (4) Break the origin URL so the offline scenario is real. + f.git(self.wt, "remote", "set-url", "origin", str(f.root / "missing.git")) + # (5) Local resume must import the receipt using the + # authorized local parent reference and leave HEAD unchanged. + result = self.resume(receipt, mode="local") + self.assertEqual(result.returncode, 0, + f"local child resume should import the captured receipt via the " + f"local parent ref (RED: fetch_target aborts the resume before " + f"resolve_parent_ref / IMPORT_RECEIPT; returncode={result.returncode}); " + f"stdout={result.stdout}") + self.assertIn("[resumed]", result.stdout) + self.assertIn("work_mode=local", result.stdout) + self.assertIn(f"observed_target={parent_head}", result.stdout) + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head) + self.assertEqual(receipt.read_bytes(), original) + self.assertIn(receipt_sha, child_card.read_text()) + + def test_local_child_resume_rejects_reachable_missing_parent(self): + """Child: configure the task card as child with + immediate_target=feature/parent, create that parent branch on the + bare remote AND locally at the recorded parent_head_sha, capture + a local receipt, then remove ONLY refs/heads/feature/parent from + the bare remote while keeping origin reachable (the bare remote + still serves refs/heads/main). `task resume` in local mode must + refuse with REMOTE_UNVERIFIED instead of silently accepting the + local parent reference: the registered immediate parent branch + is absent remotely, so the local-only reference cannot be + reconciled against the authoritative origin even though it + exists in the worktree. + + Currently RED: resolve_parent_ref() does not distinguish + `ls-remote rc=2` (ref missing on a reachable origin) from the + unreachable branch. The function falls through the + `elif [ $rc -ne 2 ]` guard and lets `local_ref` satisfy the + resolver, so cmd_resume reaches IMPORT_RECEIPT and prints + `[resumed]` even though origin is fully reachable and just + missing the registered parent ref. Post-fix the local path must + print `[verdict] REMOTE_UNVERIFIED — origin is reachable but + feature/parent is missing; preserve local work, receipt not + imported` and refuse to import the receipt. + + The fixture mirrors + test_local_child_resume_uses_local_parent_when_origin_is_unreachable + but adds the local refs/heads/feature/parent branch and only + deletes the bare remote ref — the worktree URL stays on the + disposable bare remote and main remains fetchable, so the only + authority gap is the absent remote parent ref.""" + f = self.fixture + r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip() + # (1) Create the parent initiative branch in the main repo and + # push it to the bare remote so the worktree can fetch it once + # at the start, then create the local refs/heads/feature/parent + # branch inside the worktree (the post-fix path must refuse + # even when this local branch exists), branch off the parent + # tip into feature/parent-child, then switch the worktree to + # the child branch. + f.git(f.repo, "checkout", "-b", "feature/parent") + (f.repo / "parent-tip").write_text("authorized parent initiative tip\n") + f.git(f.repo, "add", "parent-tip") + f.git(f.repo, "commit", "-m", "Parent initiative tip commit") + parent_head = f.git(f.repo, "rev-parse", "HEAD").stdout.strip() + f.git(f.repo, "push", "origin", "feature/parent") + f.git(self.wt, "fetch", "origin", + "feature/parent:refs/remotes/origin/feature/parent") + # The shared refs/heads namespace means the worktree already + # sees refs/heads/feature/parent (it was created in f.repo via + # `checkout -b feature/parent`); the test must reject even + # when this local branch exists, so leave it intact. + self.assertEqual( + f.git(self.wt, "rev-parse", "--verify", + "refs/heads/feature/parent").stdout.strip(), parent_head) + f.git(self.wt, "branch", "feature/parent-child", parent_head) + f.git(self.wt, "checkout", "feature/parent-child") + # (2) Write the child card on the child branch so branch_card + # finds it (the file is named after the branch slug) and the + # resume's immediate_target + parent_head_sha fields resolve + # to feature/parent and the recorded parent tip. + child_card = self.wt / ".task" / "feature-parent-child.md" + self.assertFalse(child_card.exists()) + child_card.parent.mkdir(exist_ok=True) + child_card.write_text( + "# feature/parent-child\n" + "任务主记录:fixture;状态:进行中;owner:Lifecycle Test\n" + f"- 权威仓:fixture;目标分支:main;R0(initial_base_sha)= {r0}\n" + f"- 工作区:{self.wt}(worktree);分支:feature/parent-child\n" + "- 分支类型:child;initiative_id:feature-parent;root_target:main;" + "immediate_target:feature/parent;accepting_requirements:false;" + "parent_branch:feature/parent;" + f"parent_head_sha:{parent_head};" + "parent_pr:无;parent_source:local(非正式准入)\n" + ) + f.git(self.wt, "add", str(child_card.relative_to(self.wt))) + f.git(self.wt, "commit", "-m", "Register child fixture task") + # (3) Capture a local receipt on the child branch BEFORE + # removing the bare remote ref, so the receipt reflects the + # state where the remote parent ref is still present. + receipt = self.capture() + original = receipt.read_bytes() + receipt_sha = json.loads(original)["receipt_sha256"] + head = f.git(self.wt, "rev-parse", "HEAD").stdout + child_card_before = child_card.read_bytes() + # (4) Remove ONLY refs/heads/feature/parent from the bare + # remote. The worktree's origin URL still points at the + # disposable bare remote and refs/heads/main stays intact, so + # the only gap in authority is the absent remote parent ref. + # The local refs/heads/feature/parent branch remains in the + # worktree (this is the "even if a local parent branch + # exists" half of the scenario). + self.assertEqual( + f.git(f.remote, "rev-parse", "--verify", + "refs/heads/feature/parent").stdout.strip(), parent_head) + f.git(f.remote, "update-ref", "-d", "refs/heads/feature/parent") + # Confirm the bare remote now refuses to serve the parent ref + # while still serving main (so origin is reachable, just + # missing the registered immediate parent ref). + self.assertNotEqual( + f.git(f.remote, "show-ref", "--verify", "--hash", + "refs/heads/feature/parent", check=False).returncode, 0) + self.assertEqual( + f.git(f.remote, "rev-parse", "--verify", + "refs/heads/main").stdout.strip(), r0) + # (5) Local resume must refuse with REMOTE_UNVERIFIED instead + # of silently accepting the local parent ref. No [resumed] + # line, the receipt is not imported (card bytes unchanged and + # receipt_sha256 not embedded), the receipt file is byte-for- + # byte unchanged, and HEAD is preserved. + result = self.resume(receipt, mode="local") + self.assertNotEqual(result.returncode, 0, + f"local child resume must reject when origin is reachable " + f"but the registered parent ref is missing; current code " + f"falls through resolve_parent_ref's rc=2 case and accepts " + f"the local parent, returning 0 and printing [resumed]; " + f"stdout={result.stdout}") + self.assertIn("[verdict] REMOTE_UNVERIFIED", result.stdout, + "the resume must surface REMOTE_UNVERIFIED for the reachable-but-" + "missing-parent scenario so the owner can reconcile the parent " + f"initiative instead of silently resuming on an unsynced tip; " + f"stdout={result.stdout}") + self.assertNotIn("[resumed]", result.stdout, + "the resume must NOT import the receipt under a reachable " + "origin whose registered parent ref is missing — accepting " + "the local-only parent would advertise delivery readiness " + f"for an initiative the worktree cannot reconcile; stdout={result.stdout}") + self.assertEqual(child_card.read_bytes(), child_card_before, + "the child card must not be updated with receipt_sha256 when " + "the resume refuses to import the receipt") + self.assertEqual(receipt.read_bytes(), original, + "the receipt file must be byte-for-byte unchanged when the " + "resume refuses to import it") + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head, + "the child HEAD must not move when the resume refuses to " + "import the receipt") + self.assertNotIn(receipt_sha, child_card.read_text(), + "the child card must not embed the receipt sha256 when " + "the resume refuses to import the receipt") + + def test_local_precheck_rejects_reachable_uncomparable_target(self): + """Origin advertises a target SHA but the advertised object is + unavailable locally, so ancestry cannot be compared against the + cached refs/remotes/origin/main. Local precheck must not advertise + LOCAL_READY under that unverifiable state — the cached target + cannot be trusted as authoritative without an ancestry check. + + A disposable test-only git wrapper fails `fetch`, makes + `ls-remote origin refs/heads/main` advertise a synthetic SHA, and + makes `cat-file -e ^{commit}` fail; all other git calls + delegate to the real binary so the registered in-progress task + context stays observable. + + The current implementation lets target_rewound() bail out at the + cat-file -e step (return 1), then unconditionally prints + LOCAL_READY in local mode because the `&& target_rewound` guard + short-circuits. The verdict is wrong; this test records the + expected red result.""" + f = self.fixture + head_before = f.git(self.wt, "rev-parse", "HEAD").stdout + cached_before = f.git(self.wt, "rev-parse", "refs/remotes/origin/main").stdout + + # Find the real git to delegate everything but the three intercepted calls. + real_git = subprocess.check_output( + ["bash", "-c", "command -v git"], env=f.env, text=True).strip() + synthetic = "1" * 40 + bin_dir = f.root / "uncomparable-bin" + bin_dir.mkdir(exist_ok=True) + wrapper = bin_dir / "git" + wrapper.write_text( + "#!/usr/bin/env bash\n" + "# Disposable fixture wrapper for the reachable-but-uncomparable\n" + "# scenario: `fetch` fails, `ls-remote origin refs/heads/main`\n" + "# advertises a synthetic SHA, `cat-file -e ^{commit}`\n" + "# fails. Anything else delegates to the real git so the\n" + "# registered task context is observable normally.\n" + f"REAL_GIT={shlex.quote(real_git)}\n" + f"SYNTHETIC={shlex.quote(synthetic)}\n" + "WRAPPER_BIN=$(cd \"$(dirname \"$0\")\" && pwd)\n" + "stripped=\"\"\n" + "old_ifs=\"$IFS\"\n" + "IFS=':'\n" + "for p in $PATH; do\n" + " [ \"$p\" = \"$WRAPPER_BIN\" ] && continue\n" + " if [ -z \"$stripped\" ]; then stripped=\"$p\"; else stripped=\"$stripped:$p\"; fi\n" + "done\n" + "IFS=\"$old_ifs\"\n" + "export PATH=\"$stripped\"\n" + "case \"$1\" in\n" + " fetch)\n" + " echo 'fatal: unable to access origin' >&2\n" + " exit 128 ;;\n" + " ls-remote)\n" + " if [ \"${2:-}\" = origin ] && [ \"${3:-}\" = refs/heads/main ]; then\n" + " printf '%s\\trefs/heads/main\\n' \"$SYNTHETIC\"\n" + " exit 0\n" + " fi\n" + " exec \"$REAL_GIT\" \"$@\" ;;\n" + " cat-file)\n" + " if [ \"${2:-}\" = -e ] && [ \"${3:-}\" = \"${SYNTHETIC}^{commit}\" ]; then\n" + " echo \"fatal: Not a valid object name ${SYNTHETIC}^{commit}.\" >&2\n" + " exit 1\n" + " fi\n" + " exec \"$REAL_GIT\" \"$@\" ;;\n" + "esac\n" + "exec \"$REAL_GIT\" \"$@\"\n") + wrapper.chmod(0o755) + wrapper_path = f"{bin_dir}{os.pathsep}{f.env.get('PATH', os.environ['PATH'])}" + wrapper_env = f.env | {"PATH": wrapper_path} + local = f.task(self.wt, "precheck", check=False, + env=wrapper_env | {"TASK_WORK_MODE": "local"}) + self.assertNotEqual(local.returncode, 0, local.stdout) + self.assertNotIn("[verdict] LOCAL_READY", local.stdout) + self.assertIn("[verdict] REMOTE_UNVERIFIED", local.stdout) + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before) + self.assertEqual(f.git(self.wt, "rev-parse", "refs/remotes/origin/main").stdout, + cached_before) + + def write_no_cache_wrapper(self, bin_dir, advertised, log_name="git.log", + auth_fail=False): + """Create a disposable git wrapper for the missing-tracking-ref and + reachable-auth-failure scenarios. The wrapper's intercept set is + parameterised so a single helper produces both no-cache (default) + and auth-failure fixtures; each test still owns its own bin_dir so + no fixtures are shared across tests. + + When auth_fail=False (default, no-cache scenario): + fetch -> fail (128); the wrapper + writes the standard "unable to access origin" stderr line so + the precheck captures the same `fetch_err` it would observe + against a real unreachable remote + ls-remote origin refs/heads/main -> serve ADVERTISED (or + nothing when ADVERTISED is empty, simulating an origin that + has no configured target branch) + branch --merged origin/main -> DELEGATED to REAL_GIT. + The earlier stub masked a production-pipeline bug: with the + cached refs/remotes/origin/main deleted, the real git call + fails under set -e/pipefail and the precheck silently exits + before the verdict is printed. The stub has been removed so + the tests now exercise the actual missing-ref strict-shell + path; the post-fix implementation must reach LOCAL_READY (or + REMOTE_UNVERIFIED) without the helper stub, and the test + records the expected red result on current code. + + When auth_fail=True (reachable-but-auth-failure scenario): + fetch -> fail (128) and write + "fatal: Authentication failed for origin (HTTP 401)" + ls-remote origin refs/heads/main -> fail (128) and write + "fatal: Authentication failed (HTTP 401)"; no value served. + This simulates a reachable origin that refuses to disclose + its target head — the production code must NOT promote the + registered task to LOCAL_READY (or [resumed] for resume) just + because ls-remote returned an auth error instead of an + advertised SHA. + + All other git calls delegate to the real binary via the resolved + REAL_GIT absolute path (and a stripped PATH so the wrapper cannot + recurse into itself) so the registered task context stays + observable normally. + + Returns (bin_dir, wrapper_log_path).""" + f = self.fixture + real_git = subprocess.check_output( + ["bash", "-c", "command -v git"], env=f.env, text=True).strip() + bin_dir.mkdir(exist_ok=True) + wrapper_log = bin_dir / log_name + wrapper = bin_dir / "git" + if auth_fail: + header_log = "echo \"configured auth-failure-mode\" >> \"$LOG\"\n" + fetch_block = ( + " fetch)\n" + " echo \"fetch-auth-failure\" >> \"$LOG\"\n" + " echo 'fatal: Authentication failed for origin (HTTP 401)' >&2\n" + " exit 128 ;;\n" + ) + ls_remote_block = ( + " ls-remote)\n" + " if [ \"${2:-}\" = origin ] && [ \"${3:-}\" = refs/heads/main ]; then\n" + " echo \"ls-remote-auth-failure advertised-target=\" >> \"$LOG\"\n" + " echo 'fatal: Authentication failed (HTTP 401)' >&2\n" + " exit 128\n" + " fi\n" + " exec \"$REAL_GIT\" \"$@\" ;;\n" + ) + else: + header_log = f"echo \"configured advertised-target=$ADVERTISED\" >> \"$LOG\"\n" + fetch_block = ( + " fetch)\n" + " echo \"fetch-intercepted\" >> \"$LOG\"\n" + " echo 'fatal: unable to access origin' >&2\n" + " exit 128 ;;\n" + ) + ls_remote_block = ( + " ls-remote)\n" + " if [ \"${2:-}\" = origin ] && [ \"${3:-}\" = refs/heads/main ]; then\n" + " echo \"ls-remote-served advertised-target=$ADVERTISED\" >> \"$LOG\"\n" + " if [ -n \"$ADVERTISED\" ]; then\n" + " printf '%s\\trefs/heads/main\\n' \"$ADVERTISED\"\n" + " fi\n" + " exit 0\n" + " fi\n" + " exec \"$REAL_GIT\" \"$@\" ;;\n" + ) + wrapper.write_text( + "#!/usr/bin/env bash\n" + "# Disposable fixture wrapper for the missing-tracking-ref and\n" + "# reachable-auth-failure scenarios. See write_no_cache_wrapper\n" + "# docstring for the intercept set.\n" + f"REAL_GIT={shlex.quote(real_git)}\n" + f"LOG={shlex.quote(str(wrapper_log))}\n" + f"ADVERTISED={shlex.quote(advertised)}\n" + "WRAPPER_BIN=$(cd \"$(dirname \"$0\")\" && pwd)\n" + + header_log + + "stripped=\"\"\n" + "old_ifs=\"$IFS\"\n" + "IFS=':'\n" + "for p in $PATH; do\n" + " [ \"$p\" = \"$WRAPPER_BIN\" ] && continue\n" + " if [ -z \"$stripped\" ]; then stripped=\"$p\"; else stripped=\"$stripped:$p\"; fi\n" + "done\n" + "IFS=\"$old_ifs\"\n" + "export PATH=\"$stripped\"\n" + "case \"$1\" in\n" + + fetch_block + ls_remote_block + + "esac\n" + "exec \"$REAL_GIT\" \"$@\"\n") + wrapper.chmod(0o755) + return bin_dir, wrapper_log + + def test_local_precheck_without_cached_ref_rejects_uncomparable_remote(self): + """Cached refs/remotes/origin/main is absent; origin advertises a + SHA whose object is unavailable locally. Local precheck must not + advertise LOCAL_READY because ancestry cannot be compared against + any locally known target — the cached target cannot be trusted + as authoritative without an ancestry check, and the advertised + SHA is not locally reachable so no forward-descendant + relationship can be established either. + + The current implementation lets target_history() short-circuit at + the missing cached-ref step (return "ok") and unconditionally + print LOCAL_READY in local mode. The verdict is wrong; this test + records the expected red result.""" + f = self.fixture + head_before = f.git(self.wt, "rev-parse", "HEAD").stdout + r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip() + # Detach the registered branch from its (now stale) upstream so + # no in-process code accidentally queries HEAD@{u} / remote.merge + # against the deleted cached ref. + f.git(self.wt, "branch", "--unset-upstream", "feature/test") + f.git(self.wt, "update-ref", "-d", "refs/remotes/origin/main") + + synthetic = "1" * 40 + bin_dir, wrapper_log = self.write_no_cache_wrapper( + f.root / "no-cache-uncomparable-bin", synthetic) + path_env = f"{bin_dir}{os.pathsep}{f.env.get('PATH', os.environ['PATH'])}" + wrapper_env = f.env | {"PATH": path_env} + + local = f.task(self.wt, "precheck", check=False, + env=wrapper_env | {"TASK_WORK_MODE": "local"}) + + # Proof the wrapper was actually used: the configured marker + # names the advertised SHA the test asked the wrapper to serve, + # `fetch` was rejected (so the unreachable remote is observable + # to the production pipeline), and `ls-remote origin refs/heads/main` + # was answered with the synthetic SHA the wrapper was configured + # to serve. The last assertion is required because reachable + # advertised cases must verify the wrapper served the expected + # ls-remote response, not only that the wrapper initialized. + log = wrapper_log.read_text() + self.assertIn(f"configured advertised-target={synthetic}", log) + self.assertIn("fetch-intercepted", log) + self.assertIn(f"ls-remote-served advertised-target={synthetic}", log) + + self.assertNotEqual(local.returncode, 0, local.stdout) + self.assertNotIn("[verdict] LOCAL_READY", local.stdout) + self.assertIn("[verdict] REMOTE_UNVERIFIED", local.stdout) + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before) + + def test_local_precheck_without_cached_ref_rejects_missing_remote_target(self): + """Cached refs/remotes/origin/main is absent; origin returns no + configured target branch (ls-remote outputs nothing). Local + precheck must not advertise LOCAL_READY because nothing can be + verified against the registered task's R0 — the cached target + is the only handle on what origin claimed, and without it (and + without an ls-remote response) the precheck has no + authoritative signal to confirm the target even exists. + + The current implementation lets target_history() short-circuit at + the missing cached-ref step (return "ok") and unconditionally + print LOCAL_READY in local mode. The verdict is wrong; this test + records the expected red result.""" + f = self.fixture + head_before = f.git(self.wt, "rev-parse", "HEAD").stdout + r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip() + f.git(self.wt, "branch", "--unset-upstream", "feature/test") + f.git(self.wt, "update-ref", "-d", "refs/remotes/origin/main") + + bin_dir, wrapper_log = self.write_no_cache_wrapper( + f.root / "no-cache-missing-target-bin", "") + path_env = f"{bin_dir}{os.pathsep}{f.env.get('PATH', os.environ['PATH'])}" + wrapper_env = f.env | {"PATH": path_env} + + local = f.task(self.wt, "precheck", check=False, + env=wrapper_env | {"TASK_WORK_MODE": "local"}) + + log = wrapper_log.read_text() + # Empty advertised value means the wrapper serves no ls-remote line. + # The configured marker still records the empty advertised value, + # and the served marker is logged with an empty advertised-target so + # the test can confirm the wrapper was actually invoked for the + # ls-remote call (not only that the wrapper binary was loaded). + self.assertIn("configured advertised-target=", log) + self.assertIn("fetch-intercepted", log) + self.assertIn("ls-remote-served advertised-target=", log) + + self.assertNotEqual(local.returncode, 0, local.stdout) + self.assertNotIn("[verdict] LOCAL_READY", local.stdout) + self.assertIn("[verdict] REMOTE_UNVERIFIED", local.stdout) + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before) + + def test_local_precheck_without_cached_ref_allows_comparable_forward_target(self): + """Cached refs/remotes/origin/main is absent; origin advertises + a forward descendant of the task's R0 (the descendant is + locally available so ancestry can be compared against the + registered baseline). Local precheck must continue to advertise + LOCAL_READY — the explicit missing-tracking-ref continuation + contract — because the registered task context is the same as + the cached ref would have represented, and the ls-remote + response can be compared against the registered baseline to + confirm origin is at or past the task's initial state. + + The current implementation short-circuits at the missing + cached-ref step (return "ok"), which coincidentally returns + LOCAL_READY for any registered task. The verdict is right; this + test records the expected green result so the post-fix + implementation preserves the contract when the ls-remote check + is added against the registered R0.""" + f = self.fixture + head_before = f.git(self.wt, "rev-parse", "HEAD").stdout + r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip() + # Advance the bare remote so the advertised target is a real + # forward descendant of R0 (not R0 itself). The descendant must + # be locally available so the wrapper can advertise it AND the + # production `cat-file -e` check inside target_history() succeeds + # when the descendant is compared against the registered R0. + f.advance_remote() + forward = f.git(self.wt, "rev-parse", "origin/main").stdout.strip() + self.assertNotEqual(forward, r0) + self.assertEqual(f.git(f.repo, "merge-base", r0, forward).stdout.strip(), r0) + f.git(self.wt, "branch", "--unset-upstream", "feature/test") + f.git(self.wt, "update-ref", "-d", "refs/remotes/origin/main") + + bin_dir, wrapper_log = self.write_no_cache_wrapper( + f.root / "no-cache-comparable-bin", forward) + path_env = f"{bin_dir}{os.pathsep}{f.env.get('PATH', os.environ['PATH'])}" + wrapper_env = f.env | {"PATH": path_env} + + local = f.task(self.wt, "precheck", check=False, + env=wrapper_env | {"TASK_WORK_MODE": "local"}) + + log = wrapper_log.read_text() + self.assertIn(f"configured advertised-target={forward}", log) + self.assertIn("fetch-intercepted", log) + # Reachable advertised cases must verify the wrapper actually + # served the expected ls-remote response (a forward descendant of + # the registered R0), not only that the wrapper binary was loaded. + self.assertIn(f"ls-remote-served advertised-target={forward}", log) + + self.assertEqual(local.returncode, 0, local.stdout) + self.assertIn("[verdict] LOCAL_READY", local.stdout) + self.assertNotIn("[verdict] REMOTE_UNVERIFIED", local.stdout) + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before) + + def test_local_child_resume_rejects_missing_parent_sha(self): + """Child: configure the task card as child with + immediate_target=feature/parent and parent_branch=feature/parent, + create that parent branch locally (no origin fetch), branch off + it into feature/parent-child, but DELIBERATELY OMIT + parent_head_sha from the child card so the recorded parent head + is empty. Make origin unreachable, capture a local receipt, + then assert `task resume` in local mode refuses to import the + receipt. + + The registered task cannot be reconciled against any + authoritative parent head when parent_head_sha is missing + AND origin is unreachable: the local branch tip is the only + handle, but without a recorded parent_head_sha the production + code cannot verify the local tip is the tip the child branched + off of. The resume must refuse with REMOTE_UNVERIFIED, must + not emit `[resumed]`, must not import the receipt (the child + card stays byte-for-byte unchanged and the receipt_sha256 is + not embedded), and HEAD must remain untouched. + + Currently RED on current code: cmd_resume only runs the + parent_head_sha ancestry check when `[ -n "$recorded" ]` is + true; an empty parent_head_sha short-circuits the check and + lets the resume fall through to IMPORT_RECEIPT and the + `[resumed]` line. This test records the expected red result so + the post-fix implementation enforces the parent_head_sha + presence contract under the local-outage fallback path. + + Preserves test_local_child_resume_uses_local_parent_when_origin_is_unreachable + (the real-outage-with-valid-parent case) and + test_local_child_resume_rejects_reachable_missing_parent (the + reachable-known-missing-parent case).""" + f = self.fixture + r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip() + # (1) Create the parent initiative branch in the main repo so + # the parent has a deterministic tip commit, push it to the + # bare remote so the worktree can fetch it once at the start, + # then create the child branch on top of the parent inside the + # worktree and switch the worktree to it. We do NOT push or + # fetch parent in the worktree because the test explicitly + # makes origin unreachable before the resume step. + f.git(f.repo, "checkout", "-b", "feature/parent") + (f.repo / "parent-tip").write_text("authorized parent initiative tip\n") + f.git(f.repo, "add", "parent-tip") + f.git(f.repo, "commit", "-m", "Parent initiative tip commit") + parent_head = f.git(f.repo, "rev-parse", "HEAD").stdout.strip() + f.git(f.repo, "push", "origin", "feature/parent") + # The worktree shares the refs/heads namespace with f.repo, + # so `checkout -b feature/parent` in f.repo already materialises + # refs/heads/feature/parent inside the worktree. The test must + # refuse even when this local branch exists, so leave it intact. + f.git(self.wt, "branch", "feature/parent-child", parent_head) + f.git(self.wt, "checkout", "feature/parent-child") + # (2) Write the child card on the child branch so branch_card + # finds it (the file is named after the branch slug) and the + # resume's immediate_target + parent_branch fields resolve to + # feature/parent. The card DELIBERATELY OMITS parent_head_sha so + # the recorded parent head is empty. + child_card = self.wt / ".task" / "feature-parent-child.md" + self.assertFalse(child_card.exists()) + child_card.parent.mkdir(exist_ok=True) + child_card.write_text( + "# feature/parent-child\n" + "任务主记录:fixture;状态:进行中;owner:Lifecycle Test\n" + f"- 权威仓:fixture;目标分支:main;R0(initial_base_sha)= {r0}\n" + f"- 工作区:{self.wt}(worktree);分支:feature/parent-child\n" + "- 分支类型:child;initiative_id:feature-parent;root_target:main;" + "immediate_target:feature/parent;accepting_requirements:false;" + "parent_branch:feature/parent;" + "parent_pr:无;parent_source:local(非正式准入)\n" + ) + f.git(self.wt, "add", str(child_card.relative_to(self.wt))) + f.git(self.wt, "commit", "-m", "Register child fixture task without parent_head_sha") + # (3) Capture a local receipt on the child branch BEFORE + # making origin unreachable. + receipt = self.capture() + original = receipt.read_bytes() + receipt_sha = json.loads(original)["receipt_sha256"] + head = f.git(self.wt, "rev-parse", "HEAD").stdout + child_card_before = child_card.read_bytes() + # (4) Break the origin URL so the offline scenario is real. + f.git(self.wt, "remote", "set-url", "origin", str(f.root / "missing.git")) + # Confirm the local parent branch still exists at the recorded + # tip (resolve_parent_ref would otherwise fall through to a + # bare error before the missing parent_head_sha check matters). + self.assertEqual( + f.git(self.wt, "rev-parse", "--verify", + "refs/heads/feature/parent").stdout.strip(), parent_head) + # (5) Local resume must refuse with REMOTE_UNVERIFIED because + # the recorded parent_head_sha is empty: the local parent tip + # alone is not a sufficient handle for authorized continuation + # when origin is unreachable. No [resumed] line, the receipt is + # not imported (card bytes unchanged and receipt_sha256 not + # embedded), the receipt file is byte-for-byte unchanged, and + # HEAD is preserved. + result = self.resume(receipt, mode="local") + self.assertNotEqual(result.returncode, 0, + f"local child resume must reject when origin is unreachable " + f"and the recorded parent_head_sha is empty; current code " + f"short-circuits the empty recorded check and lets IMPORT_RECEIPT " + f"run, returning 0 and printing [resumed]; " + f"stdout={result.stdout}") + self.assertNotIn("[resumed]", result.stdout, + "the resume must NOT import the receipt when the registered " + "parent_head_sha is empty and origin is unreachable — the local " + "parent tip alone is not a sufficient handle for authorized " + f"continuation under the offline fallback path; stdout={result.stdout}") + self.assertIn("[verdict] REMOTE_UNVERIFIED", result.stdout, + "the resume must surface REMOTE_UNVERIFIED for the unreachable-" + "origin-and-missing-parent_head_sha scenario so the owner can " + "backfill the recorded parent head before retrying; " + f"stdout={result.stdout}") + self.assertEqual(child_card.read_bytes(), child_card_before, + "the child card must not be updated with receipt_sha256 when " + "the resume refuses to import the receipt") + self.assertEqual(receipt.read_bytes(), original, + "the receipt file must be byte-for-byte unchanged when the " + "resume refuses to import it") + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head, + "the child HEAD must not move when the resume refuses to " + "import the receipt") + self.assertNotIn(receipt_sha, child_card.read_text(), + "the child card must not embed the receipt sha256 when " + "the resume refuses to import the receipt") + + def test_local_resume_rejects_reachable_auth_failure(self): + """Standalone: capture a valid TASK_WORK_MODE=local precheck + receipt against the real bare remote (origin is reachable and + serves refs/heads/main normally), then point the worktree at a + disposable wrapper that makes `fetch` and + `ls-remote origin refs/heads/main` both fail with explicit + "Authentication failed (HTTP 401)" and run `task resume` in + local mode. + + The wrapper proves the wrapper was actually used (configured + marker, fetch-auth-failure, ls-remote-auth-failure). The resume + must NOT yield the local-continuation positive signal: it + must refuse with REMOTE_UNVERIFIED, must not emit `[resumed]`, + must not import the receipt (the standalone card stays byte- + for-byte unchanged and the receipt_sha256 is not embedded), + the receipt file must be byte-for-byte unchanged, and HEAD + must remain untouched. + + Currently RED on current code: when fetch and ls-remote both + fail (auth error), `target_history` sees an empty listing and + short-circuits to "ok", so the unreachable-fallback branch + falls through to IMPORT_RECEIPT and the `[resumed]` line even + though the authoritative target head cannot be confirmed. + This test records the expected red result so the post-fix + implementation refuses the auth-failure scenario instead of + silently promoting the registered task to local continuation. + + The test owns its own disposable git wrapper so the auth + failure scenario does not leak into the wrapper shared with + the no-cache tests; the existing + test_local_resume_continues_when_origin_is_unreachable + (unreachable origin with the bare remote URL rewritten) and + test_local_child_resume_uses_local_parent_when_origin_is_unreachable + (unreachable origin with a child task) are preserved.""" + f = self.fixture + # (1) Capture a valid local receipt while origin is reachable + # so the receipt reflects the registered task state under the + # disposable bare remote. The capture itself runs without the + # wrapper so the receipt is a real artifact, not a fake. + receipt = self.capture() + original = receipt.read_bytes() + receipt_sha = json.loads(original)["receipt_sha256"] + head = f.git(self.wt, "rev-parse", "HEAD").stdout + card_before = self.card.read_bytes() + # (2) Build the disposable auth-failure wrapper so the wrapper + # intercepts fetch (128, Authentication failed) and + # ls-remote origin refs/heads/main (128, Authentication failed, + # no value served) while everything else delegates to the real + # git. The wrapper's own marker line proves it was loaded. + bin_dir, wrapper_log = self.write_no_cache_wrapper( + f.root / "auth-failure-bin", "", log_name="auth-failure.log", + auth_fail=True) + path_env = f"{bin_dir}{os.pathsep}{f.env.get('PATH', os.environ['PATH'])}" + wrapper_env = (f.env | {"PATH": path_env} + | {"TASK_WORK_MODE": "local", + "PRECHECK_RECEIPT": str(receipt)}) + # (3) Local resume under the auth-failure wrapper must refuse. + result = f.task(self.wt, "resume", check=False, env=wrapper_env) + # Proof the wrapper was actually used. + log = wrapper_log.read_text() + self.assertIn("configured auth-failure-mode", log) + self.assertIn("fetch-auth-failure", log) + self.assertIn("ls-remote-auth-failure", log) + # Auth-failure scenarios must assert the wrapper actually + # served the expected (auth) response, not only that the + # wrapper initialized. + self.assertNotEqual(result.returncode, 0, + f"local resume must reject when fetch and ls-remote both " + f"return Authentication failed (HTTP 401); current code " + f"falls through target_history's empty-listing short-" + f"circuit and reaches IMPORT_RECEIPT, returning 0 and " + f"printing [resumed]; stdout={result.stdout}") + self.assertIn("[verdict] REMOTE_UNVERIFIED", result.stdout, + "the resume must surface REMOTE_UNVERIFIED when the reachable " + "origin returns Authentication failed (HTTP 401) instead of an " + "advertised target head; the post-fix implementation must not " + "promote the registered task to local continuation under an " + f"authoritative-but-unverifiable remote; stdout={result.stdout}") + self.assertNotIn("[resumed]", result.stdout, + "the resume must NOT import the receipt when the reachable " + "origin returns Authentication failed (HTTP 401); accepting " + "the registered task would advertise delivery readiness for a " + f"target head that cannot be confirmed against origin; " + f"stdout={result.stdout}") + self.assertEqual(self.card.read_bytes(), card_before, + "the registered task card must not be updated with " + "receipt_sha256 when the resume refuses to import the receipt") + self.assertEqual(receipt.read_bytes(), original, + "the receipt file must be byte-for-byte unchanged when the " + "resume refuses to import it") + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head, + "the task HEAD must not move when the resume refuses to " + "import the receipt") + self.assertNotIn(receipt_sha, self.card.read_text(), + "the registered task card must not embed the receipt sha256 " + "when the resume refuses to import the receipt") + + def test_local_precheck_rejects_reachable_auth_failure(self): + """Standalone: capture a valid TASK_WORK_MODE=local precheck + receipt against the real bare remote (origin is reachable and + serves refs/heads/main normally), then point the worktree at a + disposable wrapper that makes `fetch` and + `ls-remote origin refs/heads/main` both fail with explicit + "Authentication failed (HTTP 401)" and run `task precheck` in + local mode. + + The wrapper proves the wrapper was actually used (configured + marker, fetch-auth-failure, ls-remote-auth-failure). The + precheck must NOT yield the local-continuation positive signal: + it must refuse with REMOTE_UNVERIFIED, must not advertise + LOCAL_READY, and HEAD must remain untouched. + + Currently RED on current code: when fetch and ls-remote both + fail (auth error), `target_history` sees an empty listing and + short-circuits to "ok", so the unreachable-fallback branch falls + through to LOCAL_READY even though the authoritative target + head cannot be confirmed. This test records the expected red + result so the post-fix implementation refuses the auth-failure + scenario instead of silently promoting the registered task to + local continuation. + + The test owns its own disposable git wrapper so the auth + failure scenario does not leak into the wrapper shared with + the no-cache tests; the existing + test_local_resume_rejects_reachable_auth_failure (unreachable + origin under resume) and test_local_child_resume_rejects_missing_parent_sha + (unreachable origin with a child task missing parent_head_sha) + are preserved.""" + f = self.fixture + head_before = f.git(self.wt, "rev-parse", "HEAD").stdout + # (1) Build the disposable auth-failure wrapper so the wrapper + # intercepts fetch (128, Authentication failed) and + # ls-remote origin refs/heads/main (128, Authentication failed, + # no value served) while everything else delegates to the real + # git. The wrapper's own marker line proves it was loaded. + bin_dir, wrapper_log = self.write_no_cache_wrapper( + f.root / "auth-failure-precheck-bin", "", + log_name="auth-failure-precheck.log", + auth_fail=True) + path_env = f"{bin_dir}{os.pathsep}{f.env.get('PATH', os.environ['PATH'])}" + wrapper_env = (f.env | {"PATH": path_env} + | {"TASK_WORK_MODE": "local"}) + # (2) Local precheck under the auth-failure wrapper must refuse. + local = f.task(self.wt, "precheck", check=False, env=wrapper_env) + # Proof the wrapper was actually used. + log = wrapper_log.read_text() + self.assertIn("configured auth-failure-mode", log) + self.assertIn("fetch-auth-failure", log) + self.assertIn("ls-remote-auth-failure", log) + # Auth-failure scenarios must assert the wrapper actually + # served the expected (auth) response, not only that the + # wrapper initialized. + self.assertNotEqual(local.returncode, 0, + f"local precheck must reject when fetch and ls-remote both " + f"return Authentication failed (HTTP 401); current code " + f"falls through target_history's empty-listing short-" + f"circuit and reaches LOCAL_READY, returning 0; " + f"stdout={local.stdout}") + self.assertIn("[verdict] REMOTE_UNVERIFIED", local.stdout, + "the precheck must surface REMOTE_UNVERIFIED when the reachable " + "origin returns Authentication failed (HTTP 401) instead of an " + "advertised target head; the post-fix implementation must not " + "promote the registered task to local continuation under an " + f"authoritative-but-unverifiable remote; stdout={local.stdout}") + self.assertNotIn("[verdict] LOCAL_READY", local.stdout, + "the precheck must NOT advertise LOCAL_READY when the " + "reachable origin returns Authentication failed (HTTP 401); " + "accepting the registered task would advertise local " + "continuation for a target head that cannot be confirmed " + f"against origin; stdout={local.stdout}") + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before, + "the task HEAD must not move when the precheck refuses to " + "advertise LOCAL_READY") + + def test_local_outage_rejects_cached_target_diverged_from_r0(self): + """Origin transport is unavailable and the cached origin/main does not + descend from the registered R0. Local precheck must surface + REMOTE_UNVERIFIED and local resume must not import the receipt, instead + of continuing on a cache that already contradicts the task baseline.""" + f = self.fixture + receipt = self.capture() + original = receipt.read_bytes() + card_before = self.card.read_bytes() + r0 = self.local_authorization_scope()["r0"] + divergent = f.git(self.wt, "commit-tree", r0 + "^{tree}", "-m", + "Unrelated cached target").stdout.strip() + f.git(self.wt, "update-ref", "refs/remotes/origin/main", divergent) + self.assertEqual(f.git(self.wt, "merge-base", "--is-ancestor", r0, divergent, + check=False).returncode, 1) + f.git(self.wt, "remote", "set-url", "origin", str(f.root / "missing.git")) + head_before = f.git(self.wt, "rev-parse", "HEAD").stdout + + local = self.precheck() + self.assertNotEqual(local.returncode, 0, local.stdout) + self.assertIn("[verdict] REMOTE_UNVERIFIED", local.stdout) + self.assertIn("rewound or diverged", local.stdout) + self.assertNotIn("[verdict] LOCAL_READY", local.stdout) + + resumed = self.resume(receipt) + self.assertNotEqual(resumed.returncode, 0, resumed.stdout) + self.assertNotIn("[resumed]", resumed.stdout) + self.assertEqual(self.card.read_bytes(), card_before) + self.assertEqual(receipt.read_bytes(), original) + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before) + if __name__ == "__main__": unittest.main()