#!/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: marker_field() { sed -nE "s/.* $1=([^ ]+).*/\1/p" <<<"$2"; } # gitea.sh returns only trusted accounts' text (jesse, Claude, Grok); # anyone else's comment never reaches a model. OWNER="${AGENT_LOOP_OWNER:-jesse}" comments_json() { "$GITEA" comments "$1"; } # Bodies' first lines (markers) of agent comments, oldest first. markers() { comments_json "$1" | jq -r '.[].body | split("\n")[0] | select(startswith("" "本轮自动处理没有完成:$reason。状态保持不变,下一轮重试。" errs="$(count_errors_since_progress "$n")" if (( errs >= MAX_ERRORS )); then set_state "$n" agent/需拍板 post_text "$n" "" "@$OWNER 连续 $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" "" "@$OWNER 找不到验收记录里的 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" "" "@$OWNER $note" set_state "$n" agent/待真机 log "issue #$n: deployed $deployed contains $want → 待真机" else since="$(comments_json "$n" | jq -r '[.[] | select(.body | startswith("" "@$OWNER 推上 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" "" "$RUN_DIR/comment.md" set_state "$n" agent/待执行 ;; 需拍板) post "$n" "" "$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" "" "$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" "" "$RUN_DIR/comment.md" if (( ${fails:-0} + 1 >= MAX_BOUNCES )); then set_state "$n" agent/需拍板 post_text "$n" "" "@$OWNER 这一单已被验收打回 $(( ${fails:-0} + 1 )) 次,按规矩停下,等你决定是继续、改方向还是放弃。" else set_state "$n" agent/待执行 fi ;; decision) post "$n" "" "$RUN_DIR/comment.md" set_state "$n" agent/需拍板 ;; *) fail_run "$n" "$state" "模型给出未知的验收结论 $verdict"; exit 0 ;; esac fi log "issue #$n: $mode done → $next"