From 9a3ef83c7f4af152ef8360b50901300eb06bdc09 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Sun, 18 Oct 2026 02:45:45 +0000 Subject: [PATCH] agent-exec transport 8112713bc92d127e3ad4155251672a88/be7ee7a3e957 --- Makefile | 4 +- scripts/task.sh | 142 ++++++++++++++++++++++++++++++-------- tests/test_task.py | 165 ++++++++++++++++++++++++++++++++++++++------- 3 files changed, 254 insertions(+), 57 deletions(-) diff --git a/Makefile b/Makefile index 66ef7dea..f95d380d 100644 --- a/Makefile +++ b/Makefile @@ -46,7 +46,7 @@ task-triage-decide: @VERDICT="$(VERDICT)" INITIATIVE="$(INITIATIVE)" REASON="$(REASON)" bash $(TASK) triage-decide task-start: - @if [ -z "$(NAME)" ]; then echo "usage: make task-start NAME=feature/ TRIAGE_RECEIPT="; exit 2; fi + @if [ -z "$(NAME)" ]; then echo "usage: make task-start NAME=feature/ TRIAGE_RECEIPT= [PRECHECK_RECEIPT=](KIND=child 已登记在父卡时可省略 TRIAGE_RECEIPT)"; exit 2; fi @bash $(TASK) start "$(NAME)" task-resume: @@ -103,7 +103,7 @@ help: @echo "make agent-exec-admit 校验暂存路径具备 transport 来源或属于 host 豁免" @echo "make task-triage REQ='<需求>' TRIAGE_RECEIPT= 以远端为准发现接纳中的 initiative 并写分流收据(候选存在时 exit 5,待决策)" @echo "make task-triage-decide TRIAGE_RECEIPT= VERDICT=JOIN|NEW [INITIATIVE=] REASON='<理由>' 记录显式分流决策(不按数量自动 JOIN)" - @echo "make task-start NAME=feature/ TRIAGE_RECEIPT= [KIND=...] [INITIATIVE=...] 建任务前核验分流收据(失败不建分支)" + @echo "make task-start NAME=feature/ TRIAGE_RECEIPT= [KIND=...] [INITIATIVE=...] [PRECHECK_RECEIPT=] 建任务前核验分流收据(失败不建分支);KIND=child 已登记在父卡「子任务登记」时可省略 TRIAGE_RECEIPT;省略 PRECHECK_RECEIPT 时自动运行启动检查" @echo "make task-pr-check [PR=N] [FINAL=1] 核验当前任务分支 PR 的 base/head 门禁(远端为准;FINAL=1 用于 initiative 收口)" @echo "make ci-wait [SHA=<完整 SHA>] [TIMEOUT=1200] [INTERVAL=15] [CONTEXTS=a,b] 阻塞等待该 SHA 的 CI 结论(失败即停)" diff --git a/scripts/task.sh b/scripts/task.sh index c5ddbbac..1a2b8aac 100755 --- a/scripts/task.sh +++ b/scripts/task.sh @@ -152,29 +152,59 @@ cmd_start() { echo "$initiative" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "invalid parent initiative branch" case "${name#*/}" in "${initiative#*/}"-?*) ;; *) die "child slug must start with parent initiative slug plus '-'" ;; esac fi - [ -n "${TRIAGE_RECEIPT:-}" ] || die "TRIAGE_RECEIPT required: make task-triage REQ='<需求>' TRIAGE_RECEIPT= and decide first (no branch created)" + local slug planned_child=0 + slug=$(echo "$name" | tr '/' '-') + if [ -z "${TRIAGE_RECEIPT:-}" ]; then + [ "$kind" = child ] || die "TRIAGE_RECEIPT required: make task-triage REQ='<需求>' TRIAGE_RECEIPT= and decide first (no branch created)" + planned_child=1 + fi fetch_target - [ -n "${PRECHECK_RECEIPT:-}" ] || die "PRECHECK_RECEIPT required: capture RECEIPT=/tmp/task-precheck.json CMD='make precheck' make evidence first" + if [ -z "${PRECHECK_RECEIPT:-}" ]; then + PRECHECK_RECEIPT=$(mktemp "${TMPDIR:-/tmp}/task-precheck-${slug}.XXXXXX") + # evidence 捕获只接受未占用路径(独占创建):mktemp 只用来取唯一名。 + rm -f -- "$PRECHECK_RECEIPT" + # start 只接受严格 READY 的 delivery precheck,local 模式的 LOCAL_READY 不能用于建任务。 + (cd "$(git rev-parse --show-toplevel)" \ + && TASK_WORK_MODE=delivery RECEIPT="$PRECHECK_RECEIPT" CMD='make precheck' bash "${SCRIPT_DIR}/evidence.sh") \ + || die "auto precheck is not READY (receipt=${PRECHECK_RECEIPT}); resolve the verdict above first (no branch created)" + echo "[precheck] auto receipt=${PRECHECK_RECEIPT}" + fi PRECHECK_RECEIPT=$(realpath -- "$PRECHECK_RECEIPT") VALIDATE_RECEIPT="$PRECHECK_RECEIPT" VALIDATE_RECEIPT_PURPOSE=start bash "${SCRIPT_DIR}/evidence.sh" - # 分流核验必须先于任何 worktree/分支创建:收据与当前远端候选不一致则无副作用拒绝。 - [ -f "$TRIAGE_RECEIPT" ] || die "triage receipt missing: ${TRIAGE_RECEIPT} (no branch created)" - TRIAGE_RECEIPT=$(realpath -- "$TRIAGE_RECEIPT") - local -a triage_args=(verify --receipt "$TRIAGE_RECEIPT" --kind "$kind") - if [ "$kind" = child ]; then triage_args+=(--initiative "$initiative"); fi - local triage_out triage_line triage_head triage_verdict triage_init triage_rsha triage_digest triage_note - if ! triage_out=$(python3 "${SCRIPT_DIR}/task_triage.py" "${triage_args[@]}"); then - printf '%s\n' "$triage_out" >&2 - die "triage receipt rejected; no worktree or branch was created" + local triage_out triage_line triage_head="" triage_verdict triage_init triage_rsha triage_digest triage_note + local parent_head="" parent_source="" parent_card + if [ "$planned_child" -eq 1 ]; then + # 计划内子任务免分流:父 initiative 卡片的子任务登记已列出本分支(delivery 读 origin,local 读本地父分支)。 + if [ "$(work_mode)" = delivery ]; then + parent_head=$(remote_parent_head "$initiative") || die "remote parent initiative ${initiative} unavailable; no worktree or branch was created" + parent_source=origin + else + parent_head=$(git rev-parse --verify "refs/heads/${initiative}^{commit}" 2>/dev/null) || die "parent initiative branch ${initiative} missing locally" + parent_source="local(非正式准入)" + fi + parent_card=".task/$(echo "$initiative" | tr '/' '-').md" + git show "${parent_head}:${parent_card}" 2>/dev/null | registered_child "$name" \ + || die "${name} is not registered in ${initiative} ${parent_card} ## 子任务登记; register it in the parent card first or provide TRIAGE_RECEIPT (no worktree or branch was created)" + triage_note="- 需求分流:免(父 initiative 子任务登记已列出 ${name};parent_head_sha=${parent_head})" + else + # 分流核验必须先于任何 worktree/分支创建:收据与当前远端候选不一致则无副作用拒绝。 + [ -f "$TRIAGE_RECEIPT" ] || die "triage receipt missing: ${TRIAGE_RECEIPT} (no branch created)" + TRIAGE_RECEIPT=$(realpath -- "$TRIAGE_RECEIPT") + local -a triage_args=(verify --receipt "$TRIAGE_RECEIPT" --kind "$kind") + if [ "$kind" = child ]; then triage_args+=(--initiative "$initiative"); fi + if ! triage_out=$(python3 "${SCRIPT_DIR}/task_triage.py" "${triage_args[@]}"); then + printf '%s\n' "$triage_out" >&2 + die "triage receipt rejected; no worktree or branch was created" + fi + triage_line=$(printf '%s\n' "$triage_out" | sed -n 's/^\[triage-verified\] //p') + triage_head=$(triage_field selected_head_sha "$(printf '%s\n' "$triage_out" | sed -n 's/^\[triage-head\] //p')") + triage_verdict=$(triage_field verdict "$triage_line") + triage_init=$(triage_field selected_initiative "$triage_line") + triage_rsha=$(triage_field requirement_sha256 "$triage_line") + triage_digest=$(triage_field receipt_sha256 "$triage_line") + [ -n "$triage_verdict" ] && [ -n "$triage_rsha" ] && [ -n "$triage_digest" ] || die "triage verification output unparsable; no branch created" + triage_note="- 需求分流:verdict=${triage_verdict};selected_initiative=${triage_init:-无};requirement_sha256=${triage_rsha};triage_receipt=${TRIAGE_RECEIPT};receipt_sha256=${triage_digest}" fi - triage_line=$(printf '%s\n' "$triage_out" | sed -n 's/^\[triage-verified\] //p') - triage_head=$(triage_field selected_head_sha "$(printf '%s\n' "$triage_out" | sed -n 's/^\[triage-head\] //p')") - triage_verdict=$(triage_field verdict "$triage_line") - triage_init=$(triage_field selected_initiative "$triage_line") - triage_rsha=$(triage_field requirement_sha256 "$triage_line") - triage_digest=$(triage_field receipt_sha256 "$triage_line") - [ -n "$triage_verdict" ] && [ -n "$triage_rsha" ] && [ -n "$triage_digest" ] || die "triage verification output unparsable; no branch created" - triage_note="- 需求分流:verdict=${triage_verdict};selected_initiative=${triage_init:-无};requirement_sha256=${triage_rsha};triage_receipt=${TRIAGE_RECEIPT};receipt_sha256=${triage_digest}" check_worktree before-sync # 已合入的同名分支不得重建(2026-10-05 实测:合入后再 task-start 同名会从新 main 长出 # 第二段历史,空卡覆盖已合入任务卡,两轮证据差点丢失)。复核/追加沿用原分支或换新 slug。 @@ -188,16 +218,17 @@ cmd_start() { [ "$(git status --porcelain | { grep -v '^.. \.zcode/' || true; } | wc -l)" -eq 0 ] || die "dirty tree — resolve first (precheck verdict BUSY)" [ "$(git submodule status --recursive 2>/dev/null | { grep -cE '^\+|^U' || true; })" -eq 0 ] || die "submodule drift/conflict — resolve first (precheck verdict BUSY)" git merge-base --is-ancestor "origin/${TARGET_BRANCH}" HEAD || die "local ${TARGET_BRANCH} behind origin — pull --ff-only first" - local rmain slug wt mainroot start_ref parent_head="" parent_source="" parent_note="" + local rmain wt mainroot start_ref parent_note="" rmain=$(git rev-parse "origin/${TARGET_BRANCH}") start_ref=$rmain - slug=$(echo "$name" | tr '/' '-') local branch_meta case "$kind" in initiative) branch_meta="- 分支类型:initiative;initiative_id:${slug};root_target:${TARGET_BRANCH};immediate_target:${TARGET_BRANCH};accepting_requirements:true" ;; child) - if [ "$(work_mode)" = delivery ]; then + if [ "$planned_child" -eq 1 ]; then + : # 父头已在登记核验时解析(delivery 为刚取回的 origin 头)。 + elif [ "$(work_mode)" = delivery ]; then # 交付模式:父 initiative 只认 origin 上刚取回的头,且必须等于分流收据核验的头(未准入的本地提交不算)。 [ -n "$triage_head" ] || die "triage receipt carries no selected initiative head; re-run make task-triage (no worktree or branch was created)" parent_head=$(remote_parent_head "$initiative") || die "remote parent initiative ${initiative} unavailable; no worktree or branch was created" @@ -238,14 +269,26 @@ cmd_start() { && echo "[warn] worktree 子模块物化不完整:${sub_drift} 项漂移/未初始化——构建前先核对(git -C $wt submodule status --recursive)" local card="$wt/.task/${slug}.md" mkdir -p "$wt/.task" - cat > "$card" </dev/null || echo '(local)') + owner=$(git config user.name || echo unknown) + { + cat <;状态:进行中;owner:$(git config user.name || echo unknown) +任务主记录:;状态:进行中;owner:${owner} -## 基线与授权 - -- 权威仓:$(git -C "$wt" remote get-url origin 2>/dev/null || echo '(local)');目标分支:${TARGET_BRANCH};R0(initial_base_sha)= ${rmain} +EOF + [ "$template" = lite ] || printf '## 基线与授权\n\n' + cat < +EOF + if [ "$template" = charter ]; then + cat <<'EOF' +<章程:用途与目标、接纳范围与非目标、方案/计划链接、分级测试策略、验收条件、追加领域规则、生命周期。> + +## 子任务登记 + +| 子任务分支 | 必需 | 状态 | PR | +|---|---|---|---| +| 待登记 | 否 | — | — | +EOF + else + echo '<预期结果、受影响入口/模块/契约;明确不做什么。>' + fi + cat <<'EOF' ## Agent 实施收据 -按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/工作区。实施、修复与测试的正常候选仅为 Cursor、Antigravity(agy)和 PI;每个候选对安全可恢复失败最多 3 次全新尝试,然后才换下一候选。三者都安全耗尽后,才允许一次全新的 Codex CLI 紧急尝试。Codex 平时只用于规划、方案设计、实施计划或独立 Review。显式 fail-stop(failed-unknown、scope-expansion、blocked、cancelled、无效配置、未对账 Git 状态与安全失败)立即停止。不因推理时长、工具次数、规划方式或首次写文件时间判定失败;尝试只在显式终态失败、进程退出、总超时,或 stdin/stdout/stderr 均无活动的空闲超时时失败。CLI 可在目标、范围与安全边界内自主检查、计划、测试并迭代。实施集成必须是成功 transport 上的 scripts/agent-exec apply,或 pre-commit admit 的精确内容来源。host 直接管理的豁免仅为 .task/**、docs/superpowers/specs/** 与 docs/superpowers/plans/**。fallback 列只记录该行是否为 Codex 紧急尝试。安全耗尽不发出 host-fallback-authorized,终态为 unavailable 或 blocked。不授权 host 直接撰写该单元。原始日志保留在源码外收据路径。 +路由与 fail-stop 规则见根目录 AGENT-EXECUTION.md 与 policy/delivery.md;每次尝试一行,原始日志留在源码外收据路径。 | 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 | |---|---|---|---|---|---|---|---|---|---|---| | 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — | +EOF + [ "$template" = lite ] || cat <<'EOF' ## 关联方向与规划 @@ -308,6 +371,8 @@ ${parent_note} | 增量/试验 | 假设 | 范围 | 判断和停止条件 | 授权/决策依据 | 结果 | 变更影响 | 需重审补测 | |---|---|---|---|---|---|---|---| | 待定义 | — | — | — | 待核对 | NOT_RUN | 待评估 | 待评估 | +EOF + cat < | 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) | |---|---|---|---|---|---| +EOF + [ "$template" = lite ] || cat <<'EOF' ## 交付调度 @@ -328,12 +395,16 @@ ${parent_note} - 固定候选:无(base/head、组件与相关配置引用实际尝试) - 调度依据:policy/merge-queue.md;新模式启用前按既有交付门禁 - 固定后证据:源码外 RECEIPT;实际结果后按授权原样导入和闭合 +EOF + cat <<'EOF' ## 结果与验证(完成后填写) - 结果: - 验证:<结论摘要 PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据由 make evidence 写入上方证据表> - 影响与剩余项: +EOF + [ "$template" = lite ] || cat <<'EOF' ## 规划进展与下一步 @@ -344,6 +415,8 @@ ${parent_note} - 预期产物/验收条件:待定义 - 重复任务核对:待核对(已有/在途任务、PR 与规划记录) - 启动授权:未授权(建议不自动启动新任务,仍须明确授权) +EOF + cat <<'EOF' ## 合入与清理 @@ -353,6 +426,7 @@ ${parent_note} - 合入记录:NOT_RUN(交付授权范围、PR、被核验 HEAD、合入 SHA、目标分支及内容核验) - 清理记录:NOT_RUN(授权依据、写者退出确认、worktree/分支/任务卡处置) EOF + } > "$card" (cd "$wt" && IMPORT_RECEIPT="$PRECHECK_RECEIPT" bash "${SCRIPT_DIR}/evidence.sh") git -C "$wt" add .task git -C "$wt" commit -q -m "Record the task card for ${slug}" \ @@ -511,6 +585,16 @@ remote_parent_head() { # $1=父 initiative 分支 → 打印刚从 origin 取回 fi git rev-parse --verify "refs/remotes/origin/$1^{commit}" } +registered_child() { # stdin=父 initiative 卡片;$1=子任务分支 → 子任务登记表(task_pr_check 同一解析)列出该分支返回 0 + python3 -c 'import sys +sys.path.insert(0, sys.argv[1]) +from task_pr_check import Stop, child_registry +try: + rows = child_registry(sys.stdin.read()) +except Stop as error: + sys.exit("ERROR: " + error.message) +sys.exit(0 if any(row["子任务分支"].strip("`").strip() == sys.argv[2] for row in rows) else 1)' "$SCRIPT_DIR" "$1" +} resolve_parent_ref() { # $1=父 initiative 分支 → 打印用于同步/核验的提交(delivery 仅远端;local 取远端与本地包含对方的一侧) git check-ref-format "refs/heads/$1" >/dev/null || die "invalid parent branch $1" if [ "$(work_mode)" = delivery ]; then diff --git a/tests/test_task.py b/tests/test_task.py index e8ed576a..c8939497 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -1,6 +1,7 @@ """Lifecycle regressions against real disposable Git repositories and remotes.""" import os import json +import re import hashlib import http.server import threading @@ -15,6 +16,10 @@ import urllib.parse SOURCE = Path(__file__).resolve().parents[1] TRIAGE_COMMANDS = ("start", "triage", "triage-decide") +ROUTING_LINE = ("路由与 fail-stop 规则见根目录 AGENT-EXECUTION.md 与 policy/delivery.md;" + "每次尝试一行,原始日志留在源码外收据路径。") +LITE_SECTIONS = ["## 目标与非目标", "## Agent 实施收据", "## 同步记录", "## 证据(Evidence)", + "## 结果与验证(完成后填写)", "## 合入与清理"] class FakeGitea: @@ -89,6 +94,8 @@ class TaskLifecycleTests(unittest.TestCase): "GIT_CONFIG_COUNT": "2", "GIT_CONFIG_KEY_0": "gc.auto", "GIT_CONFIG_VALUE_0": "0", "GIT_CONFIG_KEY_1": "maintenance.auto", "GIT_CONFIG_VALUE_1": "false", + # task-start 自动 precheck 收据落在 TMPDIR:限定在夹具目录内随测试清理。 + "TMPDIR": str(self.root), # 桩位于 127.0.0.1:禁止经代理转发,保证测试不触达外部网络。 "NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost"}) for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "GITEA_API_URL", @@ -771,12 +778,30 @@ class TaskLifecycleTests(unittest.TestCase): else: self.gitea.prs.append(FakeGitea.pr(len(self.gitea.prs) + 1, branch, sha)) - def test_start_requires_precheck_receipt_before_creating_worktree(self): + def test_start_without_precheck_receipt_runs_and_imports_auto_precheck(self): triage = self.triage_receipt() + result = self.task(self.repo, "start", "feature/new", + env=self.env | {"TRIAGE_RECEIPT": str(triage)}) + line = next(row for row in result.stdout.splitlines() if row.startswith("[precheck] auto receipt=")) + receipt = Path(line.split("=", 1)[1]) + self.assertEqual(receipt.parent, self.root) + self.assertIn("feature-new", receipt.name) + data = json.loads(receipt.read_text()) + self.assertEqual(data["command"], "make precheck") + self.assertIn("[verdict] READY", data["output"]) + card = (self.root / "repo-wt-feature-new/.task/feature-new.md").read_text() + self.assertIn(data["receipt_sha256"], card) + + def test_start_refuses_busy_auto_precheck_without_side_effects(self): + triage = self.triage_receipt() + (self.repo / "stray").write_text("unfinished\n") result = self.task(self.repo, "start", "feature/new", check=False, env=self.env | {"TRIAGE_RECEIPT": str(triage)}) self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("SHARED_WORKSPACE_BUSY", result.stdout) + self.assertIn("auto precheck is not READY", result.stdout) self.assertFalse((self.root / "repo-wt-feature-new").exists(), result.stdout) + self.assertEqual(self.git(self.repo, "branch", "--list", "feature/new").stdout.strip(), "") def test_start_preserves_merged_local_and_remote_branch_names(self): receipt = self.capture_precheck() @@ -833,23 +858,9 @@ class TaskLifecycleTests(unittest.TestCase): "| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |", agent_receipts, ) - self.assertIn("Cursor、Antigravity(agy)和 PI", agent_receipts) - self.assertIn("最多 3 次全新尝试", agent_receipts) - self.assertIn("一次全新的 Codex CLI 紧急尝试", agent_receipts) - self.assertIn("规划、方案设计、实施计划或独立 Review", agent_receipts) - self.assertIn("显式 fail-stop", agent_receipts) - self.assertIn("不因推理时长、工具次数、规划方式或首次写文件时间判定失败", agent_receipts) - self.assertIn("stdin/stdout/stderr 均无活动的空闲超时", agent_receipts) - self.assertIn("自主检查、计划、测试并迭代", agent_receipts) - self.assertIn("scripts/agent-exec apply", agent_receipts) - self.assertIn("pre-commit admit", agent_receipts) - self.assertIn(".task/**", agent_receipts) - self.assertIn("docs/superpowers/specs/**", agent_receipts) - self.assertIn("docs/superpowers/plans/**", agent_receipts) - self.assertIn("fallback 列只记录该行是否为 Codex 紧急尝试", agent_receipts) - self.assertIn("不授权 host 直接撰写该单元", agent_receipts) - self.assertIn("安全耗尽不发出 host-fallback-authorized", agent_receipts) - self.assertIn("终态为 unavailable 或 blocked", agent_receipts) + self.assertIn(ROUTING_LINE, agent_receipts) + self.assertNotIn("Cursor、Antigravity(agy)和 PI", agent_receipts) + self.assertNotIn("## 子任务登记", card) for field in ("当前阶段", "当前增量", "方案引用", "实施计划引用", "测试计划引用"): self.assertIn(field + ":", card) self.assertIn("## 交付调度\n", card) @@ -1252,6 +1263,8 @@ class TaskLifecycleTests(unittest.TestCase): self.assertEqual(self.card_helper("card_immediate_target", card), "feature/alpha") self.assertEqual(self.card_helper("card_initiative_id", card), "feature-alpha") self.assertEqual(self.card_helper("card_root_target", card), "main") + self.assertIn("- 需求分流:verdict=JOIN;selected_initiative=feature/alpha;", text) + self.assert_lite_card(text, "feature/alpha-login") def test_start_child_rejects_missing_initiative_or_wrong_slug(self): self.start_initiative() @@ -1263,6 +1276,104 @@ class TaskLifecycleTests(unittest.TestCase): self.assertNotEqual(result.returncode, 0, result.stdout) self.assertFalse(wt.exists()) + def register_child(self, parent, child, initiative="feature/alpha", publish=True): + card = parent / ".task" / (initiative.replace("/", "-") + ".md") + text = card.read_text() + self.assertIn("| 待登记 | 否 | — | — |", text) + card.write_text(text.replace("| 待登记 | 否 | — | — |", f"| `{child}` | 是 | 进行中 | — |", 1)) + self.git(parent, "add", ".task") + self.git(parent, "commit", "-m", f"Register {child}") + if publish: + self.publish_initiative(initiative) + return self.git(parent, "rev-parse", "HEAD").stdout.strip() + + def planned_child_env(self): + return self.env | {"TASK_BRANCH_KIND": "child", "TASK_INITIATIVE": "feature/alpha", + "TASK_PARENT_PR": "https://example.invalid/repo/pulls/7"} + + def assert_lite_card(self, text, name): + self.assertEqual([line for line in text.splitlines() if line.startswith("## ")], LITE_SECTIONS) + self.assertEqual(text.splitlines()[0], f"# {name} 任务卡") + self.assertRegex(text, r"(?m)^任务主记录:;状态:进行中;owner:.+$") + self.assertRegex(text, r"(?m)^- 权威仓:.+;目标分支:main;R0(initial_base_sha)= [0-9a-f]{40}$") + self.assertRegex(text, rf"(?m)^- 工作区:.+(worktree);分支:{re.escape(name)}$") + for prefix in ("- 分支类型:", "- 需求分流:", "- 授权与交付终点:", + "- 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)", + "- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)", + "- 合入执行:agent(", + "- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位)"): + self.assertRegex(text, "(?m)^" + re.escape(prefix)) + self.assertIn(ROUTING_LINE, text) + self.assertIn("| 待定义 | — | NOT_RUN | NOT_RUN |", text) + self.assertRegex(text, r"(?m)^\| 启动 \| [0-9a-f]{40} \| [0-9a-f]{40} \| ") + self.assertIn("| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |", text) + for field in ("Review 记录", "冲突记录", "CI 记录", "合入记录", "清理记录"): + self.assertIn(field + ":NOT_RUN", text) + self.assertNotIn("## 阶段记录", text) + + def test_start_planned_child_without_triage_when_registered_on_origin(self): + parent = self.start_initiative() + parent_head = self.register_child(parent, "feature/alpha-login") + result = self.task(self.repo, "start", "feature/alpha-login", env=self.planned_child_env()) + self.assertIn("[precheck] auto receipt=", result.stdout) + wt = self.root / "repo-wt-feature-alpha-login" + card = wt / ".task/feature-alpha-login.md" + text = card.read_text() + self.assertIn("- 需求分流:免(父 initiative 子任务登记已列出 feature/alpha-login;" + f"parent_head_sha={parent_head})", text) + self.assertIn(f"parent_head_sha:{parent_head};parent_pr:https://example.invalid/repo/pulls/7;" + "parent_source:origin", text) + self.git(wt, "merge-base", "--is-ancestor", parent_head, "HEAD") + self.assert_lite_card(text, "feature/alpha-login") + self.assertEqual(self.card_helper("card_immediate_target", card), "feature/alpha") + + def test_start_planned_child_refuses_unregistered_child_without_side_effects(self): + parent = self.start_initiative() + self.publish_initiative("feature/alpha") + for case in ("placeholder-only", "registered-locally-only"): + with self.subTest(case=case): + if case == "registered-locally-only": + self.register_child(parent, "feature/alpha-login", publish=False) + result = self.task(self.repo, "start", "feature/alpha-login", check=False, + env=self.planned_child_env()) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("not registered in feature/alpha", result.stdout) + self.assertIn("provide TRIAGE_RECEIPT", result.stdout) + self.assertFalse((self.root / "repo-wt-feature-alpha-login").exists()) + self.assertEqual(self.git(self.repo, "branch", "--list", "feature/alpha-login").stdout.strip(), "") + self.assertEqual(self.git(self.repo, "ls-remote", "--heads", "origin", + "feature/alpha-login").stdout.strip(), "") + + def test_start_initiative_card_is_charter_with_child_registry(self): + wt = self.start_initiative() + card = wt / ".task/feature-alpha.md" + text = card.read_text() + goal = text.split("## 目标与非目标\n", 1)[1].split("\n## ", 1)[0] + for item in ("用途与目标", "接纳范围与非目标", "方案/计划链接", "分级测试策略", "验收条件", + "追加领域规则", "生命周期"): + self.assertIn(item, goal) + sections = [line for line in text.splitlines() if line.startswith("## ")] + self.assertEqual(sections[sections.index("## 目标与非目标") + 1], "## 子任务登记") + self.assertIn("## 阶段记录", sections) + self.assertIn(ROUTING_LINE, text) + rows = self.run_cmd(self.repo, "python3", "-c", + "import json, sys; sys.path.insert(0, 'scripts');" + "from task_pr_check import child_registry;" + "print(json.dumps(child_registry(open(sys.argv[1]).read()), ensure_ascii=False))", + str(card)).stdout + self.assertEqual(json.loads(rows), [{"子任务分支": "待登记", "必需": "否", "状态": "—", "PR": "—"}]) + + def test_start_fix_and_hotfix_cards_are_lite(self): + triage = self.triage_receipt() + for name in ("fix/typo", "hotfix/urgent"): + with self.subTest(name=name): + self.task(self.repo, "start", name, env=self.env | {"TRIAGE_RECEIPT": str(triage)}) + slug = name.replace("/", "-") + text = (self.root / f"repo-wt-{slug}/.task/{slug}.md").read_text() + self.assert_lite_card(text, name) + self.assertIn("- 分支类型:standalone;root_target:main;", text) + self.assertIn("- 需求分流:verdict=NEW;", text) + def test_sync_child_merges_new_parent_initiative_commits(self): parent = self.start_initiative() wt, _ = self.start_child() @@ -1315,14 +1426,16 @@ class TaskLifecycleTests(unittest.TestCase): def test_start_without_triage_receipt_creates_no_worktree_or_branch(self): receipt = self.capture_precheck() - result = self.task(self.repo, "start", "feature/untriaged", check=False, - env=self.env | {"PRECHECK_RECEIPT": str(receipt)}) - self.assertNotEqual(result.returncode, 0, result.stdout) - self.assertIn("TRIAGE_RECEIPT required", result.stdout) - self.assertFalse((self.root / "repo-wt-feature-untriaged").exists()) - self.assertEqual(self.git(self.repo, "branch", "--list", "feature/untriaged").stdout.strip(), "") - self.assertEqual(self.git(self.repo, "ls-remote", "--heads", "origin", - "feature/untriaged").stdout.strip(), "") + for kind in ("standalone", "initiative"): + with self.subTest(kind=kind): + result = self.task(self.repo, "start", "feature/untriaged", check=False, + env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TASK_BRANCH_KIND": kind}) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("TRIAGE_RECEIPT required", result.stdout) + self.assertFalse((self.root / "repo-wt-feature-untriaged").exists()) + self.assertEqual(self.git(self.repo, "branch", "--list", "feature/untriaged").stdout.strip(), "") + self.assertEqual(self.git(self.repo, "ls-remote", "--heads", "origin", + "feature/untriaged").stdout.strip(), "") def test_start_child_refuses_non_join_receipt_without_side_effects(self): self.start_initiative("feature/alpha")