From eb46bbb296043b8b002d4ef727b5c9ed602e567d Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 07:42:12 +0000 Subject: [PATCH] Separate local task recovery from delivery readiness Why: an existing authorized task should not merge a moving target merely to resume local work, while new task startup and delivery remain strict. Evidence: SELF red-green recovery and environment regressions; task, recovery, evidence and local-mode write-guard suites passed through make evidence. Impact: add explicit LOCAL_READY and task-resume, validate worktree/card/baseline and receipt purpose, preserve schema 1 and prevent local receipts from starting or releasing tasks. Refs: U2; policy/merge-queue.md; .task/feature-merge-queue-policy-draft.md --- Makefile | 7 +- scripts/evidence.py | 75 ++++++++++++-- scripts/task.sh | 39 +++++++- tests/test_evidence.py | 8 +- tests/test_task.py | 3 +- tests/test_task_recovery.py | 195 ++++++++++++++++++++++++++++++++++++ 6 files changed, 314 insertions(+), 13 deletions(-) create mode 100644 tests/test_task_recovery.py diff --git a/Makefile b/Makefile index cec357de..c77f55d0 100644 --- a/Makefile +++ b/Makefile @@ -13,13 +13,15 @@ PLUGINS := plugins COMBO := core=$(shell git -C $(CORE) rev-parse --short HEAD 2>/dev/null)+backend=$(shell git -C $(BACKEND) rev-parse --short HEAD 2>/dev/null)+frontend=$(shell git -C $(FRONTEND) rev-parse --short HEAD 2>/dev/null)+ui=$(shell git -C $(UI) rev-parse --short HEAD 2>/dev/null)+agentcmd=$(shell git -C deps/agent-command rev-parse --short HEAD 2>/dev/null)+documents=$(shell git -C modules/documents rev-parse --short HEAD 2>/dev/null)+native=$(shell git -C deps/agent-native rev-parse --short HEAD 2>/dev/null)+squad=$(shell git -C deps/agent-squad rev-parse --short HEAD 2>/dev/null)+intent=$(shell git -C deps/agent-intent rev-parse --short HEAD 2>/dev/null) VERSION := $(shell git describe --tags --always --dirty 2>/dev/null)+$(COMBO) -.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-sync task-close task-finish facts-verify evidence version +.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-resume task-sync task-close task-finish facts-verify evidence version version: @echo "agent-system $(VERSION)" TASK := scripts/task.sh export PRECHECK_RECEIPT RECEIPT IMPORT_RECEIPT TASK_WRITERS_STOPPED +TASK_WORK_MODE ?= delivery +export TASK_WORK_MODE TASK_PARENT_BRANCH ?= $(PARENT) TASK_PARENT_PR ?= $(PARENT_PR) export TASK_PARENT_BRANCH TASK_PARENT_PR @@ -36,6 +38,9 @@ task-start: @if [ -z "$(NAME)" ]; then echo "usage: make task-start NAME=feature/"; exit 2; fi @bash $(TASK) start "$(NAME)" +task-resume: + @bash $(TASK) resume + task-sync: @bash $(TASK) sync diff --git a/scripts/evidence.py b/scripts/evidence.py index 644ec39d..e8f39578 100644 --- a/scripts/evidence.py +++ b/scripts/evidence.py @@ -197,19 +197,75 @@ def read_receipt(filename): return value -def validate_precheck(receipt): +def task_context(current=None): + """Verify an existing task checkout; this is not an approval identity.""" + current = snapshot() if current is None else current + root = Path(git_text("rev-parse", "--show-toplevel")).resolve() + branch = current["branch"] + if not re.fullmatch(r"(?:feature|fix|hotfix|release)/[A-Za-z0-9._-]+", branch): + raise EvidenceError("Local recovery requires a task branch") + records = [row.split(b"\0") for row in git("worktree", "list", "--porcelain", "-z").split(b"\0\0") if row] + matches = [row for row in records if row[0].startswith(b"worktree ") + and Path(os.fsdecode(row[0][9:])).resolve() == root] + if len(matches) != 1 or not records or matches[0] == records[0]: + raise EvidenceError("Recovery requires a registered linked task worktree") + entry = matches[0] + if any(field.startswith((b"bare", b"detached", b"prunable")) for field in entry) or ( + b"branch refs/heads/" + branch.encode() not in entry): + raise EvidenceError("Task worktree registration is not valid") + if (root / ".task").is_symlink(): + raise EvidenceError("Task registration must belong to the current checkout") + card = find_card() + if card.is_symlink() or card.resolve().parent != root / ".task": + raise EvidenceError("Task registration must belong to the current checkout") + content = card.read_text() + locations = re.findall(r"^- 工作区:(.+?)\s*[((]worktree[))];分支:([^\s]+)\s*$", content, re.M) + bases = re.findall(r"^- 权威仓:.*;目标分支:([^;]+);R0(initial_base_sha)= ([0-9a-f]+)\s*$", content, re.M) + if len(locations) != 1 or Path(locations[0][0]).resolve() != root or locations[0][1] != branch: + raise EvidenceError("Task card belongs to a different worktree or branch") + if len(bases) != 1 or bases[0][0] != current["target_branch"] or not GIT_SHA.fullmatch(bases[0][1]): + raise EvidenceError("Task card has no valid target and initial baseline") + r0 = bases[0][1] + if not current["target_head"]: + raise EvidenceError("Task target is unverified; fetch it first") + for tip in (current["head"], current["target_head"]): + git("merge-base", "--is-ancestor", r0, tip) + check = subprocess.run([sys.executable, str(Path(__file__).with_name("check-worktree.py")), "before-sync"], + capture_output=True, text=True) + if check.returncode: + raise EvidenceError("Task workspace is not ready: " + (check.stdout + check.stderr).strip()) + return {"root": root, "r0": r0} + + +def validate_precheck(receipt, purpose="start"): + if purpose not in ("start", "resume-delivery", "resume-local"): + raise EvidenceError("Unknown precheck validation purpose") after = receipt["after"] current = snapshot() if receipt["command"] not in ("make precheck", "bash scripts/task.sh precheck"): raise EvidenceError("Task start requires a precheck receipt") - if receipt["exit_code"] != 0 or not re.search(r"^\[verdict\] READY(?:\s|$)", receipt["output"], re.M): - raise EvidenceError("Precheck receipt is not READY") + expected = "LOCAL_READY" if purpose == "resume-local" else "READY" + verdicts = re.findall(r"^\[verdict\] ([A-Z_]+)(?:\s|$)", receipt["output"], re.M) + if receipt["exit_code"] != 0 or verdicts != [expected]: + raise EvidenceError("Precheck receipt is not " + expected) if after is None or receipt["after_error"] is not None: raise EvidenceError("Precheck object capture is incomplete") if receipt["before"]["head"] != current["head"] or after["head"] != current["head"]: raise EvidenceError("Precheck source HEAD has changed") - if after["target_branch"] != current["target_branch"] or not after["target_head"] or after["target_head"] != current["target_head"]: + if after["target_branch"] != current["target_branch"] or not after["target_head"]: raise EvidenceError("Precheck authoritative target has changed") + if purpose != "resume-local" and after["target_head"] != current["target_head"]: + raise EvidenceError("Precheck authoritative target has changed") + if purpose.startswith("resume-"): + context = task_context(current) + try: + Path(receipt["cwd"]).resolve().relative_to(context["root"]) + except ValueError: + raise EvidenceError("Precheck belongs to a different task worktree") + if any(obj["branch"] != current["branch"] or obj["target_branch"] != current["target_branch"] + for obj in (receipt["before"], after)): + raise EvidenceError("Precheck belongs to a different task branch") + git("merge-base", "--is-ancestor", context["r0"], after["target_head"]) def find_card(): @@ -261,6 +317,12 @@ def main(): try: if os.environ.get("SRC", "SELF") not in ("", "SELF"): raise EvidenceError("Local evidence is SELF; GATE/REPLAY require external execution identity") + if sys.argv[1:] == ["--local-context"]: + context = task_context() + print("[task-state] registered=" + str(context["root"]) + " initial_base=" + context["r0"]) + return 0 + if sys.argv[1:]: + raise EvidenceError("Unknown evidence arguments") capture, imported, validated = (os.environ.get(key) for key in ("RECEIPT", "IMPORT_RECEIPT", "VALIDATE_RECEIPT")) command = os.environ.get("CMD", "") @@ -269,7 +331,7 @@ def main(): if imported or validated: receipt = read_receipt(imported or validated) if validated: - validate_precheck(receipt) + validate_precheck(receipt, os.environ.get("VALIDATE_RECEIPT_PURPOSE", "start")) report(receipt, "precheck validated") else: card = find_card() @@ -286,7 +348,8 @@ def main(): source_cwd, source_common = str(Path.cwd().resolve()), common_dir() timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat() child_env = os.environ.copy() - for key in ("CMD", "SRC", "RECEIPT", "IMPORT_RECEIPT", "VALIDATE_RECEIPT", "TAIL"): + for key in ("CMD", "SRC", "RECEIPT", "IMPORT_RECEIPT", "VALIDATE_RECEIPT", "TAIL", + "VALIDATE_RECEIPT_PURPOSE"): child_env.pop(key, None) result = subprocess.run(["bash", "-o", "pipefail", "-c", command], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=child_env) diff --git a/scripts/task.sh b/scripts/task.sh index 0f6bdff6..22daeff8 100755 --- a/scripts/task.sh +++ b/scripts/task.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 任务启动门禁:precheck / start / sync / close / finish(配套 Makefile 同名目标)。 +# 任务启动门禁:precheck / start / resume / sync / close / finish(配套 Makefile 同名目标)。 # 规范依据:collaboration.md 共同基线与同步节点、治理清理;delivery.md §1/§5。 set -euo pipefail @@ -20,9 +20,17 @@ fetch_target() { } check_worktree() { python3 "${SCRIPT_DIR}/check-worktree.py" "$1"; } main_worktree_root() { git worktree list --porcelain -z | python3 -c 'import os,sys; print(os.fsdecode(sys.stdin.buffer.read().split(bytes([0]),1)[0][9:]))'; } +work_mode() { + case "${TASK_WORK_MODE:-delivery}" in + delivery|local) printf '%s\n' "${TASK_WORK_MODE:-delivery}" ;; + *) die "TASK_WORK_MODE must be delivery or local" ;; + esac +} cmd_precheck() { need_repo + local mode + mode=$(work_mode) fetch_target local branch head rmain dirty sub_drift sub_uninit branch=$(git rev-parse --abbrev-ref HEAD) @@ -100,11 +108,20 @@ cmd_precheck() { echo "[facts] WARN policy/facts.md 不存在——外部事实登记表缺失(AGENTS.md 证据与验证门禁)" fi echo "[required-reading] core/baseline.md, workflows/delivery.md §1$([ -f policy/delivery.md ] && echo ", policy/delivery.md")$([ "$dirty" -gt 0 ] && echo ", standards/collaboration.md(树脏=他人或未收尾现场,不得写入)")" - echo "[R0] $(echo "$rmain" | cut -c1-10)(完整:$rmain)" + if [ "$mode" = local ]; then + echo "[target] ${rmain}(observed; task initial baseline is reported separately)" + else + echo "[R0] $(echo "$rmain" | cut -c1-10)(完整:$rmain)" + fi if [ "$dirty" -gt 0 ] || [ "${sub_drift:-0}" -gt 0 ]; then echo "[verdict] SHARED_WORKSPACE_BUSY — 工作树不干净,不得写入;先收尾或经 owner 确认" exit 3 fi + if [ "$mode" = local ]; then + python3 "${SCRIPT_DIR}/evidence.py" --local-context || return 2 + echo "[verdict] LOCAL_READY — existing task may continue authorized local work; final delivery still requires latest-target sync and checks" + return 0 + fi if ! check_worktree before-sync; then echo "[verdict] SHARED_WORKSPACE_BUSY — component or active operation check failed; preserve the workspace" exit 3 @@ -123,7 +140,7 @@ cmd_start() { fetch_target [ -n "${PRECHECK_RECEIPT:-}" ] || die "PRECHECK_RECEIPT required: capture RECEIPT=/tmp/task-precheck.json CMD='make precheck' make evidence first" PRECHECK_RECEIPT=$(realpath -- "$PRECHECK_RECEIPT") - VALIDATE_RECEIPT="$PRECHECK_RECEIPT" bash "${SCRIPT_DIR}/evidence.sh" + VALIDATE_RECEIPT="$PRECHECK_RECEIPT" VALIDATE_RECEIPT_PURPOSE=start bash "${SCRIPT_DIR}/evidence.sh" check_worktree before-sync # 已合入的同名分支不得重建(2026-10-05 实测:合入后再 task-start 同名会从新 main 长出 # 第二段历史,空卡覆盖已合入任务卡,两轮证据差点丢失)。复核/追加沿用原分支或换新 slug。 @@ -274,6 +291,19 @@ EOF echo "[started] branch=${name} worktree=${wt} R0=$(echo "$rmain" | cut -c1-10)" } +cmd_resume() { + need_repo + local mode + mode=$(work_mode) + fetch_target + [ -n "${PRECHECK_RECEIPT:-}" ] || die "PRECHECK_RECEIPT required: capture the current task precheck before resume" + local receipt + receipt=$(realpath -- "$PRECHECK_RECEIPT") + VALIDATE_RECEIPT="$receipt" VALIDATE_RECEIPT_PURPOSE="resume-${mode}" bash "${SCRIPT_DIR}/evidence.sh" + IMPORT_RECEIPT="$receipt" bash "${SCRIPT_DIR}/evidence.sh" + echo "[resumed] work_mode=${mode} source_head=$(git rev-parse HEAD) observed_target=$(git rev-parse "origin/${TARGET_BRANCH}")" +} + cmd_sync() { need_repo fetch_target @@ -542,8 +572,9 @@ cmd_finish() { case "${1:-}" in precheck) shift; cmd_precheck "$@" ;; start) shift; cmd_start "$@" ;; + resume) shift; cmd_resume "$@" ;; sync) shift; cmd_sync "$@" ;; close) shift; cmd_close "$@" ;; finish) shift; cmd_finish "$@" ;; - *) echo "usage: task.sh [args]"; exit 2 ;; + *) echo "usage: task.sh [args]"; exit 2 ;; esac diff --git a/tests/test_evidence.py b/tests/test_evidence.py index 2344065f..779a2fc6 100644 --- a/tests/test_evidence.py +++ b/tests/test_evidence.py @@ -68,7 +68,8 @@ class EvidenceTests(unittest.TestCase): def run_evidence(self, command=None, cwd=None, **values): env = os.environ.copy() - for key in ("CMD", "SRC", "RECEIPT", "IMPORT_RECEIPT", "VALIDATE_RECEIPT"): + for key in ("CMD", "SRC", "RECEIPT", "IMPORT_RECEIPT", "VALIDATE_RECEIPT", + "VALIDATE_RECEIPT_PURPOSE", "TASK_WORK_MODE"): env.pop(key, None) if command is not None: env["CMD"] = command @@ -103,6 +104,11 @@ class EvidenceTests(unittest.TestCase): self.assertEqual(result.returncode, 7) self.assertIn('"exit_code": 7', self.card.read_text()) + def test_validation_purpose_is_not_injected_into_measured_command(self): + data = self.capture("printf '%s' \"${VALIDATE_RECEIPT_PURPOSE-unset}\"", + VALIDATE_RECEIPT_PURPOSE="resume-local") + self.assertEqual(data["output"], "unset") + def test_capture_without_card_and_import_without_rerun(self): self.card.unlink() data = self.capture("printf 'once\\n' >> invocation; cat invocation") diff --git a/tests/test_task.py b/tests/test_task.py index f94ae827..3b6a0ad3 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -26,7 +26,8 @@ class TaskLifecycleTests(unittest.TestCase): "GIT_AUTHOR_NAME": "Lifecycle Test", "GIT_AUTHOR_EMAIL": "test@example.invalid", "GIT_COMMITTER_NAME": "Lifecycle Test", "GIT_COMMITTER_EMAIL": "test@example.invalid"}) for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "PRECHECK_RECEIPT", - "TASK_PARENT_BRANCH", "TASK_PARENT_PR", "FORCE_ABANDON", "TASK_TARGET_BRANCH"): + "TASK_PARENT_BRANCH", "TASK_PARENT_PR", "FORCE_ABANDON", "TASK_TARGET_BRANCH", + "TASK_WORK_MODE", "VALIDATE_RECEIPT_PURPOSE"): self.env.pop(key, None) self.git(self.root, "init", "--bare", "--initial-branch=main", str(self.remote)) # receive-pack 默认在后台跑 gc --auto,与 TemporaryDirectory 清理竞态 diff --git a/tests/test_task_recovery.py b/tests/test_task_recovery.py new file mode 100644 index 00000000..b050636a --- /dev/null +++ b/tests/test_task_recovery.py @@ -0,0 +1,195 @@ +"""Local recovery is task-scoped and cannot grant delivery readiness.""" +import json +import unittest + +import test_task as lifecycle + + +class TaskRecoveryTests(unittest.TestCase): + def setUp(self): + self.fixture = lifecycle.TaskLifecycleTests(methodName="runTest") + self.fixture.setUp() + self.addCleanup(self.fixture.doCleanups) + self.wt = self.fixture.worktree() + self.card = self.fixture.record_task_card(self.wt) + self.receipt_number = 0 + + def precheck(self, mode="local", cwd=None): + f = self.fixture + return f.task(cwd or self.wt, "precheck", check=False, + env=f.env | {"TASK_WORK_MODE": mode}) + + def capture(self, mode="local"): + f = self.fixture + self.receipt_number += 1 + path = f.root / f"recovery-{self.receipt_number}.json" + result = f.run_cmd(self.wt, "bash", "scripts/evidence.sh", check=False, + env=f.env | {"TASK_WORK_MODE": mode, "RECEIPT": str(path), + "CMD": "make precheck"}) + self.assertEqual(result.returncode, 0, result.stdout) + return path + + def resume(self, receipt, mode="local", cwd=None): + f = self.fixture + return f.task(cwd or self.wt, "resume", check=False, + env=f.env | {"TASK_WORK_MODE": mode, "PRECHECK_RECEIPT": str(receipt)}) + + def save_card_change(self): + f = self.fixture + f.git(self.wt, "add", str(self.card.relative_to(self.wt))) + f.git(self.wt, "commit", "-m", "Update fixture registration") + + def test_local_precheck_allows_known_lag_without_changing_head(self): + f = self.fixture + head = f.git(self.wt, "rev-parse", "HEAD").stdout + f.advance_remote() + strict = self.precheck(mode="delivery") + self.assertNotEqual(strict.returncode, 0) + self.assertIn("SYNC_REQUIRED", strict.stdout) + local = self.precheck() + self.assertEqual(local.returncode, 0, local.stdout) + self.assertIn("[verdict] LOCAL_READY", local.stdout) + self.assertNotIn("[verdict] READY", local.stdout) + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head) + + def test_local_precheck_never_advertises_delivery_even_when_current(self): + result = self.precheck() + self.assertEqual(result.returncode, 0, result.stdout) + self.assertIn("[verdict] LOCAL_READY", result.stdout) + self.assertNotIn("[verdict] READY", result.stdout) + + def test_local_precheck_rejects_main_and_missing_registration(self): + result = self.precheck(cwd=self.fixture.repo) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.card.unlink() + self.save_card_change() + result = self.precheck() + self.assertNotEqual(result.returncode, 0, result.stdout) + + def test_local_precheck_rejects_foreign_registration(self): + self.card.write_text(self.card.read_text().replace(str(self.wt), str(self.fixture.repo))) + self.save_card_change() + result = self.precheck() + self.assertNotEqual(result.returncode, 0, result.stdout) + + def test_local_precheck_rejects_wrong_branch_registration(self): + self.card.write_text(self.card.read_text().replace("分支:feature/test", "分支:feature/other")) + self.save_card_change() + self.assertNotEqual(self.precheck().returncode, 0) + + def test_local_precheck_rejects_invalid_baseline(self): + f = self.fixture + r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip() + self.card.write_text(self.card.read_text().replace(r0, "0" * 40)) + self.save_card_change() + self.assertNotEqual(self.precheck().returncode, 0) + + def test_local_precheck_rejects_dirty_state_and_unknown_remote(self): + f = self.fixture + (self.wt / "tracked").write_text("preserve current edit\n") + result = self.precheck() + self.assertNotEqual(result.returncode, 0) + self.assertIn("SHARED_WORKSPACE_BUSY", result.stdout) + f.git(self.wt, "commit", "-am", "Save owner edit") + f.git(self.wt, "remote", "set-url", "origin", str(f.root / "missing.git")) + result = self.precheck() + self.assertNotEqual(result.returncode, 0) + self.assertIn("REMOTE_UNVERIFIED", result.stdout) + + def test_unknown_work_mode_is_rejected(self): + result = self.precheck(mode="anything") + self.assertNotEqual(result.returncode, 0, result.stdout) + + def test_local_resume_imports_original_receipt_without_sync(self): + f = self.fixture + receipt = self.capture() + original = receipt.read_bytes() + head = f.git(self.wt, "rev-parse", "HEAD").stdout + f.advance_remote() + result = self.resume(receipt) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertIn("[resumed]", result.stdout) + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head) + self.assertEqual(receipt.read_bytes(), original) + self.assertIn(json.loads(original)["receipt_sha256"], self.card.read_text()) + current = f.git(self.wt, "rev-parse", "origin/main").stdout.strip() + self.assertNotEqual(json.loads(original)["after"]["target_head"], current) + + def test_make_resume_exports_the_selected_mode_and_receipt(self): + f = self.fixture + receipt = self.capture() + result = f.run_cmd(self.wt, "make", "-f", str(lifecycle.SOURCE / "Makefile"), + "task-resume", check=False, + env=f.env | {"TASK_WORK_MODE": "local", "PRECHECK_RECEIPT": str(receipt)}) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertIn("[resumed]", result.stdout) + + def test_local_receipt_cannot_start_a_task_or_resume_for_delivery(self): + f = self.fixture + receipt = self.capture() + before = self.card.read_bytes() + delivery = self.resume(receipt, mode="delivery") + self.assertNotEqual(delivery.returncode, 0, delivery.stdout) + self.assertEqual(self.card.read_bytes(), before) + start = f.task(self.wt, "start", "feature/bypass", check=False, + env=f.env | {"PRECHECK_RECEIPT": str(receipt), "TASK_WORK_MODE": "local", + "VALIDATE_RECEIPT_PURPOSE": "resume-local"}) + self.assertNotEqual(start.returncode, 0, start.stdout) + self.assertFalse((f.root / "repo-wt-feature-bypass").exists()) + + def test_strict_resume_rejects_target_advance(self): + f = self.fixture + receipt = self.capture(mode="delivery") + head = f.git(self.wt, "rev-parse", "HEAD").stdout + f.advance_remote() + rejected = self.resume(receipt, mode="delivery") + self.assertNotEqual(rejected.returncode, 0) + self.assertNotIn("[resumed]", rejected.stdout) + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head) + + def test_strict_resume_imports_current_ready_receipt(self): + receipt = self.capture(mode="delivery") + result = self.resume(receipt, mode="delivery") + self.assertEqual(result.returncode, 0, result.stdout) + + def test_resume_rejects_changed_source_head(self): + receipt = self.capture() + f = self.fixture + (self.wt / "tracked").write_text("new task revision\n") + f.git(self.wt, "commit", "-am", "Change source after precheck") + before = self.card.read_bytes() + result = self.resume(receipt) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.card.read_bytes(), before) + + def test_resume_rejects_another_worktree_with_same_head(self): + f = self.fixture + other = f.root / "other-worktree" + r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip() + other_card = self.wt / ".task/feature-other.md" + other_card.write_text( + "# Other task\n任务主记录:fixture;状态:进行中;owner:Lifecycle Test\n" + f"- 权威仓:fixture;目标分支:main;R0(initial_base_sha)= {r0}\n" + f"- 工作区:{other}(worktree);分支:feature/other\n") + f.git(self.wt, "add", ".task/feature-other.md") + f.git(self.wt, "commit", "-m", "Register second fixture task") + f.git(f.repo, "worktree", "add", "-b", "feature/other", str(other), "HEAD") + # The main checkout has an older HEAD; align this owned fixture branch to the task HEAD. + f.git(other, "merge", "--ff-only", "feature/test") + receipt = self.capture(mode="delivery") + self.assertEqual(f.git(other, "rev-parse", "HEAD").stdout, + f.git(self.wt, "rev-parse", "HEAD").stdout) + result = self.resume(receipt, mode="delivery", cwd=other) + self.assertNotEqual(result.returncode, 0) + + def test_unknown_validation_purpose_is_rejected(self): + receipt = self.capture(mode="delivery") + f = self.fixture + result = f.run_cmd(self.wt, "bash", "scripts/evidence.sh", check=False, + env=f.env | {"VALIDATE_RECEIPT": str(receipt), + "VALIDATE_RECEIPT_PURPOSE": "ignore"}) + self.assertNotEqual(result.returncode, 0, result.stdout) + + +if __name__ == "__main__": + unittest.main()