Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
274 lines
14 KiB
Bash
Executable File
274 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# One tick of the Claude side of the agent loop (plan + review + deploy check).
|
||
# State lives in Gitea issue labels; see docs/tasks/TASK-agent-loop-pilot-20261013.md §4.
|
||
#
|
||
# Order per tick: release stale locks → deploy checks (no model) → at most ONE
|
||
# model run: a review (agent/待验收) first, else a plan (agent/待规划).
|
||
# The script, not the model, owns label transitions and validates every claim
|
||
# the model makes (brief exists on staging, reviewed SHA is the branch head,
|
||
# staging contains it) before posting.
|
||
set -euo pipefail
|
||
|
||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
GITEA="$HERE/gitea.sh"
|
||
REPO_DIR="${JYOTISHA_REPO_DIR:-/workspace/Jyotisha}"
|
||
LOOP_WT="${AGENT_LOOP_WORKTREE:-$REPO_DIR/.worktrees/agent-loop}"
|
||
CONF_DIR="$HOME/.config/jyotisha-agent"
|
||
STATE_DIR="${AGENT_LOOP_STATE_DIR:-$HOME/.local/state/jyotisha-agent}"
|
||
PILOT_LABEL="${AGENT_LOOP_SCOPE_LABEL:-agent/试点}"
|
||
HEALTH_URL="${AGENT_LOOP_HEALTH_URL:-https://staging.jyotisha.chat/api/health}"
|
||
PLAN_TIMEOUT="${AGENT_LOOP_PLAN_TIMEOUT:-40m}"
|
||
REVIEW_TIMEOUT="${AGENT_LOOP_REVIEW_TIMEOUT:-75m}"
|
||
MAX_BUDGET="${AGENT_LOOP_MAX_BUDGET_USD:-25}"
|
||
DEPLOY_WAIT_HOURS="${AGENT_LOOP_DEPLOY_WAIT_HOURS:-3}"
|
||
MAX_BOUNCES=2
|
||
MAX_ERRORS=3
|
||
|
||
mkdir -p "$STATE_DIR/runs"
|
||
LOG="$STATE_DIR/claude-tick.log"
|
||
log() { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" >> "$LOG"; }
|
||
|
||
[[ -e "$CONF_DIR/STOP" ]] && { log "STOP file present, skipping"; exit 0; }
|
||
exec 9> "$STATE_DIR/claude.lock"
|
||
flock -n 9 || { log "another tick is running, skipping"; exit 0; }
|
||
|
||
STATES=(agent/待规划 agent/待执行 agent/待验收 agent/部署中 agent/待真机 agent/需拍板)
|
||
|
||
# ---------- helpers ----------
|
||
|
||
has_label() { "$GITEA" labels "$1" | grep -qxF "$2"; }
|
||
|
||
set_state() { # set_state N NEW_STATE — drop every other state label, add NEW_STATE
|
||
local n="$1" new="$2" s
|
||
for s in "${STATES[@]}"; do [[ "$s" == "$new" ]] || "$GITEA" remove-label "$n" "$s"; done
|
||
"$GITEA" add-label "$n" "$new"
|
||
}
|
||
|
||
post() { # post N MARKER BODY_FILE — marker first line, Chinese body, footer
|
||
local n="$1" marker="$2" body="$3" out
|
||
out="$(mktemp)"
|
||
{ printf '%s\n' "$marker"; cat "$body"; printf '\n\n— Claude(自动循环 · %s)\n' "$RUN_ID"; } > "$out"
|
||
"$GITEA" comment "$n" "$out"
|
||
rm -f "$out"
|
||
}
|
||
|
||
post_text() { # post_text N MARKER TEXT
|
||
local f; f="$(mktemp)"; printf '%s\n' "$3" > "$f"; post "$1" "$2" "$f"; rm -f "$f"
|
||
}
|
||
|
||
# Public repository: refuse to post anything that looks like personal data or a secret.
|
||
privacy_ok() {
|
||
! grep -qE '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}|-----BEGIN [A-Z ]*PRIVATE KEY|(api[_-]?key|secret|password|passwd|token)[[:space:]]*[:=][[:space:]]*[^[:space:]<]{8,}' "$1"
|
||
}
|
||
|
||
# Marker fields: <!-- agent:claude review sha=abc verdict=fail ... -->
|
||
marker_field() { sed -nE "s/.* $1=([^ ]+).*/\1/p" <<<"$2"; }
|
||
|
||
# Only comments written by the owner account (the product owner and both agents
|
||
# post as it) are read. Anyone else's comment is ignored, never passed to a model.
|
||
OWNER="${AGENT_LOOP_OWNER:-$("$GITEA" whoami)}"
|
||
[[ -n "$OWNER" && "$OWNER" != null ]] || { log "cannot resolve owner account"; exit 1; }
|
||
comments_json() { "$GITEA" comments "$1" | jq --arg o "$OWNER" '[.[] | select(.user.login == $o)]'; }
|
||
|
||
# Bodies' first lines (markers) of agent comments, oldest first.
|
||
markers() { comments_json "$1" | jq -r '.[].body | split("\n")[0] | select(startswith("<!-- agent:"))'; }
|
||
|
||
latest_marker() { markers "$1" | grep -F "$2" | tail -1 || true; }
|
||
|
||
count_errors_since_progress() { # consecutive claude error markers since the last non-error claude marker
|
||
markers "$1" | grep -F 'agent:claude' | tac | awk '/agent:claude error/{c++; next} {exit} END{print c+0}'
|
||
}
|
||
|
||
prepare_worktree() {
|
||
git -C "$REPO_DIR" fetch -q origin --prune
|
||
if [[ ! -d "$LOOP_WT" ]]; then
|
||
git -C "$REPO_DIR" worktree add -q --detach "$LOOP_WT" origin/staging
|
||
fi
|
||
git -C "$LOOP_WT" checkout -q --detach -f origin/staging
|
||
git -C "$LOOP_WT" clean -q -fd # ignored deps (node_modules, .venv) survive
|
||
}
|
||
|
||
build_context() { # build_context N MODE OUT_JSON [EXTRA_JSON]
|
||
local n="$1" mode="$2" out="$3" extra="${4:-null}"
|
||
jq -n \
|
||
--argjson issue "$("$GITEA" issue "$n" | jq --arg o "$OWNER" '{number, title, body: (if .user.login == $o then .body else "(非本人账号创建的 issue,正文已忽略)" end), created_at, labels: [.labels[].name]}')" \
|
||
--argjson comments "$(comments_json "$n" | jq '[.[] | {created_at, body}]')" \
|
||
--arg mode "$mode" \
|
||
--arg staging "$(git -C "$REPO_DIR" rev-parse origin/staging)" \
|
||
--argjson extra "$extra" \
|
||
'{mode: $mode, origin_staging: $staging, issue: $issue, comments: $comments, extra: $extra}' > "$out"
|
||
}
|
||
|
||
run_claude() { # run_claude PROMPT_FILE SCHEMA_FILE CONTEXT_FILE TIMEOUT OUT_JSON [DOCS_ONLY]
|
||
local prompt="$1" schema="$2" ctx="$3" tmo="$4" out="$5" docs_only="${6:-0}"
|
||
(
|
||
cd "$LOOP_WT"
|
||
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0="$HERE/hooks/claude"
|
||
export AGENT_LOOP_DOCS_ONLY="$docs_only" AGENT_LOOP_CONTEXT="$ctx"
|
||
timeout --kill-after=60 "$tmo" claude -p \
|
||
"$(cat "$prompt")
|
||
|
||
本轮上下文文件(JSON):$ctx" \
|
||
--output-format json \
|
||
--json-schema "$(cat "$schema")" \
|
||
--settings "$HERE/claude-settings.json" \
|
||
--permission-mode dontAsk \
|
||
--max-budget-usd "$MAX_BUDGET"
|
||
) > "$out" 2>> "$LOG"
|
||
}
|
||
|
||
fail_run() { # fail_run N STATE REASON — keep the state, count errors, escalate after MAX_ERRORS
|
||
local n="$1" state="$2" reason="$3" errs
|
||
post_text "$n" "<!-- agent:claude error state=$state -->" "本轮自动处理没有完成:$reason。状态保持不变,下一轮重试。"
|
||
errs="$(count_errors_since_progress "$n")"
|
||
if (( errs >= MAX_ERRORS )); then
|
||
set_state "$n" agent/需拍板
|
||
post_text "$n" "<!-- agent:claude escalate -->" "@jesse 连续 $errs 轮自动处理失败,已停下等你决定。最近一次原因:$reason。日志在开发机 $LOG。"
|
||
fi
|
||
}
|
||
|
||
# ---------- 1. stale locks on this side ----------
|
||
# flock above guarantees no other tick runs here, so any Claude lock label is a crash leftover.
|
||
|
||
for lock in agent/规划中 agent/验收中; do
|
||
for n in $("$GITEA" issues "$lock,$PILOT_LABEL" | jq -r '.[].number'); do
|
||
"$GITEA" remove-label "$n" "$lock"
|
||
state=agent/待规划; [[ "$lock" == agent/验收中 ]] && state=agent/待验收
|
||
log "issue #$n: released stale lock $lock"
|
||
RUN_ID="lock-release"; fail_run "$n" "$state" "上一轮在处理中途中断(锁 $lock 已释放)"
|
||
done
|
||
done
|
||
|
||
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
|
||
RUN_DIR="$STATE_DIR/runs/$RUN_ID"
|
||
|
||
# ---------- 2. deploy checks (no model) ----------
|
||
|
||
deployed="$(curl -sS -m 20 "$HEALTH_URL" | jq -r '.deployment.gitCommit // empty' 2>/dev/null || true)"
|
||
for n in $("$GITEA" issues "agent/部署中,$PILOT_LABEL" | jq -r '.[].number'); do
|
||
m="$(latest_marker "$n" 'agent:claude review')"
|
||
want="$(marker_field staging "$m")"
|
||
checklist="$(marker_field checklist "$m")"
|
||
[[ -z "$want" ]] && { set_state "$n" agent/需拍板; post_text "$n" "<!-- agent:claude escalate -->" "@jesse 找不到验收记录里的 staging 提交号,无法核对部署,请人工看一下。"; continue; }
|
||
git -C "$REPO_DIR" fetch -q origin
|
||
ok=0
|
||
if [[ -n "$deployed" ]]; then
|
||
if git -C "$REPO_DIR" merge-base --is-ancestor "$want" "$deployed" 2>/dev/null; then ok=1
|
||
elif (cd "$REPO_DIR" && deploy/is-docs-only-range.sh "$deployed" "$want" >/dev/null 2>&1); then ok=1
|
||
fi
|
||
fi
|
||
if (( ok )); then
|
||
note="staging 已部署:/api/health 的 deployment.gitCommit = ${deployed:0:9},包含本次改动 ${want:0:9}。"
|
||
[[ -n "$checklist" && "$checklist" != "-" ]] && note="$note
|
||
|
||
请照真机清单走一遍:\`$checklist\`。没问题就关闭本 issue;有问题直接在下面写,并把标签改回 \`agent/待规划\`。"
|
||
post_text "$n" "<!-- agent:claude deployed sha=$deployed -->" "@jesse $note"
|
||
set_state "$n" agent/待真机
|
||
log "issue #$n: deployed $deployed contains $want → 待真机"
|
||
else
|
||
since="$(comments_json "$n" | jq -r '[.[] | select(.body | startswith("<!-- agent:claude review"))] | last | .created_at')"
|
||
age_h=$(( ( $(date +%s) - $(date -d "$since" +%s) ) / 3600 ))
|
||
if (( age_h >= DEPLOY_WAIT_HOURS )); then
|
||
set_state "$n" agent/需拍板
|
||
post_text "$n" "<!-- agent:claude escalate -->" "@jesse 推上 staging 已 ${age_h} 小时,部署仍未包含 ${want:0:9}(当前部署 ${deployed:0:9})。可能是门禁红了,请到 Gitea Actions 看一下。"
|
||
log "issue #$n: deploy wait exceeded"
|
||
fi
|
||
fi
|
||
done
|
||
|
||
# ---------- 3. one model run: review first, then plan ----------
|
||
|
||
pick() { "$GITEA" issues "$1,$PILOT_LABEL" | jq -r --arg lock "$2" '[.[] | select([.labels[].name] | index($lock) | not)] | .[0].number // empty'; }
|
||
|
||
n="$(pick agent/待验收 agent/验收中)"
|
||
if [[ -n "$n" ]]; then
|
||
mode=review; lock=agent/验收中; state=agent/待验收
|
||
else
|
||
n="$(pick agent/待规划 agent/规划中)"
|
||
[[ -z "$n" ]] && { log "nothing to do"; exit 0; }
|
||
mode=plan; lock=agent/规划中; state=agent/待规划
|
||
fi
|
||
|
||
mkdir -p "$RUN_DIR"
|
||
log "issue #$n: $mode start ($RUN_ID)"
|
||
"$GITEA" add-label "$n" "$lock"
|
||
trap '"$GITEA" remove-label "$n" "$lock" || true' EXIT
|
||
prepare_worktree
|
||
|
||
if [[ "$mode" == review ]]; then
|
||
rep="$(latest_marker "$n" 'agent:grok report')"
|
||
branch="$(marker_field branch "$rep")"; sha="$(marker_field sha "$rep")"
|
||
if [[ -z "$branch" || -z "$sha" ]]; then
|
||
fail_run "$n" "$state" "找不到 Grok 的回报(缺 branch 或 sha)"; exit 0
|
||
fi
|
||
head="$(git -C "$REPO_DIR" ls-remote origin "refs/heads/$branch" | cut -f1)"
|
||
if [[ "$head" != "$sha" ]]; then
|
||
fail_run "$n" "$state" "分支 $branch 当前 head ${head:0:9} 与 Grok 回报的 ${sha:0:9} 不一致,等 Grok 重新回报"; exit 0
|
||
fi
|
||
fails="$(markers "$n" | grep -F 'agent:claude review' | grep -cF 'verdict=fail' || true)"
|
||
extra="$(jq -nc --arg b "$branch" --arg s "$sha" --argjson f "${fails:-0}" '{branch:$b, sha:$s, previous_failed_reviews:$f}')"
|
||
else
|
||
extra=null
|
||
fi
|
||
|
||
build_context "$n" "$mode" "$RUN_DIR/context.json" "$extra"
|
||
tmo="$PLAN_TIMEOUT"; [[ "$mode" == review ]] && tmo="$REVIEW_TIMEOUT"
|
||
if ! run_claude "$HERE/prompts/claude-$mode.md" "$HERE/prompts/claude-$mode.schema.json" "$RUN_DIR/context.json" "$tmo" "$RUN_DIR/out.json"; then
|
||
fail_run "$n" "$state" "模型运行超时或异常退出(run $RUN_ID)"; exit 0
|
||
fi
|
||
res="$(jq -c '.structured_output // empty' "$RUN_DIR/out.json" 2>/dev/null || true)"
|
||
[[ -z "$res" ]] && { fail_run "$n" "$state" "模型没有给出结构化结果(run $RUN_ID)"; exit 0; }
|
||
printf '%s' "$res" > "$RUN_DIR/result.json"
|
||
jq -r '.comment_markdown' <<<"$res" > "$RUN_DIR/comment.md"
|
||
privacy_ok "$RUN_DIR/comment.md" || { fail_run "$n" "$state" "评论草稿疑似含个人资料或密钥,已拦下未发(草稿在开发机 $RUN_DIR)"; exit 0; }
|
||
|
||
git -C "$REPO_DIR" fetch -q origin
|
||
next="$(jq -r '.next // .verdict' <<<"$res")"
|
||
|
||
if [[ "$mode" == plan ]]; then
|
||
case "$next" in
|
||
待执行)
|
||
brief="$(jq -r .brief_path <<<"$res")"; br="$(jq -r .branch <<<"$res")"
|
||
git -C "$REPO_DIR" cat-file -e "origin/staging:$brief" 2>/dev/null \
|
||
|| { fail_run "$n" "$state" "任务书 $brief 不在 origin/staging 上"; exit 0; }
|
||
[[ "$br" =~ ^codex/[a-z0-9][a-z0-9-]*-[0-9]{8}$ ]] \
|
||
|| { fail_run "$n" "$state" "分支名 $br 不符合 codex/<主题>-<日期>"; exit 0; }
|
||
post "$n" "<!-- agent:claude plan brief=$brief branch=$br base=$(git -C "$REPO_DIR" rev-parse origin/staging) -->" "$RUN_DIR/comment.md"
|
||
set_state "$n" agent/待执行 ;;
|
||
需拍板)
|
||
post "$n" "<!-- agent:claude decision -->" "$RUN_DIR/comment.md"
|
||
set_state "$n" agent/需拍板 ;;
|
||
*) fail_run "$n" "$state" "模型给出未知的下一状态 $next"; exit 0 ;;
|
||
esac
|
||
else
|
||
verdict="$(jq -r .verdict <<<"$res")"; reviewed="$(jq -r .reviewed_sha <<<"$res")"
|
||
[[ "$reviewed" == "$sha" ]] || { fail_run "$n" "$state" "验收结论针对 ${reviewed:0:9},不是 Grok 回报的 ${sha:0:9}"; exit 0; }
|
||
head_now="$(git -C "$REPO_DIR" ls-remote origin "refs/heads/$branch" | cut -f1)"
|
||
[[ "$head_now" == "$sha" ]] || { fail_run "$n" "$state" "验收期间分支有新推送(${head_now:0:9}),下一轮重验"; exit 0; }
|
||
case "$verdict" in
|
||
pass)
|
||
st="$(jq -r .staging_sha <<<"$res")"; cl="$(jq -r '.checklist_path // "-"' <<<"$res")"
|
||
git -C "$REPO_DIR" merge-base --is-ancestor "$sha" "$st" 2>/dev/null \
|
||
&& git -C "$REPO_DIR" merge-base --is-ancestor "$st" origin/staging 2>/dev/null \
|
||
|| { fail_run "$n" "$state" "结论为通过,但 origin/staging 不包含 ${sha:0:9}(声称的 staging ${st:0:9})"; exit 0; }
|
||
post "$n" "<!-- agent:claude review sha=$sha verdict=pass staging=$st checklist=${cl:--} -->" "$RUN_DIR/comment.md"
|
||
set_state "$n" agent/部署中 ;;
|
||
fail)
|
||
fix="$(jq -r .fix_brief_path <<<"$res")"
|
||
git -C "$REPO_DIR" cat-file -e "origin/staging:$fix" 2>/dev/null \
|
||
|| { fail_run "$n" "$state" "结论为未通过,但修复单 $fix 不在 origin/staging 上"; exit 0; }
|
||
post "$n" "<!-- agent:claude review sha=$sha verdict=fail brief=$fix branch=$branch -->" "$RUN_DIR/comment.md"
|
||
if (( ${fails:-0} + 1 >= MAX_BOUNCES )); then
|
||
set_state "$n" agent/需拍板
|
||
post_text "$n" "<!-- agent:claude escalate -->" "@jesse 这一单已被验收打回 $(( ${fails:-0} + 1 )) 次,按规矩停下,等你决定是继续、改方向还是放弃。"
|
||
else
|
||
set_state "$n" agent/待执行
|
||
fi ;;
|
||
decision)
|
||
post "$n" "<!-- agent:claude review sha=$sha verdict=decision -->" "$RUN_DIR/comment.md"
|
||
set_state "$n" agent/需拍板 ;;
|
||
*) fail_run "$n" "$state" "模型给出未知的验收结论 $verdict"; exit 0 ;;
|
||
esac
|
||
fi
|
||
log "issue #$n: $mode done → $next"
|