Files
Jyotisha/tools/agent-loop/claude-tick.sh
T

274 lines
14 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
# 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"