Files
Agent-System-Dev/tests/test_task_recovery.py
T

1296 lines
73 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Local recovery is task-scoped and cannot grant delivery readiness."""
import json
import os
import shlex
import subprocess
import sys
import unittest
# 允许 `python3 -m unittest tests/test_task_recovery.py` 直接运行(与 discover 模式一致地导入同目录夹具)。
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_task as lifecycle # noqa: E402
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 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
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_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")
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"))
# (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")
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 assert_history_change_rejected(self, receipt):
f = self.fixture
before = self.card.read_bytes()
original = receipt.read_bytes()
head = f.git(self.wt, "rev-parse", "HEAD").stdout
result = self.resume(receipt)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("Precheck target history has rewound or diverged", result.stdout)
self.assertNotIn("[resumed]", result.stdout)
self.assertEqual(self.card.read_bytes(), before)
self.assertEqual(receipt.read_bytes(), original)
self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head)
def test_local_resume_rejects_target_rewind_before_import(self):
f = self.fixture
r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip()
f.advance_remote()
receipt = self.capture()
observed = json.loads(receipt.read_text())["after"]["target_head"]
self.assertNotEqual(observed, r0)
# Only this test's disposable bare remote is rewritten.
f.git(f.remote, "update-ref", "refs/heads/main", r0, observed)
# Reproduce recovery after another client operation refreshed the target.
f.git(self.wt, "fetch", "origin", "+refs/heads/main:refs/remotes/origin/main")
self.assert_history_change_rejected(receipt)
def test_local_resume_rejects_divergent_target_before_import(self):
f = self.fixture
r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip()
f.advance_remote()
receipt = self.capture()
observed = json.loads(receipt.read_text())["after"]["target_head"]
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()
f.git(f.repo, "push", "origin", "HEAD:refs/heads/divergent")
self.assertEqual(f.git(f.repo, "merge-base", "--is-ancestor", observed,
divergent, check=False).returncode, 1)
# Both targets retain R0; only normal ancestry may permit recovery.
f.git(f.remote, "update-ref", "refs/heads/main", divergent, observed)
f.git(self.wt, "fetch", "origin", "+refs/heads/main:refs/remotes/origin/main")
self.assert_history_change_rejected(receipt)
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",
"TRIAGE_RECEIPT": str(f.triage_receipt())})
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)
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 <synthetic>^{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 <synthetic>^{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")
if __name__ == "__main__":
unittest.main()