diff --git a/scripts/task.sh b/scripts/task.sh index 164cafa4..4cfdccea 100755 --- a/scripts/task.sh +++ b/scripts/task.sh @@ -35,13 +35,20 @@ origin_transport_unavailable() { # $1=失败的远端命令 stderr → 仅明确 esac [ ! -e "$path" ] } -target_history() { # $1=可选基线(无缓存目标时代替比较,如任务 R0)→ 输出 ok(远端传输不可达/通告提交为基线后代)、rewound(回退或分叉)或 uncomparable(认证/授权/未知错误、目标 ref 缺失、无基线、通告提交本地缺失或无法比较) - local cached listing advertised rc=0 - cached=$(git rev-parse -q --verify "refs/remotes/origin/${TARGET_BRANCH}^{commit}" 2>/dev/null) || cached=${1:-} +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 ok; else echo uncomparable; fi + 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; } @@ -174,9 +181,8 @@ cmd_precheck() { exit 3 fi if [ "$mode" = local ]; then - python3 "${SCRIPT_DIR}/evidence.py" --local-context --active || return 2 if [ "$remote_fresh" -eq 0 ]; then - # 无缓存目标时只以活动任务卡登记的 R0 作比较基线,不把它当作远端最新值。 + # 以活动任务卡登记的 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" @@ -186,6 +192,7 @@ cmd_precheck() { 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 diff --git a/tests/test_task_recovery.py b/tests/test_task_recovery.py index ded3c923..ab2966db 100644 --- a/tests/test_task_recovery.py +++ b/tests/test_task_recovery.py @@ -1290,6 +1290,37 @@ class TaskRecoveryTests(unittest.TestCase): "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()