#!/usr/bin/env bash # kimi-loop.sh — Dropout-proof Kimi code agent dispatch loop # Picks an open issue from Gitea, creates a worktree, runs Kimi Code CLI, # handles failures gracefully, and loops forever. # # Dropout-proof means: # - If Kimi Code crashes/hangs, we kill it and move on # - If worktree creation fails, skip and retry # - If push fails, log and continue # - Exponential backoff on repeated failures # - Clean up worktrees after PR is created set -euo pipefail # === CONFIG === REPO_DIR="$HOME/worktrees/kimi-repo" WORKTREE_BASE="$HOME/worktrees" GITEA_URL="http://143.198.27.163:3000" GITEA_TOKEN=$(cat "$HOME/.hermes/kimi_token") REPO_OWNER="rockachopa" REPO_NAME="Timmy-time-dashboard" KIMI_TIMEOUT=600 # 10 min per issue COOLDOWN=30 # seconds between issues MAX_FAILURES=5 # consecutive failures before long sleep LONG_SLEEP=300 # 5 min backoff on repeated failures LOG_DIR="$HOME/.hermes/logs" SKIP_FILE="$LOG_DIR/kimi-skip-list.json" # issues to skip temporarily mkdir -p "$LOG_DIR" "$WORKTREE_BASE" # Initialize skip file if missing [ -f "$SKIP_FILE" ] || echo '{}' > "$SKIP_FILE" # === STATE === failure_count=0 issues_completed=0 # === SKIP LIST FUNCTIONS === is_skipped() { local issue_num="$1" python3 -c " import json, time, sys try: with open('$SKIP_FILE') as f: skips = json.load(f) except: skips = {} entry = skips.get(str($issue_num), {}) if entry and entry.get('until', 0) > time.time(): print('skip') sys.exit(0) # Expired or not found — clean up and allow if str($issue_num) in skips: del skips[str($issue_num)] with open('$SKIP_FILE', 'w') as f: json.dump(skips, f) print('ok') " 2>/dev/null } mark_skip() { local issue_num="$1" local reason="$2" local skip_hours="${3:-1}" # default 1 hour python3 -c " import json, time try: with open('$SKIP_FILE') as f: skips = json.load(f) except: skips = {} skips[str($issue_num)] = { 'until': time.time() + ($skip_hours * 3600), 'reason': '$reason', 'failures': skips.get(str($issue_num), {}).get('failures', 0) + 1 } # If 3+ failures, skip for 6 hours instead if skips[str($issue_num)]['failures'] >= 3: skips[str($issue_num)]['until'] = time.time() + (6 * 3600) with open('$SKIP_FILE', 'w') as f: json.dump(skips, f, indent=2) " 2>/dev/null log "SKIP: #${issue_num} added to skip list — ${reason}" } log() { local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*" echo "$msg" >> "$LOG_DIR/kimi-loop.log" } post_issue_comment() { local issue_num="$1" local body="$2" local payload payload=$(python3 - "$body" <<'PY' import json, sys print(json.dumps({"body": sys.argv[1]})) PY ) curl -sf -X POST "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues/${issue_num}/comments" \ -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/json" \ -d "$payload" >/dev/null 2>&1 || true } remote_branch_exists() { local branch="$1" git ls-remote --heads origin "$branch" 2>/dev/null | grep -q . } get_pr_num() { local branch="$1" curl -sf "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/pulls?state=all&head=${REPO_OWNER}:${branch}&limit=1" -H "Authorization: token ${GITEA_TOKEN}" | python3 -c " import sys,json prs = json.load(sys.stdin) if prs: print(prs[0]['number']) else: print('') " 2>/dev/null } get_pr_file_count() { local pr_num="$1" curl -sf "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/pulls/${pr_num}/files" -H "Authorization: token ${GITEA_TOKEN}" | python3 -c " import sys, json try: files = json.load(sys.stdin) print(len(files) if isinstance(files, list) else 0) except: print(0) " 2>/dev/null } get_pr_state() { local pr_num="$1" curl -sf "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/pulls/${pr_num}" -H "Authorization: token ${GITEA_TOKEN}" | python3 -c " import sys, json try: pr = json.load(sys.stdin) if pr.get('merged'): print('merged') else: print(pr.get('state', 'unknown')) except: print('unknown') " 2>/dev/null } get_issue_state() { local issue_num="$1" curl -sf "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues/${issue_num}" -H "Authorization: token ${GITEA_TOKEN}" | python3 -c " import sys, json try: issue = json.load(sys.stdin) print(issue.get('state', 'unknown')) except: print('unknown') " 2>/dev/null } proof_comment_status() { local issue_num="$1" local branch="$2" curl -sf "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues/${issue_num}/comments" -H "Authorization: token ${GITEA_TOKEN}" | BRANCH="$branch" python3 -c " import os, sys, json branch = os.environ.get('BRANCH', '').lower() try: comments = json.load(sys.stdin) except Exception: print('missing|') raise SystemExit(0) for c in reversed(comments): user = ((c.get('user') or {}).get('login') or '').lower() body = c.get('body') or '' body_l = body.lower() if user != 'kimi': continue if 'proof:' not in body_l and 'verification:' not in body_l: continue has_branch = branch in body_l has_pr = ('pr:' in body_l) or ('pull request:' in body_l) or ('/pulls/' in body_l) has_push = ('push:' in body_l) or ('pushed' in body_l) has_verify = ('tox -e unit' in body_l) or ('pytest' in body_l) or ('verification:' in body_l) status = 'ok' if (has_branch and has_pr and has_push and has_verify) else 'incomplete' print(status + '|' + (c.get('html_url') or '')) raise SystemExit(0) print('missing|') " 2>/dev/null } cleanup_worktree() { local wt="$1" local branch="$2" if [ -d "$wt" ]; then cd "$REPO_DIR" git worktree remove --force "$wt" 2>/dev/null || rm -rf "$wt" git worktree prune 2>/dev/null git branch -D "$branch" 2>/dev/null || true log "Cleaned up worktree: $wt" fi } get_next_issue() { # Get open issues ASSIGNED TO KIMI only — Kimi works its own queue # NOTE: Gitea's assignee filter is unreliable — we validate in Python local skip_file="$SKIP_FILE" curl -sf "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues?state=open&type=issues&limit=50&sort=created" \ -H "Authorization: token ${GITEA_TOKEN}" | python3 -c " import sys, json, time issues = json.load(sys.stdin) # Reverse to oldest-first (Gitea returns newest-first) — respects dependency order issues.reverse() # Load skip list try: with open('${skip_file}') as f: skips = json.load(f) except: skips = {} for i in issues: # MUST be assigned to kimi (Gitea filter is broken, validate here) assignees = [a['login'] for a in (i.get('assignees') or [])] if 'kimi' not in assignees: continue title = i['title'].lower() # Skip philosophy, epics, showcases, features (not 10-min code work) if '[philosophy]' in title: continue if '[epic]' in title or 'epic:' in title: continue if '[showcase]' in title: continue if '[feature]' in title: continue # Check skip list num_str = str(i['number']) entry = skips.get(num_str, {}) if entry and entry.get('until', 0) > time.time(): continue print(json.dumps({'number': i['number'], 'title': i['title']})) sys.exit(0) print('null') " 2>/dev/null } build_prompt() { local issue_num="$1" local issue_title="$2" local worktree="$3" cat < (#${issue_num})", "body": "Fixes #${issue_num} Verification: - tox -e format - tox -e unit - tox -e lint", "head": "kimi/issue-${issue_num}", "base": "main"}' 5. COMMENT on the issue with a PROOF BLOCK before you exit. Use this exact shape: Proof: - branch: kimi/issue-${issue_num} - commit: - push: ok - pr: - verification: - tox -e format: - tox -e unit: - tox -e lint: - summary: 6. FILE NEW ISSUES if you find bugs, missing tests, or improvements while working: curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues" \ -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"title": "[kimi-generated] ", "body": "<description>"}' == RULES == - Read CLAUDE.md or project README first for conventions - tox is the ONLY way to run tests/lint/format. Never run pytest/ruff directly. - Never use --no-verify on git commands. - If tests fail after 2 attempts, STOP and comment on the issue explaining why. - Be thorough. If you see something broken nearby, file an issue for it. - ALWAYS run `git add .` before `git commit`. NEVER create an empty commit. - ALWAYS check `git diff --cached --stat` before committing — if empty, do NOT commit or create a PR. - DO NOT claim success unless the branch exists on Gitea, the PR exists, and the Proof block is posted on the issue. == CRITICAL: FINISH = PUSHED + PR'D + PROVED == - NEVER exit without committing your work. Even partial progress MUST be committed. - Before you finish, ALWAYS: git add -A && git commit && git push origin kimi/issue-${issue_num} - ALWAYS create a PR before exiting. No exceptions. - ALWAYS post the Proof block before exiting. No proof comment = not done. - Your work is WASTED if it's not pushed. Push early, push often. PROMPT } # === MAIN LOOP === log "=== Kimi Loop Started ===" log "Repo: ${REPO_DIR}" log "Worktrees: ${WORKTREE_BASE}" while true; do # Check for too many consecutive failures if [ "$failure_count" -ge "$MAX_FAILURES" ]; then log "BACKOFF: ${failure_count} consecutive failures. Sleeping ${LONG_SLEEP}s..." sleep "$LONG_SLEEP" failure_count=0 fi # Fetch latest main (resilient — never die on git errors) cd "$REPO_DIR" timeout 60 git fetch origin main 2>/dev/null || { log "WARN: git fetch failed, continuing anyway"; } git checkout main 2>/dev/null || true git reset --hard origin/main 2>/dev/null || true # Get next issue issue_json=$(get_next_issue) if [ "$issue_json" = "null" ] || [ -z "$issue_json" ]; then # Only log idle ONCE, then go quiet until work appears if [ "${LAST_STATE:-}" != "idle" ]; then log "Queue empty. Waiting for assignments..." LAST_STATE="idle" fi sleep "$LONG_SLEEP" continue fi LAST_STATE="working" 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'])") branch="kimi/issue-${issue_num}" worktree="${WORKTREE_BASE}/kimi-${issue_num}" log "=== ISSUE #${issue_num}: ${issue_title} ===" # Create worktree if [ -d "$worktree" ]; then log "Worktree already exists, cleaning..." cleanup_worktree "$worktree" "$branch" fi cd "$REPO_DIR" if ! git worktree add "$worktree" -b "$branch" origin/main 2>&1; then log "ERROR: Failed to create worktree for #${issue_num}" failure_count=$((failure_count + 1)) sleep "$COOLDOWN" continue fi # Configure git remote with kimi's token so it can push cd "$worktree" git remote set-url origin "http://kimi:${GITEA_TOKEN}@143.198.27.163:3000/${REPO_OWNER}/${REPO_NAME}.git" cd "$REPO_DIR" # Build prompt prompt=$(build_prompt "$issue_num" "$issue_title" "$worktree") # Run Kimi Code CLI with timeout log "Launching Kimi Code for #${issue_num} (timeout: ${KIMI_TIMEOUT}s)..." set +e cd "$worktree" gtimeout "$KIMI_TIMEOUT" kimi \ --print \ --quiet \ -w "$worktree" \ -p "$prompt" \ </dev/null 2>&1 | tee "$LOG_DIR/kimi-${issue_num}.log" exit_code=${PIPESTATUS[0]} cd "$REPO_DIR" set -e if [ "$exit_code" -eq 0 ]; then log "SUCCESS: #${issue_num} exited 0 — verifying push + PR + proof..." cd "$worktree" if ! remote_branch_exists "$branch"; then log " BLOCKED: remote branch ${branch} not found — work was not pushed" post_issue_comment "$issue_num" "Loop gate blocked completion: remote branch \`${branch}\` was not found on origin after Kimi exited. Issue remains open for retry." mark_skip "$issue_num" "missing_remote_branch" 1 failure_count=$((failure_count + 1)) cd "$REPO_DIR" else cd "$REPO_DIR" pr_num=$(get_pr_num "$branch") if [ -z "$pr_num" ]; then log " BLOCKED: no PR found for branch ${branch}" post_issue_comment "$issue_num" "Loop gate blocked completion: branch \`${branch}\` exists remotely, but no pull request was found. Issue remains open for retry." mark_skip "$issue_num" "missing_pr" 1 failure_count=$((failure_count + 1)) else pr_files=$(get_pr_file_count "$pr_num") if [ "${pr_files:-0}" -eq 0 ]; then log " BLOCKED: PR #${pr_num} has 0 changed files — empty commit detected, closing PR without merge" curl -sf -X PATCH "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/pulls/${pr_num}" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d '{"state": "closed"}' >/dev/null 2>&1 || true post_issue_comment "$issue_num" "PR #${pr_num} was closed automatically: it had 0 changed files (empty commit). Kimi must stage files with \`git add\` before committing. Issue remains open for retry." mark_skip "$issue_num" "empty_commit" 2 failure_count=$((failure_count + 1)) else proof_status=$(proof_comment_status "$issue_num" "$branch") proof_state="${proof_status%%|*}" proof_url="${proof_status#*|}" if [ "$proof_state" != "ok" ]; then log " BLOCKED: proof comment missing or incomplete (${proof_state})" post_issue_comment "$issue_num" "Loop gate blocked completion: PR #${pr_num} exists and has ${pr_files} changed file(s), but the required Proof block from Kimi is missing or incomplete. Issue remains open for retry." mark_skip "$issue_num" "missing_proof" 1 failure_count=$((failure_count + 1)) else log " PROOF: verified issue comment ${proof_url}" pr_state=$(get_pr_state "$pr_num") if [ "$pr_state" = "open" ]; 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 pr_state=$(get_pr_state "$pr_num") fi if [ "$pr_state" = "merged" ]; then 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 issue_state=$(get_issue_state "$issue_num") if [ "$issue_state" = "closed" ]; then log " VERIFIED: branch pushed, PR #${pr_num} merged, proof present, issue closed" failure_count=0 issues_completed=$((issues_completed + 1)) log "Stats: ${issues_completed} issues completed this session" else log " BLOCKED: PR #${pr_num} merged but issue #${issue_num} did not close" post_issue_comment "$issue_num" "Loop gate published and merged the work in PR #${pr_num}, but could not verify the issue closed cleanly. Please inspect state manually." mark_skip "$issue_num" "issue_close_unverified" 1 failure_count=$((failure_count + 1)) fi else log " BLOCKED: PR #${pr_num} state is ${pr_state} — merge not verified" post_issue_comment "$issue_num" "Loop gate published the work and verified proof, but could not verify merge for PR #${pr_num}. Issue remains open for retry/review." mark_skip "$issue_num" "merge_unverified" 1 failure_count=$((failure_count + 1)) fi fi fi fi fi elif [ "$exit_code" -eq 124 ]; then log "TIMEOUT: #${issue_num} exceeded ${KIMI_TIMEOUT}s" mark_skip "$issue_num" "timeout" 1 failure_count=$((failure_count + 1)) else log "FAILED: #${issue_num} exited with code ${exit_code}" mark_skip "$issue_num" "exit_code_${exit_code}" 1 failure_count=$((failure_count + 1)) fi # Clean up worktree cleanup_worktree "$worktree" "$branch" # Cooldown log "Cooling down ${COOLDOWN}s before next issue..." sleep "$COOLDOWN" done