Integrate current task recovery and accepted planning contracts

Why:
Preserve work merged by concurrent tasks while adopting versioned dependency workspaces.

Evidence:
SELF task receipts record conflict detection, all 96 task/recovery/cutover cases, all 33 evidence cases and 5 component identity cases. Documents links and requirements were replayed after preserving both planning entries.

Impact:
Keep upstream idempotent sync, local resume and source-external receipt rules; adapt fixed-guidelines readiness and ignored component identity validation. Remove only obsolete index links and retain their physical Git metadata.

Refs:
Authoritative main57bcb17ea212a531186b6e21617a7f3bea65a791; Documents4a26798000c39cc59ba0c2cc6328f5c8f10dcb32; dependency-workspace R15.
This commit is contained in:
2026-10-13 17:38:24 +00:00
72 changed files with 8387 additions and 98 deletions
@@ -1,7 +1,11 @@
#!/usr/bin/env bash
# P8 第八节十场景验收(提案 rdm-settings-members-duties-perms-increment §8)。
# 走真实 dev 服务 HTTP(--auth 会话),多账号多角色;输出逐场景 PASS/FAIL。
# 用法:BASE=http://127.0.0.1:8788 ADMIN_USER=weichen ADMIN_PASS=<密码> ./acceptance_scenarios.sh
# 走真实服务 HTTP(--auth 会话),多账号多角色;输出逐场景 PASS/FAIL。
# v0.4.0 版:RDM 命令面用 rdm.project.create 的 RID(不再直用基座项目);
# 场景 2 姓名过滤全量断言(基座 v0.4.0+ AccountSearcher);setRole→set-role(#350)。
# 前置:ADMIN 账号须为基座管理员(改密门已过)且已登记 RDM 域管理员
# (组合二进制 -bootstrap-admin <owner>,仅空台账生效)。
# 用法:BASE=http://127.0.0.1:8799 ADMIN_USER=rootadmin ADMIN_PASS=<密码> ./acceptance_scenarios.sh
set -u
BASE="${BASE:-http://127.0.0.1:8788}"
ADMIN_USER="${ADMIN_USER:?需要管理员账号}"
@@ -33,19 +37,27 @@ JA="$T/admin"; login "$ADMIN_USER" "$ADMIN_PASS" "$JA"
say "== 准备:账号与两项目 =="
TS=$(date +%s)
# 建号:非管理员负责人 owner2 / 普通成员 mem1 / 三权三人 w1 a1 r1(密码同式)
# 建号:非管理员负责人 owner2 / 普通成员 mem1 / 三权三人 w1 a1 r1(密码同式;name=角色-<u>)
for u in owner2 mem1 w1 a1 r1; do
resp=$(api "$JA" POST /api/admin/users "{\"owner\":\"$u\",\"name\":\"角色-$u\",\"password\":\"Pass-$TS-x8z\",\"isAdmin\":false}")
[ "$(echo "$resp" | code)" = "200" ] || say " ($u 已存在或其他:$(echo "$resp" | code))"
done
# 各自完成改密门(登录→改密)
# 各自完成改密门(登录→改密);基座 #195 起改密撤销旧会话 → 用新密码重登刷新 jar
for u in owner2 mem1 w1 a1 r1; do
J="$T/$u"; login "$u" "Pass-$TS-x8z" "$J"
api "$J" POST /api/auth/password "{\"current\":\"Pass-$TS-x8z\",\"next\":\"Pass2-$TS\"}" >/dev/null
login "$u" "Pass2-$TS" "$J"
done
JPASS="Pass2-$TS"
# 场景 10 前置数据 + 项目创建走 owner2(非管理员)
RDM() { # jar cmd json
api "$1" POST "/api/commands/$2" "{\"params\":$3}" | body
}
PAYLOAD() { # stdin json → payload 子对象(命令应答信封 {payload,receipt})
python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin)["payload"], ensure_ascii=False))'
}
# 基座项目(场景 1/10 用):owner2(非管理员)创建
JO="$T/owner2"
P1=$(api "$JO" POST /api/projects "{\"name\":\"验收甲-$TS\"}")
PID1=$(echo "$P1" | body | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' 2>/dev/null)
@@ -53,9 +65,15 @@ P2=$(api "$JO" POST /api/projects "{\"name\":\"验收乙-$TS\"}")
PID2=$(echo "$P2" | body | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' 2>/dev/null)
[ -n "${PID1:-}" ] && [ -n "${PID2:-}" ] && ok "准备:两项目创建($PID1 / $PID2)" || { bad "准备:项目创建" "$P1"; exit 1; }
RDM() { # jar cmd json
api "$1" POST "/api/commands/$2" "{\"params\":$3}" | body
}
# RDM 项目(场景 2-8 用):域管理员建项(创建者自动为 owner)+ 绑定基座项目;
# owner2 入项为 owner 后域管理员自移出——owner2 成为唯一 owner(场景 3b 前提)
RID=$(RDM "$JA" rdm.project.create "{\"name\":\"验收RDM甲-$TS\"}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["payload"]["projectId"])' 2>/dev/null)
[ -n "${RID:-}" ] && ok "准备:RDM 项目创建($RID)" || { bad "准备:RDM 项目创建" ""; exit 1; }
RDM "$JA" rdm.project.bind "{\"hostProjectId\":\"$PID1\",\"projectId\":\"$RID\"}" >/dev/null
out=$(RDM "$JA" rdm.project.member.add "{\"projectId\":\"$RID\",\"member\":\"owner2\",\"role\":\"owner\"}")
echo "$out" | grep -q '"owner"' || { bad "准备:owner2 入项" "$out"; exit 1; }
out=$(RDM "$JA" rdm.project.member.remove "{\"projectId\":\"$RID\",\"member\":\"$ADMIN_USER\"}")
echo "$out" | grep -q '"removed":true' && ok "准备:owner2 为 RDM 项目唯一 owner(管理员自移出)" || { bad "准备:管理员自移出" "$out"; exit 1; }
say "== 场景 1:受管项写入拒绝(user/project 层,含手造请求)=="
r=$(api "$JO" PUT /api/settings/user '{"models":{"chat":{"model":"m-hack"}}}' | code)
@@ -66,51 +84,68 @@ r=$(api "$JO" PUT "/api/settings/project/$PID1" '{"models":{"chat":{"apiKey":"sk
r=$(api "$JO" PUT /api/settings/system '{"models":{"chat":{"model":"x"}}}' | code)
[ "$r" = "403" ] && ok "1c 非管理员写系统面 403" || bad "1c" "$r"
say "== 场景 2:目录查找成员(账号/分页;姓名过滤待发版 tag 后全量)=="
out=$(RDM "$JO" rdm.account.list "{\"projectId\":\"$PID1\",\"limit\":5}")
n=$(echo "$out" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(len(d.get("accounts",[])))' 2>/dev/null)
say "== 场景 2:目录查找成员(账号/分页 + 姓名过滤 v0.4.0 全量)=="
out=$(RDM "$JO" rdm.account.list "{\"projectId\":\"$RID\",\"limit\":5}")
n=$(echo "$out" | python3 -c 'import json,sys; d=json.load(sys.stdin)["payload"]; print(len(d.get("accounts",[])))' 2>/dev/null)
[ "${n:-0}" -gt 0 ] && ok "2a 目录分页浏览($n 条)" || bad "2a" "$out"
# 2b 姓名子串「角色-w」→ 仅 w1(角色-w1),姓名随行、检索无游标
out=$(RDM "$JO" rdm.account.list "{\"projectId\":\"$RID\",\"query\":\"角色-w\",\"limit\":20}")
r=$(echo "$out" | python3 -c 'import json,sys
d=json.load(sys.stdin)["payload"]; a=d.get("accounts",[])
print("ok" if len(a)==1 and a[0]["owner"]=="w1" and a[0].get("name")=="角色-w1" and d.get("next","")=="" else json.dumps(d,ensure_ascii=False))' 2>/dev/null)
[ "$r" = "ok" ] && ok "2b 姓名过滤「角色-w」仅 w1 且姓名随行无游标" || bad "2b" "$r"
# 2c 账号子串大小写不敏感「MEM」→ 仅 mem1(角色-mem1)
out=$(RDM "$JO" rdm.account.list "{\"projectId\":\"$RID\",\"query\":\"MEM\",\"limit\":20}")
r=$(echo "$out" | python3 -c 'import json,sys
d=json.load(sys.stdin)["payload"]; a=d.get("accounts",[])
print("ok" if len(a)==1 and a[0]["owner"]=="mem1" and a[0].get("name")=="角色-mem1" else json.dumps(d,ensure_ascii=False))' 2>/dev/null)
[ "$r" = "ok" ] && ok "2c 账号过滤「MEM」(不区分大小写)仅 mem1" || bad "2c" "$r"
# 2d 多命中(角色- 前缀 5 人)+ limit 截断
out=$(RDM "$JO" rdm.account.list "{\"projectId\":\"$RID\",\"query\":\"角色\",\"limit\":3}")
r=$(echo "$out" | python3 -c 'import json,sys
d=json.load(sys.stdin)["payload"]; a=d.get("accounts",[])
print("ok" if len(a)==3 and all(str(e.get("name","")).startswith("角色-") for e in a) else json.dumps(d,ensure_ascii=False))' 2>/dev/null)
[ "$r" = "ok" ] && ok "2d 多命中姓名检索按 limit 截断(3/5)" || bad "2d" "$r"
say "== 场景 3:重复添加与最后负责人保护 =="
RDM "$JO" rdm.project.member.add "{\"projectId\":\"$PID1\",\"member\":\"mem1\",\"role\":\"member\"}" >/dev/null
out=$(RDM "$JO" rdm.project.member.add "{\"projectId\":\"$PID1\",\"member\":\"mem1\",\"role\":\"owner\"}")
RDM "$JO" rdm.project.member.add "{\"projectId\":\"$RID\",\"member\":\"mem1\",\"role\":\"member\"}" >/dev/null
out=$(RDM "$JO" rdm.project.member.add "{\"projectId\":\"$RID\",\"member\":\"mem1\",\"role\":\"owner\"}")
echo "$out" | grep -q "已是成员" && ok "3a 重复添加拒绝且不隐式变更" || bad "3a" "$out"
out=$(RDM "$JO" rdm.project.member.setRole "{\"projectId\":\"$PID1\",\"member\":\"owner2\",\"role\":\"member\"}")
out=$(RDM "$JO" rdm.project.member.set-role "{\"projectId\":\"$RID\",\"member\":\"owner2\",\"role\":\"member\"}")
echo "$out" | grep -q "最后一位负责人" && ok "3b 降级唯一负责人被拒" || bad "3b" "$out"
out=$(RDM "$JO" rdm.project.member.setRole "{\"projectId\":\"$PID1\",\"member\":\"mem1\",\"role\":\"owner\"}")
out=$(RDM "$JO" rdm.project.member.set-role "{\"projectId\":\"$RID\",\"member\":\"mem1\",\"role\":\"owner\"}")
echo "$out" | grep -q '"role":"owner"' && ok "3c 显式升级成功" || bad "3c" "$out"
RDM "$JO" rdm.project.member.setRole "{\"projectId\":\"$PID1\",\"member\":\"mem1\",\"role\":\"member\"}" >/dev/null
RDM "$JO" rdm.project.member.set-role "{\"projectId\":\"$RID\",\"member\":\"mem1\",\"role\":\"member\"}" >/dev/null
# 成员补充:三权三人入项目
for u in w1 a1 r1; do RDM "$JO" rdm.project.member.add "{\"projectId\":\"$PID1\",\"member\":\"$u\"}" >/dev/null; done
for u in w1 a1 r1; do RDM "$JO" rdm.project.member.add "{\"projectId\":\"$RID\",\"member\":\"$u\"}" >/dev/null; done
say "== 场景 4:文档目录与职责分配对象校验 =="
# 建文档(经 RDM 世界初始化:用 rdm.init 或模板?最小:rdm.duty.catalog 应答;docId 误提交由场景 5 的 assign 覆盖
cat_out=$(RDM "$JO" rdm.duty.catalog "{\"projectId\":\"$PID1\"}")
echo "$cat_out" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert "docs" in d' 2>/dev/null && ok "4a duty.catalog 目录可读" || bad "4a" "$cat_out"
cat_out=$(RDM "$JO" rdm.duty.catalog "{\"projectId\":\"$RID\"}")
echo "$cat_out" | python3 -c 'import json,sys; assert "docs" in json.load(sys.stdin)["payload"]' 2>/dev/null && ok "4a duty.catalog 目录可读" || bad "4a" "$cat_out"
# 章节误提交:构造不存在的 docId → assign 拒绝
out=$(RDM "$JO" rdm.review.assign.doc "{\"projectId\":\"$PID1\",\"docId\":\"section-not-doc\",\"author\":\"w1\",\"reviewers\":[\"a1\"],\"approver\":\"r1\"}")
out=$(RDM "$JO" rdm.review.assign.doc "{\"projectId\":\"$RID\",\"docId\":\"section-not-doc\",\"author\":\"w1\",\"reviewers\":[\"a1\"],\"approver\":\"r1\"}")
echo "$out" | grep -qE "不存在|失败|错误" && ok "4b 不存在对象整笔拒绝" || bad "4b" "$out"
say "== 场景 5:非成员职责目标被拒;移出成员保存被拒 =="
out=$(RDM "$JO" rdm.review.assign.doc "{\"projectId\":\"$PID1\",\"docId\":\"any\",\"author\":\"zombie-$TS\",\"reviewers\":[\"a1\"],\"approver\":\"r1\"}")
out=$(RDM "$JO" rdm.review.assign.doc "{\"projectId\":\"$RID\",\"docId\":\"any\",\"author\":\"zombie-$TS\",\"reviewers\":[\"a1\"],\"approver\":\"r1\"}")
echo "$out" | grep -q "成员" && ok "5a 非成员职责目标被拒" || bad "5a" "$out"
# mem1 移出后 setRole 保存拒绝
RDM "$JO" rdm.project.member.remove "{\"projectId\":\"$PID1\",\"member\":\"mem1\"}" >/dev/null
out=$(RDM "$JO" rdm.project.member.setRole "{\"projectId\":\"$PID1\",\"member\":\"mem1\",\"role\":\"owner\"}")
# mem1 移出后 set-role 保存拒绝
RDM "$JO" rdm.project.member.remove "{\"projectId\":\"$RID\",\"member\":\"mem1\"}" >/dev/null
out=$(RDM "$JO" rdm.project.member.set-role "{\"projectId\":\"$RID\",\"member\":\"mem1\",\"role\":\"owner\"}")
echo "$out" | grep -q "不是" && ok "5b 移出成员的成员面操作被拒" || bad "5b" "$out"
say "== 场景 6:批量整批拒绝(预检列原因)=="
out=$(RDM "$JO" rdm.review.assign.doc.batch "{\"projectId\":\"$PID1\",\"docs\":[{\"docId\":\"x1\",\"author\":\"w1\",\"reviewers\":[\"a1\"],\"approver\":\"r1\"},{\"docId\":\"x2\",\"author\":\"zombie-$TS\",\"reviewers\":[\"a1\"],\"approver\":\"r1\"}]}")
out=$(RDM "$JO" rdm.review.assign.doc.batch "{\"projectId\":\"$RID\",\"docs\":[{\"docId\":\"x1\",\"author\":\"w1\",\"reviewers\":[\"a1\"],\"approver\":\"r1\"},{\"docId\":\"x2\",\"author\":\"zombie-$TS\",\"reviewers\":[\"a1\"],\"approver\":\"r1\"}]}")
echo "$out" | grep -q "整批拒绝" && ok "6 批量含非成员整批拒绝并列原因" || bad "6" "$out"
say "== 场景 7:单成员解释与矩阵同源 =="
chk=$(RDM "$JO" rdm.perm.check "{\"projectId\":\"$PID1\",\"member\":\"owner2\"}")
mtx=$(RDM "$JO" rdm.perm.matrix "{\"projectId\":\"$PID1\"}")
chk=$(RDM "$JO" rdm.perm.check "{\"projectId\":\"$RID\",\"member\":\"owner2\"}")
mtx=$(RDM "$JO" rdm.perm.matrix "{\"projectId\":\"$RID\"}")
same=$(python3 - "$chk" "$mtx" <<'PY'
import json,sys
c=json.loads(sys.argv[1]); m=json.loads(sys.argv[2])
c=json.loads(sys.argv[1])["payload"]; m=json.loads(sys.argv[2])["payload"]
row=[r for r in m.get("rows",[]) if r["member"]==c["member"]]
if not row: print("no-row"); sys.exit(1)
if not row: print("no-row"); sys.exit(0)
ca={a["action"]:(a["allowed"],a.get("label"),a.get("scope")) for a in c["actions"]}
ra={a["action"]:(a["allowed"],a.get("label"),a.get("scope")) for a in row[0]["actions"]}
print("same" if ca==ra else "diff")
@@ -119,15 +154,15 @@ PY
[ "$same" = "same" ] && ok "7 单成员详情与矩阵单元格一致" || bad "7" "$same"
# 普通成员自省:a1 查自己
ja="$T/a1"
out=$(RDM "$ja" rdm.perm.check "{\"projectId\":\"$PID1\",\"member\":\"a1\"}")
out=$(RDM "$ja" rdm.perm.check "{\"projectId\":\"$RID\",\"member\":\"a1\"}")
echo "$out" | grep -q '"actions"' && ok "7b 普通成员自省可用" || bad "7b" "$out"
say "== 场景 8:系统动作不可项目覆盖 =="
out=$(RDM "$JO" rdm.perm.set "{\"projectId\":\"$PID1\",\"member\":\"a1\",\"action\":\"project.create\",\"allow\":true}")
out=$(RDM "$JO" rdm.perm.set "{\"projectId\":\"$RID\",\"member\":\"a1\",\"action\":\"project.create\",\"allow\":true}")
echo "$out" | grep -q "系统范围" && ok "8a project.create 覆盖被服务端拒绝" || bad "8a" "$out"
out=$(RDM "$JO" rdm.perm.set "{\"projectId\":\"$PID1\",\"member\":\"a1\",\"action\":\"doc.write\",\"allow\":true}")
echo "$out" | grep -q '"ok"' || echo "$out" | grep -q 'ok' && ok "8b 项目范围动作覆盖可用" || bad "8b" "$out"
out=$(RDM "$JO" rdm.perm.unset "{\"projectId\":\"$PID1\",\"member\":\"a1\",\"action\":\"doc.write\"}")
out=$(RDM "$JO" rdm.perm.set "{\"projectId\":\"$RID\",\"member\":\"a1\",\"action\":\"doc.write\",\"allow\":true}")
echo "$out" | grep -q '"allow":true' && ok "8b 项目范围动作覆盖可用" || bad "8b" "$out"
out=$(RDM "$JO" rdm.perm.unset "{\"projectId\":\"$RID\",\"member\":\"a1\",\"action\":\"doc.write\"}")
echo "$out" | grep -q '"restored":true' && ok "8c 恢复默认(实际撤销)" || bad "8c" "$out"
say "== 场景 9:迟到响应/断网(UI 行为)=="
+24
View File
@@ -59,6 +59,30 @@ class ComponentGovernanceTests(unittest.TestCase):
self.assertNotEqual(result.returncode,0,'a hidden component writer must block synchronization')
self.assertTrue((self.child/'.git/index.lock').exists())
def test_startup_receipt_rejects_changed_ignored_component_objects(self):
spec=importlib.util.spec_from_file_location('component_evidence_validation',SCRIPTS/'evidence.py')
module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
self.run_git(self.root,'update-ref','refs/remotes/origin/main','HEAD')
before=module.snapshot()
receipt=module.create_receipt('make precheck','[verdict] READY\n',0,before,before,
timestamp='2026-10-13T00:00:00+00:00',cwd=str(self.root),
git_common_dir=module.common_dir())
module.validate_precheck(receipt)
(self.child/'value').write_text('changed after startup')
with self.assertRaises(module.EvidenceError):
module.validate_precheck(receipt)
def test_new_layout_cannot_use_receipt_without_component_identity(self):
spec=importlib.util.spec_from_file_location('component_evidence_legacy',SCRIPTS/'evidence.py')
module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
self.run_git(self.root,'update-ref','refs/remotes/origin/main','HEAD')
before=module.snapshot();before.pop('components')
receipt=module.create_receipt('make precheck','[verdict] READY\n',0,before,before,
timestamp='2026-10-13T00:00:00+00:00',cwd=str(self.root),
git_common_dir=module.common_dir())
with self.assertRaises(module.EvidenceError):
module.validate_precheck(receipt)
def test_receipts_reject_component_path_escape(self):
data=json.loads((self.root/'components.json').read_text())
data['components'][0]['path']='../outside'
+23 -1
View File
@@ -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,27 @@ 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_fixed_candidate_capture_preserves_checkout(self):
self.git("add", ".task/main.md")
self.git("commit", "-qm", "Save fixture card before validation")
head = self.git("rev-parse", "HEAD")
card = self.card.read_bytes()
index = self.git("ls-files", "--stage")
data = self.capture("printf 'fixed candidate check\\n'")
self.assertEqual(data["before"]["head"], head)
self.assertEqual(data["after"]["head"], head)
self.assertEqual(data["output"], "fixed candidate check\n")
self.assertEqual(self.git("rev-parse", "HEAD"), head)
self.assertEqual(self.git("ls-files", "--stage"), index)
self.assertEqual(self.card.read_bytes(), card)
self.assertEqual(self.git("status", "--porcelain"), "")
self.assertEqual(self.receipt.stat().st_mode & 0o777, 0o600)
def test_capture_without_card_and_import_without_rerun(self):
self.card.unlink()
data = self.capture("printf 'once\\n' >> invocation; cat invocation")
+80
View File
@@ -0,0 +1,80 @@
"""Real disposable Git scenarios; these do not claim CI or automatic queue locking."""
import unittest
import test_task as lifecycle
class QueueTrialTests(unittest.TestCase):
def setUp(self):
self.fixture = lifecycle.TaskLifecycleTests(methodName="runTest")
self.fixture.setUp()
self.addCleanup(self.fixture.doCleanups)
def create_task(self, branch):
f = self.fixture
wt = f.root / branch.replace("/", "-")
f.git(f.repo, "worktree", "add", "-b", branch, str(wt), "origin/main")
f.record_task_card(wt)
name = branch.split("/")[1]
(wt / name).write_text(name + "\n")
f.git(wt, "add", name)
f.git(wt, "commit", "-m", "Add independent fixture change")
return wt
def test_sequential_delivery_does_not_sync_waiting_branches(self):
f = self.fixture
branches = ["feature/a", "feature/b", "feature/c"]
worktrees = {branch: self.create_task(branch) for branch in branches}
waiting = {branch: f.git(wt, "rev-parse", "HEAD").stdout for branch, wt in worktrees.items()}
f.advance_remote()
for position, branch in enumerate(branches):
wt = worktrees[branch]
f.task(wt, "sync")
candidate = f.git(wt, "rev-parse", "HEAD").stdout
second = f.task(wt, "sync")
self.assertIn("changed=0", second.stdout)
self.assertEqual(f.git(wt, "rev-parse", "HEAD").stdout, candidate)
self.assertEqual(f.git(wt, "status", "--porcelain").stdout, "")
f.git(f.repo, "merge", "--no-ff", branch, "-m", "Merge fixture candidate")
f.git(f.repo, "push", "origin", "main")
for pending in branches[position + 1:]:
self.assertEqual(f.git(worktrees[pending], "rev-parse", "HEAD").stdout, waiting[pending])
for name in ("a", "b", "c"):
self.assertEqual((f.repo / name).read_text(), name + "\n")
def test_conflicted_task_does_not_block_an_independent_healthy_task(self):
f = self.fixture
conflict = self.create_task("feature/conflict")
healthy = self.create_task("feature/healthy")
(conflict / "tracked").write_text("task choice\n")
f.git(conflict, "commit", "-am", "Conflicting task edit")
(f.repo / "tracked").write_text("target choice\n")
f.git(f.repo, "commit", "-am", "Target edit")
f.git(f.repo, "push", "origin", "main")
failed = f.task(conflict, "sync", check=False)
self.assertNotEqual(failed.returncode, 0)
self.assertNotIn("[synced]", failed.stdout)
self.assertIn("UU tracked", f.git(conflict, "status", "--porcelain").stdout)
succeeded = f.task(healthy, "sync")
self.assertIn("[synced]", succeeded.stdout)
self.assertEqual((healthy / "tracked").read_text(), "target choice\n")
self.assertEqual((healthy / "healthy").read_text(), "healthy\n")
def test_external_target_advance_requires_a_new_candidate(self):
f = self.fixture
task = self.create_task("feature/a")
f.task(task, "sync")
original = f.git(task, "rev-parse", "HEAD").stdout
f.advance_remote()
target = f.git(f.repo, "rev-parse", "HEAD").stdout.strip()
result = f.task(task, "sync")
current = f.git(task, "rev-parse", "HEAD").stdout
self.assertNotEqual(current, original)
self.assertIn("changed=1", result.stdout)
f.git(task, "merge-base", "--is-ancestor", target, "HEAD")
self.assertEqual((task / "a").read_text(), "a\n")
self.assertEqual((task / "new-main").read_text(), "new main\n")
if __name__ == "__main__":
unittest.main()
+92 -1
View File
@@ -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 清理竞态
@@ -69,6 +70,20 @@ class TaskLifecycleTests(unittest.TestCase):
self.git(self.repo, "commit", "-m", "Advance main")
self.git(self.repo, "push", "origin", "main")
def record_task_card(self, wt):
branch = self.git(wt, "branch", "--show-current").stdout.strip()
r0 = self.git(wt, "rev-parse", "origin/main").stdout.strip()
card = wt / ".task" / (branch.replace("/", "-") + ".md")
card.parent.mkdir(exist_ok=True)
card.write_text(
f"# {branch}\n任务主记录:fixture;状态:进行中;owner:Lifecycle Test\n"
f"- 权威仓:fixture;目标分支:main;R0(initial_base_sha)= {r0}\n"
f"- 工作区:{wt}(worktree);分支:{branch}\n"
)
self.git(wt, "add", str(card.relative_to(wt)))
self.git(wt, "commit", "-m", "Record fixture task")
return card
def add_submodule(self):
child = self.root / "component"
self.git(self.root, "init", "--initial-branch=main", str(child))
@@ -105,6 +120,76 @@ class TaskLifecycleTests(unittest.TestCase):
reading = next(line for line in result.stdout.splitlines() if line.startswith("[required-reading]"))
self.assertIn("policy/delivery.md", reading)
def test_sync_same_base_preserves_head_and_card(self):
wt = self.worktree()
card = self.record_task_card(wt)
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
content = card.read_bytes()
index = self.git(wt, "ls-files", "--stage").stdout
for _ in range(2):
result = self.task(wt, "sync")
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
self.assertEqual(card.read_bytes(), content)
self.assertEqual(self.git(wt, "ls-files", "--stage").stdout, index)
self.assertEqual(self.git(wt, "status", "--porcelain").stdout, "")
self.assertIn("changed=0", result.stdout)
self.assertIn(f"tested_head={head}", result.stdout)
def test_sync_new_base_reports_actual_final_head(self):
wt = self.worktree()
card = self.record_task_card(wt)
self.advance_remote()
base = self.git(self.repo, "rev-parse", "HEAD").stdout.strip()
result = self.task(wt, "sync")
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
self.git(wt, "merge-base", "--is-ancestor", base, head)
self.assertIn(f"sync_base={base}", result.stdout)
self.assertIn(f"tested_head={head}", result.stdout)
self.assertIn("changed=1", result.stdout)
content = card.read_bytes()
self.task(wt, "sync")
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
self.assertEqual(card.read_bytes(), content)
def test_sync_preserves_dirty_parent_and_staged_changes(self):
for staged in (False, True):
with self.subTest(staged=staged):
wt = self.worktree(name="feature/staged" if staged else "feature/dirty")
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
(wt / "tracked").write_text("keep owner work\n")
if staged:
self.git(wt, "add", "tracked")
index = self.git(wt, "ls-files", "--stage").stdout
result = self.task(wt, "sync", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
self.assertEqual(self.git(wt, "ls-files", "--stage").stdout, index)
self.assertEqual((wt / "tracked").read_text(), "keep owner work\n")
self.git(self.repo, "worktree", "remove", "--force", str(wt))
def test_sync_rejects_unreachable_target_without_changing_head(self):
wt = self.worktree()
self.record_task_card(wt)
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
self.git(wt, "remote", "set-url", "origin", str(self.root / "missing.git"))
result = self.task(wt, "sync", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertIn("REMOTE_UNVERIFIED", result.stdout)
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
def test_sync_conflict_is_not_reported_as_synced(self):
wt = self.worktree()
self.record_task_card(wt)
(wt / "tracked").write_text("task choice\n")
self.git(wt, "commit", "-am", "Task edit")
(self.repo / "tracked").write_text("target choice\n")
self.git(self.repo, "commit", "-am", "Target edit")
self.git(self.repo, "push", "origin", "main")
result = self.task(wt, "sync", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertNotIn("[synced]", result.stdout)
self.assertIn("UU tracked", self.git(wt, "status", "--porcelain").stdout)
def test_sync_materializes_updated_component(self):
child = self.add_submodule()
wt = self.worktree()
@@ -396,6 +481,7 @@ class TaskLifecycleTests(unittest.TestCase):
(wt / ".task/feature-test.md").write_text("# Task\n")
self.git(wt, "add", ".task")
self.git(wt, "commit", "-m", "Create task card")
self.advance_remote()
hook = self.repo / ".git/hooks/pre-commit"
hook.write_text("#!/bin/sh\nexit 1\n")
hook.chmod(0o755)
@@ -611,6 +697,11 @@ class TaskLifecycleTests(unittest.TestCase):
self.assertIn(f"{field}:无", card)
for field in ("当前阶段", "当前增量", "方案引用", "实施计划引用", "测试计划引用"):
self.assertIn(field + ":", card)
self.assertIn("## 交付调度\n", card)
queue = card.split("## 交付调度\n", 1)[1].split("\n## ", 1)[0]
self.assertIn("调度状态:未入队", queue)
for field in ("调度记录", "entry_id", "attempt_id", "窗口执行者", "固定候选"):
self.assertIn(field + ":无", queue)
self.assertIn("## 关联方向与规划\n", card)
planning = card.split("## 关联方向与规划\n", 1)[1].split("\n## ", 1)[0]
for field in ("适用状态", "主记录路径/版本", "对应里程碑"):
+321
View File
@@ -0,0 +1,321 @@
"""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 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"})
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)
class WorkspaceRecoveryTests(unittest.TestCase):
"""New-format recovery keeps its branch and fixes startup rule availability."""
def setUp(self):
import test_workspace_cutover as cutover
self.fixture = cutover.WorkspaceCutoverTests(methodName="runTest")
self.fixture.setUp()
self.addCleanup(self.fixture.doCleanups)
f = self.fixture
f.adopt()
self.wt = f.worktree()
r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip()
self.card = self.wt / ".task/feature-old.md"
self.card.parent.mkdir()
self.card.write_text("# Recovery\n任务主记录:fixture;状态:进行中;owner:Cutover Test\n"
f"- 权威仓:fixture;目标分支:main;R0(initial_base_sha)= {r0}\n"
f"- 工作区:{self.wt}(worktree);分支:feature/old\n")
f.git(self.wt, "add", ".task")
f.git(self.wt, "commit", "-m", "Register current task")
self.env = f.env | {"TASK_WORK_MODE": "local"}
def precheck(self):
return self.fixture.task(self.wt, "precheck", env=self.env, check=False)
def test_local_recovery_verifies_fixed_rules_without_sync_or_application_materialization(self):
f = self.fixture
head = f.git(self.wt, "rev-parse", "HEAD").stdout
(f.repo / "new-main").write_text("main advanced\n")
f.git(f.repo, "add", "new-main")
f.git(f.repo, "commit", "-m", "Advance main")
f.git(f.repo, "push", "origin", "main")
result = self.precheck()
self.assertEqual(result.returncode, 0, result.stdout)
self.assertIn("[verdict] LOCAL_READY", result.stdout)
self.assertIn("[rules]", result.stdout)
self.assertNotIn("[verdict] READY", result.stdout)
self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head)
self.assertFalse((self.wt / ".workspace").exists())
def test_local_recovery_refuses_missing_fixed_rules_before_readiness(self):
f = self.fixture
lock = self.wt / "components.lock.json"
record = json.loads(lock.read_text())
record["components"]["Rules"]["commit"] = "0" * 40
lock.write_text(json.dumps(record))
f.git(self.wt, "commit", "-am", "Pin unavailable rules")
before = self.card.read_bytes()
result = self.precheck()
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertNotIn("[verdict] LOCAL_READY", result.stdout)
self.assertEqual(self.card.read_bytes(), before)
self.assertFalse((self.wt / ".workspace").exists())
def test_resume_rechecks_fixed_rules_before_importing_receipt(self):
f = self.fixture
receipt = f.base / "resume.json"
f.call(self.wt, "bash", "scripts/evidence.sh", env=self.env | {"RECEIPT": str(receipt), "CMD": "make precheck"})
# The cache is not an application overlay; a changed rule source cannot
# turn a formerly valid local precheck into current startup authority.
rules = list((f.base / "cache").rglob("core/baseline.md"))
self.assertTrue(rules, "fixed startup rules were never prepared")
rules[0].write_text("owner changed rules\n")
before = self.card.read_bytes()
result = f.task(self.wt, "resume", env=self.env | {"PRECHECK_RECEIPT": str(receipt)}, check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertNotIn("[resumed]", result.stdout)
self.assertEqual(self.card.read_bytes(), before)
self.assertEqual(rules[0].read_text(), "owner changed rules\n")
def test_local_resume_preserves_registered_edit_branch_and_unsaved_component(self):
f = self.fixture
f.workspace(self.wt, "edit", "--name", "App", "--branch", "feature/component-owner")
child = self.wt / ".workspace/App"
(child / "content").write_text("preserve author edit\n")
receipt = f.base / "resume-edit.json"
f.call(self.wt, "bash", "scripts/evidence.sh", env=self.env | {"RECEIPT": str(receipt), "CMD": "make precheck"})
result = f.task(self.wt, "resume", env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
self.assertIn("[resumed]", result.stdout)
self.assertEqual((child / "content").read_text(), "preserve author edit\n")
self.assertEqual(f.git(child, "branch", "--show-current").stdout.strip(), "feature/component-owner")
if __name__ == "__main__":
unittest.main()