Implement 現地現物 (Genchi Genbutsu) post-completion verification: - Add bin/genchi-genbutsu.sh performing 5 world-state checks: 1. Branch exists on remote 2. PR exists 3. PR has real file changes (> 0) 4. PR is mergeable 5. Issue has a completion comment from the agent - Wire verification into all agent loops: - bin/claude-loop.sh: call genchi-genbutsu before merge/close - bin/gemini-loop.sh: delegate existing inline checks to genchi-genbutsu - bin/agent-loop.sh: resurrect generic agent loop with genchi-genbutsu wired in - Update metrics JSONL to include 'verified' field for all loops - Update burn monitor (tasks.py velocity_tracking): - Report verified_completion count alongside raw completions - Dashboard shows verified trend history - Update morning report (tasks.py good_morning_report): - Count only verified completions from the last 24h - Surface verification failures in the report body Fixes #348 Refs #345
274 lines
10 KiB
Bash
Executable File
274 lines
10 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# agent-loop.sh — Universal agent dev loop with Genchi Genbutsu verification
|
|
#
|
|
# Usage: agent-loop.sh <agent-name> [num-workers]
|
|
# agent-loop.sh claude 2
|
|
# agent-loop.sh gemini 1
|
|
#
|
|
# Dispatches via agent-dispatch.sh, then verifies with genchi-genbutsu.sh.
|
|
|
|
set -uo pipefail
|
|
|
|
AGENT="${1:?Usage: agent-loop.sh <agent-name> [num-workers]}"
|
|
NUM_WORKERS="${2:-1}"
|
|
|
|
# Resolve agent tool and model from config or fallback
|
|
case "$AGENT" in
|
|
claude) TOOL="claude"; MODEL="sonnet" ;;
|
|
gemini) TOOL="gemini"; MODEL="gemini-2.5-pro-preview-05-06" ;;
|
|
grok) TOOL="opencode"; MODEL="grok-3-fast" ;;
|
|
*) TOOL="$AGENT"; MODEL="" ;;
|
|
esac
|
|
|
|
# === CONFIG ===
|
|
GITEA_URL="${GITEA_URL:-https://forge.alexanderwhitestone.com}"
|
|
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
|
WORKTREE_BASE="$HOME/worktrees"
|
|
LOG_DIR="$HOME/.hermes/logs"
|
|
LOCK_DIR="$LOG_DIR/${AGENT}-locks"
|
|
SKIP_FILE="$LOG_DIR/${AGENT}-skip-list.json"
|
|
ACTIVE_FILE="$LOG_DIR/${AGENT}-active.json"
|
|
TIMEOUT=600
|
|
COOLDOWN=30
|
|
|
|
mkdir -p "$LOG_DIR" "$WORKTREE_BASE" "$LOCK_DIR"
|
|
[ -f "$SKIP_FILE" ] || echo '{}' > "$SKIP_FILE"
|
|
echo '{}' > "$ACTIVE_FILE"
|
|
|
|
# === SHARED FUNCTIONS ===
|
|
log() {
|
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ${AGENT}: $*" >> "$LOG_DIR/${AGENT}-loop.log"
|
|
}
|
|
|
|
lock_issue() {
|
|
local key="$1"
|
|
mkdir "$LOCK_DIR/$key.lock" 2>/dev/null && echo $$ > "$LOCK_DIR/$key.lock/pid"
|
|
}
|
|
|
|
unlock_issue() {
|
|
rm -rf "$LOCK_DIR/$1.lock" 2>/dev/null
|
|
}
|
|
|
|
mark_skip() {
|
|
local issue_num="$1" reason="$2"
|
|
python3 -c "
|
|
import json, time, fcntl
|
|
with open('${SKIP_FILE}', 'r+') as f:
|
|
fcntl.flock(f, fcntl.LOCK_EX)
|
|
try: skips = json.load(f)
|
|
except: skips = {}
|
|
failures = skips.get(str($issue_num), {}).get('failures', 0) + 1
|
|
skip_hours = 6 if failures >= 3 else 1
|
|
skips[str($issue_num)] = {'until': time.time() + (skip_hours * 3600), 'reason': '$reason', 'failures': failures}
|
|
f.seek(0); f.truncate()
|
|
json.dump(skips, f, indent=2)
|
|
" 2>/dev/null
|
|
}
|
|
|
|
get_next_issue() {
|
|
python3 -c "
|
|
import json, sys, time, urllib.request, os
|
|
token = '${GITEA_TOKEN}'
|
|
base = '${GITEA_URL}'
|
|
repos = ['Timmy_Foundation/the-nexus', 'Timmy_Foundation/timmy-config', 'Timmy_Foundation/hermes-agent']
|
|
try:
|
|
with open('${SKIP_FILE}') as f: skips = json.load(f)
|
|
except: skips = {}
|
|
try:
|
|
with open('${ACTIVE_FILE}') as f: active = json.load(f); active_issues = {v['issue'] for v in active.values()}
|
|
except: active_issues = set()
|
|
all_issues = []
|
|
for repo in repos:
|
|
url = f'{base}/api/v1/repos/{repo}/issues?state=open&type=issues&limit=50&sort=created'
|
|
req = urllib.request.Request(url, headers={'Authorization': f'token {token}'})
|
|
try:
|
|
resp = urllib.request.urlopen(req, timeout=10)
|
|
issues = json.loads(resp.read())
|
|
for i in issues: i['_repo'] = repo
|
|
all_issues.extend(issues)
|
|
except: continue
|
|
for i in sorted(all_issues, key=lambda x: x['title'].lower()):
|
|
assignees = [a['login'] for a in (i.get('assignees') or [])]
|
|
if assignees and '${AGENT}' not in assignees: continue
|
|
num_str = str(i['number'])
|
|
if num_str in active_issues: continue
|
|
if skips.get(num_str, {}).get('until', 0) > time.time(): continue
|
|
lock = '${LOCK_DIR}/' + i['_repo'].replace('/', '-') + '-' + num_str + '.lock'
|
|
if os.path.isdir(lock): continue
|
|
owner, name = i['_repo'].split('/')
|
|
print(json.dumps({'number': i['number'], 'title': i['title'], 'repo_owner': owner, 'repo_name': name, 'repo': i['_repo']}))
|
|
sys.exit(0)
|
|
print('null')
|
|
" 2>/dev/null
|
|
}
|
|
|
|
# === WORKER FUNCTION ===
|
|
run_worker() {
|
|
local worker_id="$1"
|
|
log "WORKER-${worker_id}: Started"
|
|
|
|
while true; do
|
|
issue_json=$(get_next_issue)
|
|
if [ "$issue_json" = "null" ] || [ -z "$issue_json" ]; then
|
|
sleep 30
|
|
continue
|
|
fi
|
|
|
|
issue_num=$(echo "$issue_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['number'])")
|
|
issue_title=$(echo "$issue_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['title'])")
|
|
repo_owner=$(echo "$issue_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['repo_owner'])")
|
|
repo_name=$(echo "$issue_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['repo_name'])")
|
|
issue_key="${repo_owner}-${repo_name}-${issue_num}"
|
|
branch="${AGENT}/issue-${issue_num}"
|
|
worktree="${WORKTREE_BASE}/${AGENT}-w${worker_id}-${issue_num}"
|
|
|
|
if ! lock_issue "$issue_key"; then
|
|
sleep 5
|
|
continue
|
|
fi
|
|
|
|
log "WORKER-${worker_id}: === ISSUE #${issue_num}: ${issue_title} (${repo_owner}/${repo_name}) ==="
|
|
|
|
# Clone / checkout
|
|
rm -rf "$worktree" 2>/dev/null
|
|
CLONE_URL="http://${AGENT}:${GITEA_TOKEN}@143.198.27.163:3000/${repo_owner}/${repo_name}.git"
|
|
if git ls-remote --heads "$CLONE_URL" "$branch" 2>/dev/null | grep -q "$branch"; then
|
|
git clone --depth=50 -b "$branch" "$CLONE_URL" "$worktree" >/dev/null 2>&1
|
|
else
|
|
git clone --depth=1 -b main "$CLONE_URL" "$worktree" >/dev/null 2>&1
|
|
cd "$worktree" && git checkout -b "$branch" >/dev/null 2>&1
|
|
fi
|
|
cd "$worktree"
|
|
|
|
# Generate prompt
|
|
prompt=$(bash "$(dirname "$0")/agent-dispatch.sh" "$AGENT" "$issue_num" "${repo_owner}/${repo_name}")
|
|
|
|
CYCLE_START=$(date +%s)
|
|
set +e
|
|
if [ "$TOOL" = "claude" ]; then
|
|
env -u CLAUDECODE gtimeout "$TIMEOUT" claude \
|
|
--print --model "$MODEL" --dangerously-skip-permissions \
|
|
-p "$prompt" </dev/null >> "$LOG_DIR/${AGENT}-${issue_num}.log" 2>&1
|
|
elif [ "$TOOL" = "gemini" ]; then
|
|
gtimeout "$TIMEOUT" gemini -p "$prompt" --yolo \
|
|
</dev/null >> "$LOG_DIR/${AGENT}-${issue_num}.log" 2>&1
|
|
else
|
|
gtimeout "$TIMEOUT" "$TOOL" "$prompt" \
|
|
</dev/null >> "$LOG_DIR/${AGENT}-${issue_num}.log" 2>&1
|
|
fi
|
|
exit_code=$?
|
|
set -e
|
|
CYCLE_END=$(date +%s)
|
|
CYCLE_DURATION=$((CYCLE_END - CYCLE_START))
|
|
|
|
# Salvage
|
|
cd "$worktree" 2>/dev/null || true
|
|
DIRTY=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
|
|
if [ "${DIRTY:-0}" -gt 0 ]; then
|
|
git add -A 2>/dev/null
|
|
git commit -m "WIP: ${AGENT} progress on #${issue_num}
|
|
|
|
Automated salvage commit — agent session ended (exit $exit_code)." 2>/dev/null || true
|
|
fi
|
|
|
|
UNPUSHED=$(git log --oneline "origin/main..HEAD" 2>/dev/null | wc -l | tr -d ' ')
|
|
if [ "${UNPUSHED:-0}" -gt 0 ]; then
|
|
git push -u origin "$branch" 2>/dev/null && \
|
|
log "WORKER-${worker_id}: Pushed $UNPUSHED commit(s) on $branch" || \
|
|
log "WORKER-${worker_id}: Push failed for $branch"
|
|
fi
|
|
|
|
# Create PR if needed
|
|
pr_num=$(curl -sf "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls?state=open&head=${repo_owner}:${branch}&limit=1" \
|
|
-H "Authorization: token ${GITEA_TOKEN}" | python3 -c "
|
|
import sys,json
|
|
prs = json.load(sys.stdin)
|
|
print(prs[0]['number'] if prs else '')
|
|
" 2>/dev/null)
|
|
|
|
if [ -z "$pr_num" ] && [ "${UNPUSHED:-0}" -gt 0 ]; then
|
|
pr_num=$(curl -sf -X POST "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls" \
|
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$(python3 -c "
|
|
import json
|
|
print(json.dumps({
|
|
'title': '${AGENT}: Issue #${issue_num}',
|
|
'head': '${branch}',
|
|
'base': 'main',
|
|
'body': 'Automated PR for issue #${issue_num}.\nExit code: ${exit_code}'
|
|
}))
|
|
")" | python3 -c "import sys,json; print(json.load(sys.stdin).get('number',''))" 2>/dev/null)
|
|
[ -n "$pr_num" ] && log "WORKER-${worker_id}: Created PR #${pr_num} for issue #${issue_num}"
|
|
fi
|
|
|
|
# ── Genchi Genbutsu: verify world state before declaring success ──
|
|
VERIFIED="false"
|
|
if [ "$exit_code" -eq 0 ]; then
|
|
log "WORKER-${worker_id}: SUCCESS #${issue_num} — running genchi-genbutsu"
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
if verify_result=$("$SCRIPT_DIR/genchi-genbutsu.sh" "$repo_owner" "$repo_name" "$issue_num" "$branch" "$AGENT" 2>/dev/null); then
|
|
VERIFIED="true"
|
|
log "WORKER-${worker_id}: VERIFIED #${issue_num}"
|
|
if [ -n "$pr_num" ]; then
|
|
curl -sf -X POST "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls/${pr_num}/merge" \
|
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"Do": "squash"}' >/dev/null 2>&1 || true
|
|
curl -sf -X PATCH "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/issues/${issue_num}" \
|
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"state": "closed"}' >/dev/null 2>&1 || true
|
|
log "WORKER-${worker_id}: PR #${pr_num} merged, issue #${issue_num} closed"
|
|
fi
|
|
consecutive_failures=0
|
|
else
|
|
verify_details=$(echo "$verify_result" | python3 -c "import sys,json; print(json.load(sys.stdin).get('details','unknown'))" 2>/dev/null || echo "unverified")
|
|
log "WORKER-${worker_id}: UNVERIFIED #${issue_num} — $verify_details"
|
|
mark_skip "$issue_num" "unverified" 1
|
|
consecutive_failures=$((consecutive_failures + 1))
|
|
fi
|
|
elif [ "$exit_code" -eq 124 ]; then
|
|
log "WORKER-${worker_id}: TIMEOUT #${issue_num} (work saved in PR)"
|
|
consecutive_failures=$((consecutive_failures + 1))
|
|
else
|
|
log "WORKER-${worker_id}: FAILED #${issue_num} exit ${exit_code} (work saved in PR)"
|
|
consecutive_failures=$((consecutive_failures + 1))
|
|
fi
|
|
|
|
# ── METRICS ──
|
|
python3 -c "
|
|
import json, datetime
|
|
print(json.dumps({
|
|
'ts': datetime.datetime.utcnow().isoformat() + 'Z',
|
|
'agent': '${AGENT}',
|
|
'worker': $worker_id,
|
|
'issue': $issue_num,
|
|
'repo': '${repo_owner}/${repo_name}',
|
|
'outcome': 'success' if $exit_code == 0 else 'timeout' if $exit_code == 124 else 'failed',
|
|
'exit_code': $exit_code,
|
|
'duration_s': $CYCLE_DURATION,
|
|
'pr': '${pr_num:-}',
|
|
'verified': ${VERIFIED:-false}
|
|
}))
|
|
" >> "$LOG_DIR/${AGENT}-metrics.jsonl" 2>/dev/null
|
|
|
|
rm -rf "$worktree" 2>/dev/null
|
|
unlock_issue "$issue_key"
|
|
sleep "$COOLDOWN"
|
|
done
|
|
}
|
|
|
|
# === MAIN ===
|
|
log "=== Agent Loop Started — ${AGENT} with ${NUM_WORKERS} worker(s) ==="
|
|
|
|
rm -rf "$LOCK_DIR"/*.lock 2>/dev/null
|
|
|
|
for i in $(seq 1 "$NUM_WORKERS"); do
|
|
run_worker "$i" &
|
|
log "Launched worker $i (PID $!)"
|
|
sleep 3
|
|
done
|
|
|
|
wait
|