Files
Agent-System-Dev/scripts/task.sh
T
weichenandCursor 88d5787f29
ci / test (pull_request) Failing after 1m0s
Compare pin trees offline and archive finished task cards
Why: Merge commits share a tree with the pinned feature commit, the import ratchet downloaded modules during CI, and task sync wrote the checkpoint onto the wrong card.
Evidence: check-pins accepts 6c0f1e8 (same tree as Backend squad 8e987bf) and rejects empty-tree 8db069d; both objects stayed local. An empty GOMODCACHE run of lint-boundaries matches the cached go list, 14 pairs. bash -n scripts/task.sh.
Impact: make pin no longer checks out Backend nested core or agent-command, and a failing pin check does not fail the target. task sync writes the branch card or warns. precheck reports completed-but-unarchived cards and card-close branches older than 24 hours.
Refs: feature/agent-stack-r0-r1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 10:19:06 +00:00

358 lines
19 KiB
Bash
Executable File
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.
#!/usr/bin/env bash
# 任务启动门禁:precheck / start / sync / close / finish(配套 Makefile 同名目标)。
# 规范依据:collaboration.md 共同基线与同步节点、治理清理;delivery.md §1/§5。
set -euo pipefail
TARGET_BRANCH="${TASK_TARGET_BRANCH:-main}"
die() { echo "ERROR: $*" >&2; exit 1; }
need_repo() { git rev-parse --git-dir >/dev/null 2>&1 || die "not inside a git repository"; }
width() { python3 -c 'import sys,unicodedata;print(sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in sys.argv[1]))' "$1"; }
cmd_precheck() {
need_repo
git fetch -q origin "${TARGET_BRANCH}" 2>/dev/null || true
local branch head rmain dirty sub_drift sub_uninit
branch=$(git rev-parse --abbrev-ref HEAD)
head=$(git rev-parse HEAD)
rmain=$(git rev-parse --verify -q "origin/${TARGET_BRANCH}" 2>/dev/null || echo "$head")
dirty=$(git status --porcelain | { grep -v '^.. \.zcode/' || true; } | wc -l)
sub_drift=$(git submodule status --recursive 2>/dev/null | { grep -cE '^\+|^U' || true; })
sub_uninit=$(git submodule status --recursive 2>/dev/null | { grep -cE '^-' || true; })
local behind ahead
behind=$(git rev-list --count "HEAD..origin/${TARGET_BRANCH}" 2>/dev/null || echo 0)
ahead=$(git rev-list --count "origin/${TARGET_BRANCH}..HEAD" 2>/dev/null || echo 0)
echo "[repo] branch=${branch} head=$(echo "$head" | cut -c1-10)"
echo "[remote] origin/${TARGET_BRANCH}=$(echo "$rmain" | cut -c1-10) ahead=${ahead} behind=${behind}"
echo "[tree] $([ "$dirty" -eq 0 ] && echo clean || echo "dirty(${dirty})") submodules_drift=${sub_drift} uninit=${sub_uninit}"
local wt merged
wt=$(git worktree list | tail -n +2 | wc -l)
merged=$(git branch --merged "origin/${TARGET_BRANCH}" 2>/dev/null | { grep -vE 'main|master|\*' || true; } | tr -d ' +')
[ "${sub_uninit:-0}" -gt 0 ] && echo "[warn] ${sub_uninit} 个子模块未初始化——构建/测试前需物化(不阻断写入)"
echo "[worktrees] linked=${wt}"
[ -n "$merged" ] && echo "[recycle] merged un-recycled branches: $(echo $merged)"
local open_cards="" pc pb pst
for pc in .task/*.md; do
[ -f "$pc" ] || continue
pb=$(card_of_branch "$pc"); [ -n "$pb" ] || continue
pst=$(card_status "$pc"); case "$pst" in 已完成*) continue ;; esac
card_pending "${pc##*/}" && continue
if git log --merges --format='%s' "origin/${TARGET_BRANCH}" 2>/dev/null | grep -qF " from $pb into ${TARGET_BRANCH}"; then
open_cards="${open_cards} ${pc##*/}"
fi
done
[ -n "$open_cards" ] && echo "[cards] 已合入未闭合任务卡:$(echo $open_cards)——make task-close 回填归档(主检出运行)"
local done_open="" stale="" now ts ref
for pc in .task/*.md; do
[ -f "$pc" ] || continue
case "$(card_status "$pc")" in
已完成*) done_open="${done_open} ${pc##*/}" ;;
esac
done
[ -n "$done_open" ] && echo "[cards] 已完成但未归档:$(echo $done_open)"
now=$(date +%s)
while read -r ref ts; do
[ -n "$ref" ] || continue
git merge-base --is-ancestor "$ref" "origin/${TARGET_BRANCH}" 2>/dev/null && continue
if [ $((now - ts)) -gt 86400 ]; then
stale="${stale} ${ref#origin/}"
fi
done < <(git for-each-ref --format='%(refname:short) %(committerdate:unix)' "refs/remotes/origin/feature/card-close-*")
[ -n "$stale" ] && echo "[cards] 闭合分支超过 24 小时未合入:$(echo $stale)"
echo "[required-reading] core/baseline.md, workflows/delivery.md §1$([ "$dirty" -gt 0 ] && echo ", standards/collaboration.md(树脏=他人或未收尾现场,不得写入)")"
echo "[R0] $(echo "$rmain" | cut -c1-10)(完整:$rmain)"
if [ "$dirty" -gt 0 ] || [ "${sub_drift:-0}" -gt 0 ]; then
echo "[verdict] SHARED_WORKSPACE_BUSY — 工作树不干净,不得写入;先收尾或经 owner 确认"
exit 3
fi
if [ "$behind" -gt 0 ] && [ "$branch" = "${TARGET_BRANCH}" ]; then
echo "[verdict] SYNC_REQUIRED — 本地 ${TARGET_BRANCH} 落后远端 ${behind} 提交;先 git pull --ff-only"
exit 4
fi
echo "[verdict] READY — 可建任务工作区:make task-start NAME=feature/your-branch"
}
cmd_start() {
need_repo
local name="${1:-}"; [ -n "$name" ] || die "usage: task.sh start <feature|fix|hotfix>/<slug>"
echo "$name" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "branch name must match (feature|fix|hotfix|release)/<slug>"
git fetch -q origin "${TARGET_BRANCH}" 2>/dev/null || true
[ "$(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
rmain=$(git rev-parse "origin/${TARGET_BRANCH}")
slug=$(echo "$name" | tr '/' '-')
# 落点须为主检出的平级目录:$PWD 推算在子目录(或任务 worktree 内)运行时会算错,
# 改用 git-common-dir 的父目录定位主检出(与 cmd_finish 同款惯用法)。
mainroot=$(dirname "$(cd "$(git rev-parse --git-common-dir)" && pwd)")
wt="$(dirname "$mainroot")/$(basename "$mainroot")-wt-${slug}"
[ -e "$wt" ] && die "worktree path exists: $wt"
git worktree add -q "$wt" -b "$name" "$rmain"
# 物化后按 submodule status 实际状态核对,不只信退出码:网络抖动或嵌套指针
# 漂移时 update 可能静默留坑(2026-09-30 实测深嵌套层带病通过,构建期才炸)。
(cd "$wt" && git submodule update --init --recursive >/dev/null 2>&1) \
|| echo "[warn] submodule update 非零退出,见下方状态核对"
local sub_drift
sub_drift=$(git -C "$wt" submodule status --recursive 2>/dev/null | grep -cE '^[-+U]' || true)
[ "${sub_drift:-0}" -gt 0 ] \
&& echo "[warn] worktree 子模块物化不完整:${sub_drift} 项漂移/未初始化——构建前先核对(git -C $wt submodule status --recursive)"
local card="$wt/.task/${slug}.md"
mkdir -p "$wt/.task"
cat > "$card" <<EOF
# ${name} 任务卡
任务主记录:<PR 链接,task-finish 时填写>;状态:进行中;owner:$(git config user.name || echo unknown)
## 基线与授权
- 权威仓:$(git -C "$wt" remote get-url origin 2>/dev/null || echo '(local)');目标分支:${TARGET_BRANCH};R0(initial_base_sha)= ${rmain}
- 工作区:${wt}(worktree);分支:${name}
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
## 目标与非目标
<预期结果、受影响入口/模块/契约;明确不做什么。>
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at |
|---|---|---|---|
| 启动 | ${rmain} | $(git -C "$wt" rev-parse HEAD) | $(date -u +%FT%TZ) |
## 结果与验证(完成后填写)
- 结果:
- 验证:
- 影响与剩余项:
EOF
git -C "$wt" add .task && git -C "$wt" commit -q -m "Record the task card for ${slug}" && echo "[card] $card(已随分支提交)"
echo "[started] branch=${name} worktree=${wt} R0=$(echo "$rmain" | cut -c1-10)"
}
cmd_sync() {
need_repo
git fetch -q origin "${TARGET_BRANCH}"
[ "$(git status --porcelain | grep -v '^.. \.zcode/' | wc -l)" -eq 0 ] || die "dirty tree — 先显式提交;不隐式 stash(collaboration.md:11)"
git merge --no-edit "origin/${TARGET_BRANCH}"
git merge-base --is-ancestor "origin/${TARGET_BRANCH}" HEAD || die "sync incomplete"
local base head card branch slug pc
base=$(git rev-parse "origin/${TARGET_BRANCH}"); head=$(git rev-parse HEAD)
branch=$(git rev-parse --abbrev-ref HEAD)
slug=$(echo "$branch" | tr '/' '-')
card=".task/${slug}.md"
if [ ! -f "$card" ]; then
card=""
for pc in .task/*.md; do
[ -f "$pc" ] || continue
if [ "$(card_of_branch "$pc")" = "$branch" ]; then
card=$pc
break
fi
done
fi
if [ -n "$card" ] && [ -f "$card" ]; then
printf '| 同步前验收 | %s | %s | %s |\n' "$base" "$head" "$(date -u +%FT%TZ)" >> "$card"
git add "$card"
git commit -q -m "Record the sync checkpoint" || true
else
echo "[warn] 找不到分支 ${branch} 的任务卡,同步记录未写入" >&2
fi
echo "[synced] sync_base=$(echo "$base" | cut -c1-10) tested_head=$(echo "$head" | cut -c1-10) checked_at=$(date -u +%FT%TZ)"
echo "[next] 跑正式验收(make test 等),证据写入任务卡"
}
# ---- 卡片闭合(防欠账)----
# 依据:delivery.md §5 治理清理;机制化 2026-09-30 治理缺口(11 张已合入卡片未闭合)。
# 检测 .task/*.md 中"分支已合入 origin/<target> 但状态非已完成"的卡片:回填状态、主记录
# (PR 链接+合并 SHA)与闭合记录,并 git mv 到 .task/archive/。变更落点:主检出运行→
# 新建 feature/card-close-* 分支推送(集成分支受保护,不直推);任务分支内运行须 --fold
# (随当前任务 PR 合入)。合并判定用合并提交主题(分支删除后仍可追溯),不依赖分支存活。
card_of_branch() { sed -n 's/^- 工作区:.*分支:\([A-Za-z0-9._/-]\+\).*/\1/p' "$1" | head -1; }
card_status() { sed -n 's/^.*;状态:\([^;]*\).*/\1/p' "$1" | head -1; }
card_merge() { # $1=branch → "sha|subject"(该分支合入 origin/<target> 的合并提交)
git log --merges --format='%H|%s' "origin/${TARGET_BRANCH}" 2>/dev/null \
| grep -F " from $1 into ${TARGET_BRANCH}" | head -1
}
card_pending() { # $1=卡片文件名——闭合是否已在某 origin/feature/card-close-* 分支待合入
local cb
for cb in $(git for-each-ref --format='%(refname:short)' "refs/remotes/origin/feature/card-close-*"); do
git cat-file -e "${cb}:.task/archive/$1" 2>/dev/null && return 0
done
return 1
}
card_backfill() { # $1=card $2=pr_num $3=merge_sha $4=merge_subject $5=web_base(原地改写)
python3 - "$1" "$2" "$3" "$4" "$5" <<'PY'
import re, sys
path, pr, sha, subject, base = sys.argv[1:6]
s = open(path, encoding='utf-8').read()
s = re.sub(r'状态:[^;\n]*', '状态:已完成(回填)', s, count=1)
m = re.search(r'任务主记录:([^;\n]*)', s)
if m and 'pulls/' not in m.group(1):
s = s.replace(m.group(1), f'PR #{pr} {base}/pulls/{pr}(merge {sha[:10]})', 1)
s = s.replace('## 结果与验证(完成后填写)', '## 结果与验证', 1)
s = s.rstrip('\n') + f'''
## 闭合记录(card-autoclose 回填)
- 依据:merge {sha}(PR #{pr})已包含于 origin/main,分支成果已落盘;本段为合入后回填。
- 合并主题:{subject}
- 归档:随闭合移入 .task/archive/;任务执行期的过程证据见原任务记录与 PR。
'''
open(path, 'w', encoding='utf-8').write(s)
PY
}
cmd_close() {
need_repo
local fold=0
[ "${1:-}" = "--fold" ] && fold=1
git fetch -q origin "${TARGET_BRANCH}" 2>/dev/null || true
# 默认值取远端通告的 Web 面(git push 输出的 PR 建议链接);可用 TASK_CARD_WEB_BASE 覆盖。
# git-panel 前缀已失效(TLS 握手失败),默认值改为实测可用的 git-home(AGENTS.md 访问事实节)。
local web_base="${TASK_CARD_WEB_BASE:-https://git-home.u1680832.nyat.app:30319}"
local url owner repo
url=$(git remote get-url origin); url="${url%.git}"
repo=$(basename "$url"); owner=$(basename "$(dirname "$url")")
local branch c b st m cards=""
branch=$(git rev-parse --abbrev-ref HEAD)
for c in .task/*.md; do
[ -f "$c" ] || continue
b=$(card_of_branch "$c"); [ -n "$b" ] || continue
st=$(card_status "$c")
done_unarchived=0
case "$st" in 已完成*) done_unarchived=1 ;; esac
if card_pending "${c##*/}"; then
echo "[close] ${c##*/} 已有待合入的闭合分支,跳过(等其 PR 合入)"
continue
fi
m=$(card_merge "$b") || m=""
if [ -z "$m" ]; then
[ "$done_unarchived" = 1 ] || continue
m="ARCHIVE"
fi
cards="$cards
$c|$b|$m"
done
[ -n "$cards" ] || { echo "[close] 无已合入未闭合的任务卡"; exit 0; }
# 落点:主检出→feature/card-close-* 分支(提交过 pre-commit 分支闸);任务分支须显式 --fold
local newbranch=""
if [ "$branch" = "${TARGET_BRANCH}" ]; then
[ -z "$(git status --porcelain | grep -v '^.. \.zcode/' || true)" ] || die "主检出树脏——先收尾再 close(防混入未提交现场)"
newbranch="feature/card-close-$(date -u +%Y%m%d-%H%M%SZ)"
git rev-parse --verify -q "refs/heads/${newbranch}" >/dev/null && die "分支已存在:${newbranch}"
git checkout -q -b "$newbranch" "origin/${TARGET_BRANCH}"
elif [ "$fold" != "1" ]; then
die "close 在任务分支内须加 --fold(并入当前任务 PR);或到主检出运行(将建 feature/card-close-* 分支+PR)"
fi
local n=0 sha subject pr closed_list=""
while IFS='|' read -r c b m; do
[ -n "$c" ] || continue
if [ "$m" = "ARCHIVE" ]; then
mkdir -p .task/archive
git mv "$c" ".task/archive/${c##*/}"
closed_list="$closed_list ${c##*/}(already-complete)"
n=$((n+1))
continue
fi
sha="${m%%|*}"; subject="${m#*|}"
pr=$(printf '%s' "$subject" | sed -n 's/.*(#\([0-9]\+\)) from.*/\1/p')
[ -n "$pr" ] || pr=unknown
card_backfill "$c" "$pr" "$sha" "$subject" "${web_base}/${owner}/${repo}"
mkdir -p .task/archive
git mv "$c" ".task/archive/${c##*/}"
closed_list="$closed_list ${c##*/}(PR#${pr})"
n=$((n+1))
done <<EOF
$cards
EOF
local msg; msg=$(mktemp)
{ echo "Close ${n} merged task card(s) and archive them"
echo
echo "Why: branches merged into ${TARGET_BRANCH} had left their task cards open; backfill status and archive to prevent bookkeeping debt."
echo "Evidence: $(echo $closed_list) — merges verified via git log --merges against origin/${TARGET_BRANCH}."
echo "Impact: .task/*.md moved to .task/archive/ with status backfilled; no code or submodule change."
echo "Refs: task.sh close (card-autoclose-r1)"
} > "$msg"
git add .task
git commit -q -F "$msg"; rm -f "$msg"
# push 失败重试一次:家服隧道间歇抖动,单次尝试曾让钩子闭合半途而废(2026-09-30)。
git push -q -u origin HEAD || { sleep 5; git push -q -u origin HEAD; }
local headbr pr_url=""
headbr=$(git rev-parse --abbrev-ref HEAD)
if [ -n "${GITEA_TOKEN:-}" ]; then
# HTTPS 面偶发 broken pipe / 超时(AGENTS.md 访问事实节),单发 curl 会让 PR 静默
# 缺失(闭合分支推了、PR 没建,2026-09-30 三次踩中):退避重试 + 失败可见 + 终局
# 按 head 查回可能已存在的 PR(post-merge 钩子与手动 close 并发时 PR 会重复请求)。
local resp attempt api="${web_base}/api/v1/repos/${owner}/${repo}/pulls"
for attempt in 1 2 3 4; do
resp=$(curl -sS -m 25 -X POST -H "Authorization: token ${GITEA_TOKEN}" -H 'Content-Type: application/json' \
"$api" \
-d "{\"head\":\"${headbr}\",\"base\":\"${TARGET_BRANCH}\",\"title\":\"Close merged task cards (${headbr})\",\"body\":\"Auto-generated by task.sh close; see commit message for evidence.\"}" 2>&1) || resp=""
pr_url=$(printf '%s' "$resp" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("html_url","") if isinstance(d,dict) else "")' 2>/dev/null || true)
[ -n "$pr_url" ] && break
echo "[close] 建 PR 第 ${attempt} 次未成功${resp:+($(printf '%s' "$resp" | head -c 120))},退避重试" >&2
[ "$attempt" = 4 ] || sleep $((attempt * 3))
done
if [ -z "$pr_url" ]; then
resp=$(curl -sS -m 25 -H "Authorization: token ${GITEA_TOKEN}" "$api?state=open" 2>/dev/null || true)
pr_url=$(printf '%s' "$resp" | python3 -c 'import json,sys
for p in json.load(sys.stdin):
if p["head"]["ref"] == sys.argv[1]:
print(p["html_url"]); break' "$headbr" 2>/dev/null || true)
fi
fi
if [ -n "$newbranch" ]; then git checkout -q "${TARGET_BRANCH}"; fi
if [ -n "$pr_url" ]; then
echo "[pr] ${pr_url}(合入权在所有者)"
else
echo "[next] 建 PR:${web_base}/${owner}/${repo}/pulls/new?head=${headbr}(合入权在所有者)"
fi
echo "[closed] ${n} 张已合入卡片已回填归档,提交于 ${headbr} 并推送"
}
cmd_finish() {
need_repo
local abandon="${1:-}"
local branch head common mainrepo
branch=$(git rev-parse --abbrev-ref HEAD)
head=$(git rev-parse HEAD)
common=$(git rev-parse --git-common-dir); common=$(cd "$common" && pwd)
mainrepo=$(dirname "$common")
# 防护(T0 事故教训):finish 必须在任务 worktree 内运行——本脚本把 $PWD 当
# worktree 回收(rm -rf);从主检出运行会误删主检出。mainrepo==PWD 即主检出。
[ "$(cd "$mainrepo" && pwd)" = "$PWD" ] && die "task-finish 须在任务 worktree 内运行(cd 到 worktree 再执行),防止误删主检出"
git fetch -q origin "${TARGET_BRANCH}" 2>/dev/null || true
if [ "$abandon" = "--abandon" ]; then
local reason="${2:-}"; [ -n "$reason" ] || die "--abandon 需要原因:task.sh finish --abandon <reason>"
local untracked; untracked=$(git status --porcelain | grep -c '^??' || true)
[ "$untracked" -gt 0 ] && die "存在 ${untracked} 个未跟踪文件 — 先处置或用 task-finish FORCE_ABANDON=1"
[ "${FORCE_ABANDON:-0}" = "1" ] || { echo "[confirm] 放弃未合入工作属破坏性动作:重跑 FORCE_ABANDON=1 task.sh finish --abandon \"${reason}\""; exit 5; }
echo "[abandoned] branch=${branch} reason=${reason} recorded_at=$(date -u +%FT%TZ)"
else
git merge-base --is-ancestor "$head" "origin/${TARGET_BRANCH}" || die "分支未合入 origin/${TARGET_BRANCH}——先走 PR 合入;放弃则用 --abandon <reason>"
echo "[verified] ${branch} 已包含于 origin/${TARGET_BRANCH};成果已落盘"
fi
# git 对含 gitlink 的 worktree 无条件拒绝 remove(查索引),回收走 rm -rf + prune。
# 此处不得跑 submodule deinit:submodule.<name>.url 写在共享的 .git/config 里,deinit
# 会把其他检出/worktree 的子模块一并标成未初始化(2026-09-30 实测 finish 后主检出 uninit=12)。
git -C "$mainrepo" worktree remove "$PWD" 2>/dev/null || { rm -rf "$PWD"; git -C "$mainrepo" worktree prune; }
git -C "$mainrepo" branch -d "$branch" 2>/dev/null || git -C "$mainrepo" branch -D "$branch"
git -C "$mainrepo" worktree prune
git push -q --delete origin "$branch" 2>/dev/null || true
echo "[recycled] worktree 与分支 ${branch} 已回收(本地+远端,$(date -u +%FT%TZ))"
# 合入成功→自动闭合任务卡(含本任务卡;经 feature/card-close-* 分支+PR,合入权在所有者)
( cd "$mainrepo" && bash scripts/task.sh close ) \
|| echo "[next] 卡片自动闭合未完成——在主检出运行 make task-close"
}
case "${1:-}" in
precheck) shift; cmd_precheck "$@" ;;
start) shift; cmd_start "$@" ;;
sync) shift; cmd_sync "$@" ;;
close) shift; cmd_close "$@" ;;
finish) shift; cmd_finish "$@" ;;
*) echo "usage: task.sh <precheck|start|sync|close|finish> [args]"; exit 2 ;;
esac