同步 herdr-issue skill:阻擋關係改用 Gitea 原生 issue dependencies

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 16:38:47 +08:00
co-authored by Claude Sonnet 5.5
parent d1cd61ee15
commit 03ec8318b8
5 changed files with 90 additions and 32 deletions
+10 -6
View File
@@ -23,19 +23,23 @@ description: "Run Gitea issues through Herdr: one Claude agent per issue in its
## 流程 A:啟動一個議題
1. `S/start.sh <owner/repo> <N>`。它會建 worktree `../<repo>-issue-N`(分支已存在就接續)、開 tab `#N`、啟動 `issue-N` agent、送提示詞(`scripts/prompt.txt`)、掛 `in-progress` 標籤。最後一行是 JSON。失敗訊息在 stderr,照實轉述。
1. `S/start.sh <owner/repo> <N>`。它會建 worktree `../<repo>-issue-N`(分支已存在就接續)、開 tab `#N`、啟動 `issue-N` agent、送提示詞(`scripts/prompt.txt`)並等 agent 進入 `working` 或 `blocked`,確認有反應後才掛 `in-progress` 標籤。最後一行是 JSON,`state` 欄位是 agent 當時的狀態(`blocked` 表示一開始就卡在對話框,watch-issue.sh 會通知)。失敗(agent 啟動不了、送出後沒反應)時它已自己回滾:關 tab、移除 worktree、刪掉本次新建的分支,議題等同沒啟動過。把 stderr 的錯誤照實轉述,**不要重跑**,跳過第 2 步直接做「推薦下一步」;這個議題會重新出現在「可執行」清單,要重試由使用者再勾。
2. 在**背景**執行 `S/watch-issue.sh <owner/repo> <N>`(Bash 的 `run_in_background`)。它會阻塞到議題關閉,期間 agent blocked 或消失會彈 Herdr 通知;關閉後自己關 tab、清 worktree 與分支、拿掉標籤、往上檢查規格與總覽能不能關。結束時你會被叫醒。
3. 做一次「推薦下一步」。
## 阻擋規則
議題之間的阻擋只認 Gitea 原生的 issue dependencies(議題頁右側的「依賴」),不看內文。有任何一個 open 的依賴(跨 repo 也算)就是被阻擋:`ready.sh` 不會列出它,`start.sh` 會拒絕啟動。依賴的議題一關閉就解鎖,不看 PR 有沒有合併。讀依賴失敗時 `ready.sh` 整個停下並印出錯誤,不猜。
## 流程 B:從 repo 挑議題
1. `S/ready.sh <owner/repo>`。
2. 用多選問題列出「可執行」的議題,每項標題帶規格標籤;同一規格的議題在描述裡註明「同規格,PR 可能互相衝突」。「掛 in-progress 但沒有 agent」的議題另列一題,說明重跑會接續先前分支。
3. 勾選的每一個議題各走流程 A 的第 1、2 步,全部啟動後做一次「推薦下一步」。
2. 「可執行」是空的就直接說「目前沒有可執行的議題」,不出多選題,但「執行中」和「掛 in-progress 但沒 agent」照常轉述。否則用多選問題列出「可執行」的議題,每項標題帶規格標籤;同一規格的議題在描述裡註明「同規格,PR 可能互相衝突」。「掛 in-progress 但沒有 agent」的議題另列一題,說明重跑會接續先前分支。
3. 勾選的每一個議題各走流程 A 的第 1、2 步。其中一個 `start.sh` 失敗不影響其餘的,繼續啟動下一個;全部跑完後把失敗的議題與原因一併轉述,再做一次「推薦下一步」。
## 推薦下一步
`S/ready.sh <owner/repo>` 再跑一次(剛啟動的議題已經在執行中,會被排除),結果用多選問題呈現,格式同流程 B。沒勾就什麼都不做。
`S/ready.sh <owner/repo>` 再跑一次(剛啟動的議題已經在執行中,會被排除),結果用多選問題呈現,格式同流程 B(可執行為空就直說,不出題)。沒勾就什麼都不做。
## 被 watch-issue.sh 叫醒時
@@ -45,8 +49,8 @@ description: "Run Gitea issues through Herdr: one Claude agent per issue in its
| 腳本 | 給誰 | 做什麼 |
| --- | --- | --- |
| `ready.sh` | 你 | 列出可執行、執行中、被阻擋的議題 |
| `start.sh` | 你 | 建 worktree、開 tab、啟動 agent、送提示詞、掛標籤 |
| `ready.sh` | 你 | 列出可執行、執行中、掛 in-progress 但沒 agent 的議題(被阻擋的不列) |
| `start.sh` | 你 | 檢查沒被阻擋後建 worktree、開 tab、啟動 agent、送提示詞並確認 agent 有反應、掛標籤;失敗自動回滾 |
| `watch-issue.sh` | 你(背景) | 等議題關閉,清理,往上關規格與總覽 |
| `tick.sh` | 子 agent | 勾議題內文第 k 個核取方塊 |
| `pr-wait.sh` | 子 agent(背景) | 等 PR 衝突、關閉或合併 |
+15
View File
@@ -79,3 +79,18 @@ pr_for_issue() {
| jq_py "m=[p for p in d if p.get('head')=='issue-$2']
print(' '.join([m[0]['index'],m[0]['state'],str(m[0].get('mergeable'))]) if m else '')"
}
# 擋住議題 N 的 open 依賴(Gitea 原生 issue dependencies),一行一個:同 repo 印 #M,跨 repo 印 owner/repo#M。
# tea api 遇到 HTTP 錯誤仍 exit 0,所以用回應是否為 JSON 陣列判斷成敗;失敗就 die。
open_blockers() {
tea api --repo "$1" "/repos/{owner}/{repo}/issues/$2/dependencies" | python3 -c "import sys,json
raw=sys.stdin.read()
try: d=json.loads(raw)
except Exception: d=None
if not isinstance(d,list):
sys.stderr.write('herdr-issue: 讀取 #$2 的依賴失敗:'+raw.strip()[:300]+'\n'); sys.exit(1)
for i in d:
if i.get('state')=='open':
r=(i.get('repository') or {}).get('full_name','')
print(('#' if r in ('','$1') else r+'#')+str(i['number']))"
}
+26 -17
View File
@@ -1,10 +1,12 @@
#!/usr/bin/env python3
"""列出可執行的議題。輸入:open.json agents.json tabs.json"""
import json, re, sys
"""列出可執行的議題。輸入:open.json agents.json tabs.json owner/repo
阻擋關係只認 Gitea 原生 issue dependencies(tea api),不看議題內文。"""
import json, re, subprocess, sys
open_issues = json.load(open(sys.argv[1]))
agents = json.load(open(sys.argv[2]))
tabs = json.load(open(sys.argv[3]))
repo = sys.argv[4]
def find(o, k):
if isinstance(o, dict):
@@ -19,9 +21,6 @@ def find(o, k):
def labels(i): return [l.strip() for l in (i.get("labels") or "").split() if l.strip()] if isinstance(i.get("labels"), str) else [l["name"] for l in i.get("labels") or []]
open_idx = {i["index"] for i in open_issues}
by_idx = {i["index"]: i for i in open_issues}
# Herdr 裡活著的 agent:名稱 issue-N,或掛在標籤 #N 的 tab 上
running = set()
for a in find(agents, "agents") or []:
@@ -32,14 +31,25 @@ for t in find(tabs, "tabs") or []:
m = re.fullmatch(r"#(\d+)", str(t.get("label") or ""))
if m and t.get("tab_id") in agent_tabs: running.add(m.group(1))
def blockers(i):
m = re.search(r"Blocked by:\s*([^\n]*)", i.get("body") or "")
return re.findall(r"#(\d+)", m.group(1)) if m else []
def open_blockers(n):
"""擋住 #n 的 open 依賴。tea api 遇到 HTTP 錯誤仍 exit 0,所以回應不是陣列就當失敗,整個停下。"""
raw = subprocess.run(["tea", "api", "--repo", repo, f"/repos/{{owner}}/{{repo}}/issues/{n}/dependencies"],
capture_output=True, text=True).stdout
try: d = json.loads(raw)
except Exception: d = None
if not isinstance(d, list):
sys.exit(f"herdr-issue: 讀取 #{n} 的依賴失敗:{raw.strip()[:300]}")
out = []
for i in d:
if i.get("state") != "open": continue
r = (i.get("repository") or {}).get("full_name", "")
out.append(("#" if r in ("", repo) else r + "#") + str(i["number"]))
return out
def spec(i):
return next((l for l in labels(i) if l.startswith("規格/#") or l.startswith("總覽/#")), "")
runnable, blocked, stale = [], [], []
runnable, stale = [], []
for i in sorted(open_issues, key=lambda x: int(x["index"])):
ls = labels(i)
if "ready-for-agent" not in ls: continue
@@ -48,17 +58,16 @@ for i in sorted(open_issues, key=lambda x: int(x["index"])):
if n in running: continue
if "in-progress" in ls:
stale.append(n); continue
open_blockers = [b for b in blockers(i) if b in open_idx]
if open_blockers:
blocked.append((n, open_blockers)); continue
if open_blockers(n): continue
runnable.append((n, spec(i), i["title"]))
print(f"可執行({len(runnable)}):編號\t規格\t標題")
for n, s, t in runnable:
print(f"#{n}\t{s}\t{t}")
if runnable:
print(f"可執行({len(runnable)}):編號\t規格\t標題")
for n, s, t in runnable:
print(f"#{n}\t{s}\t{t}")
else:
print("目前沒有可執行的議題。")
print()
print("Herdr 執行中:" + (" ".join(f"#{n}" for n in sorted(running, key=int)) or "無"))
if stale:
print("掛 in-progress 但沒有 agent(需處理,重跑 start 會接續):" + " ".join(f"#{n}" for n in stale))
if blocked:
print("被阻擋:" + " ".join(f"#{n}←{','.join('#'+b for b in bs)}" for n, bs in blocked))
+2 -2
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
# 用法:ready.sh <owner/repo>
# 列出可執行的議題(open、ready-for-agent、blocker 全關、Herdr 沒在跑、沒掛 in-progress)。
# 列出可執行的議題(open、ready-for-agent、Gitea 原生依賴全關、Herdr 沒在跑、沒掛 in-progress)。被阻擋的不列。
source "$(dirname "$0")/lib.sh"
REPO="${1:?用法:ready.sh <owner/repo>}"
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
tea issues list --repo "$REPO" --state open --limit 500 --output json --fields index,title,labels,body > "$T/open.json"
herdr agent list > "$T/agents.json"
herdr tab list --workspace "${HERDR_WORKSPACE_ID:?}" > "$T/tabs.json"
python3 "$SCRIPTS/ready.py" "$T/open.json" "$T/agents.json" "$T/tabs.json"
python3 "$SCRIPTS/ready.py" "$T/open.json" "$T/agents.json" "$T/tabs.json" "$REPO"
+37 -7
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# 用法:start.sh <owner/repo> <N>
# 在目前 clone 旁建 worktree、開 Herdr tab、啟動 claude、送提示詞、掛 in-progress 標籤。
# 最後一行印出 JSON:{"tab":..,"pane":..,"path":..,"resumed":..}
# 在目前 clone 旁建 worktree、開 Herdr tab、啟動 claude、送提示詞並確認 agent 有反應、掛 in-progress 標籤。
# 送出提示詞後 15 秒內沒觀察到 working 或 blocked 就視為失敗並回滾(關 tab、移除 worktree、刪本次新建的分支)。
# 最後一行印出 JSON:{"tab":..,"pane":..,"path":..,"resumed":..,"state":"working"|"blocked"}
source "$(dirname "$0")/lib.sh"
REPO="${1:?}"; N="${2:?用法:start.sh <owner/repo> <N>}"
LINK="https://gitea.jsc.idv.tw/$REPO/issues/$N"
@@ -14,6 +15,8 @@ if [ "$(agent_status "$N")" != missing ]; then
die "issue-$N 的 agent 還活著(tab $(tab_for_issue "$N")),不重複啟動"
fi
[ "$(issue_state "$REPO" "$N")" = open ] || die "#$N 不是 open"
BLOCKERS="$(open_blockers "$REPO" "$N")" || exit 1
[ -z "$BLOCKERS" ] || die "#$N 被還沒關閉的議題擋住:$(echo "$BLOCKERS" | tr '\n' ' ')(Gitea 依賴設定)。等它們關閉,或到 Gitea 拿掉依賴再啟動"
git -C "$CLONE" fetch --quiet origin
RESUMED=false
@@ -26,24 +29,51 @@ else
git -C "$CLONE" worktree add --quiet -b "$BRANCH" "$WT" origin/main
fi
TAB=""
# 回滾到跟沒跑過一樣:關 tab、移除 worktree;分支只在本次新建時刪除。
rollback() {
[ -n "$TAB" ] && herdr tab close "$TAB" >/dev/null 2>&1 || true
if git -C "$CLONE" worktree list --porcelain | grep -qx "worktree $WT"; then
git -C "$CLONE" worktree remove --force "$WT" >/dev/null 2>&1 || true
fi
$RESUMED || git -C "$CLONE" branch -D "$BRANCH" >/dev/null 2>&1 || true
}
TAB_JSON="$(herdr tab create --workspace "${HERDR_WORKSPACE_ID:?}" --cwd "$WT" --label "#$N" --no-focus)"
TAB="$(jq_py 'print(find(d,"tab")["tab_id"])' <<<"$TAB_JSON")"
PANE="$(jq_py 'print(find(d,"root_pane")["pane_id"])' <<<"$TAB_JSON")"
START_ERR="$(mktemp)"
for _ in 1 2 3 4 5 6; do
if herdr agent start "issue-$N" --kind claude --pane "$PANE" --timeout 90000 -- --model "$MODEL" >/dev/null 2>"$WT/.herdr-start.err"; then
rm -f "$WT/.herdr-start.err"; break
if herdr agent start "issue-$N" --kind claude --pane "$PANE" --timeout 90000 -- --model "$MODEL" >/dev/null 2>"$START_ERR"; then
rm -f "$START_ERR"; break
fi
sleep 2
done
[ -f "$WT/.herdr-start.err" ] && { cat "$WT/.herdr-start.err" >&2; herdr tab close "$TAB" >/dev/null; die "agent 啟動失敗"; }
if [ -f "$START_ERR" ]; then
cat "$START_ERR" >&2; rm -f "$START_ERR"
rollback
die "agent 啟動失敗,已回滾"
fi
if $RESUMED; then RESUME_LINE="分支已有先前進度,先看 git log 和議題核取方塊再接續。"; else RESUME_LINE=""; fi
PROMPT="$(sed -e "s|{LINK}|$LINK|g" -e "s|{REPO}|$REPO|g" -e "s|{N}|$N|g" -e "s|{S}|$SCRIPTS|g" -e "s|{RESUME}|$RESUME_LINE|" "$SCRIPTS/prompt.txt")"
herdr agent prompt "issue-$N" "$PROMPT" >/dev/null
# 送出後必須觀察到 agent 進入 working 或 blocked;否則 Herdr 回非 0(agent_prompt_stalled、timeout、agent_blocked 等)。
PROMPT_ERR="$(mktemp)"
if PROMPT_OUT="$(herdr agent prompt "issue-$N" "$PROMPT" --wait --until working --until blocked --timeout 15000 2>"$PROMPT_ERR")"; then
rm -f "$PROMPT_ERR"
else
echo "herdr-issue: 送出提示詞後 agent 沒有反應,已回滾" >&2
cat "$PROMPT_ERR" >&2; rm -f "$PROMPT_ERR"
rollback
exit 1
fi
STATE="$(jq_py 'print(find(d,"agent_status") or "")' <<<"$PROMPT_OUT" 2>/dev/null || true)"
case "$STATE" in working|blocked) ;; *) STATE="$(agent_status "$N")" ;; esac
ensure_label "$REPO"
tea issues edit "$N" --repo "$REPO" --remove-labels "$LABEL" >/dev/null 2>&1 || true
tea issues edit "$N" --repo "$REPO" --add-labels "$LABEL" >/dev/null
printf '{"tab":"%s","pane":"%s","path":"%s","resumed":%s}\n' "$TAB" "$PANE" "$WT" "$RESUMED"
printf '{"tab":"%s","pane":"%s","path":"%s","resumed":%s,"state":"%s"}\n' "$TAB" "$PANE" "$WT" "$RESUMED" "$STATE"