gendesign/scripts/cleanup-stale-claims.sh
bot-backend 4b019ebd67
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
chore(claude): retire autonomous bot-pipeline
Ролевые окна (claude --strict-mcp-config --mcp-config .claude/mcp/<role>.json)
не используются — отказ по стоимости: слишком много токенов на простых задачах.

Удалено (25 файлов):
- scripts/: start-bot, start-{analyst,backend,frontend,qa,reviewer}, setup-bot-env
- .claude/mcp/: 5 per-role MCP-конфигов
- .claude/commands/: 6 work-as-*
- .claude/agents/: 6 auto-* + _autonomous_pickup

Ссылки в выживших файлах поправлены: CLAUDE.md, README.md, git-pr.md,
deep-review-phases/phase-5-verdict.md, deep-code-reviewer.md,
cleanup-stale-claims.sh. Self-extending guard сохранён — из него убраны
только имена удалённых файлов, сам запрет на self-merge правил пайплайна цел.

Универсальные воркеры не тронуты: backend-engineer, frontend-engineer,
database-expert, devops-engineer, tech-analyst, code-reviewer, qa-tester,
deep-code-reviewer.
2026-08-28 22:27:00 +03:00

174 lines
6.9 KiB
Bash
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
# Cleanup stale claims for autonomous multi-agent workflow.
#
# Освобождает issues застрявшие в `status/wip` дольше 4 часов:
# - убирает assignee
# - меняет label status/wip → status/ready (delete wip → add ready)
#
# Why: если worker crashes или window закрывается во время работы, issue
# остаётся wip+assigned forever. Без cleanup queue забивается.
#
# Run: cron каждые 30 минут (см. .forgejo/workflows/stale-claims.yml).
#
# Env vars (требуются):
# FORGEJO_TOKEN — admin или с write:issue scope на repo
# FORGEJO_URL — https://git.gendsgn.ru (без /api/v1)
# FORGEJO_REPO — lekss361/gendesign
# STALE_HOURS — порог в часах (default 4)
#
# Assumption: issue.updated_at = последний touch (label/comment/assignee
# change). Worker делающий task >4h без касаний issue будет считаться stale.
# Для текущих small tasks 4h enough; в Phase 4 можно добавить heartbeat
# comment'ы от worker'а.
#
# Exit codes:
# 0 — success (even if 0 stale found)
# 1 — env vars missing / API error
set -euo pipefail
: "${FORGEJO_TOKEN:?FORGEJO_TOKEN required}"
: "${FORGEJO_URL:?FORGEJO_URL required}"
: "${FORGEJO_REPO:?FORGEJO_REPO required}"
export STALE_HOURS="${STALE_HOURS:-4}"
STALE_SECS=$((STALE_HOURS * 3600))
curl_forgejo() {
curl -sS -H "Authorization: token $FORGEJO_TOKEN" \
-H "Content-Type: application/json" \
"$FORGEJO_URL/api/v1/$1" "${@:2}"
}
now_utc_epoch() {
python3 -c 'import datetime; print(int(datetime.datetime.now(datetime.timezone.utc).timestamp()))'
}
# Pause-bots guard: если активен kill-switch — НЕ освобождать claims. Иначе worker,
# приостановленный mid-work (держит wip-claim до un-pause per claim-контракт
# «Pause-bots поведение mid-work»), потеряет работу — cron снимет его claim.
PAUSE_CHECK=$(curl_forgejo "repos/$FORGEJO_REPO/issues?labels=pause-bots&state=open&limit=1")
PAUSE_COUNT=$(printf '%s' "$PAUSE_CHECK" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))')
if [ "$PAUSE_COUNT" != "0" ]; then
echo "[$(date -Is)] cleanup-stale-claims: pause-bots active — skipping (workers may intentionally hold wip)"
exit 0
fi
echo "[$(date -Is)] cleanup-stale-claims: scanning status/wip issues (stale threshold: ${STALE_HOURS}h)"
NOW=$(now_utc_epoch)
export CUTOFF=$((NOW - STALE_SECS))
# GET all open issues с label status/wip — pagination в Python (более надёжно чем
# склеивать JSON arrays в bash). Defensive cap 10 pages × 50 = 500.
ISSUES_TMP=$(mktemp)
trap 'rm -f "$ISSUES_TMP"' EXIT
for PAGE in 1 2 3 4 5 6 7 8 9 10; do
PAGE_RESP=$(curl_forgejo "repos/$FORGEJO_REPO/issues?labels=status/wip&state=open&limit=50&page=$PAGE")
echo "$PAGE_RESP" >> "$ISSUES_TMP"
echo "---PAGE_END---" >> "$ISSUES_TMP"
PAGE_LEN=$(echo "$PAGE_RESP" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))')
if [ "$PAGE_LEN" -lt 50 ]; then
break
fi
done
# Парсим JSON — читаем файл прямо в Python через env var (не через pipe `cat | python3`).
# При pipe + sys.exit(0) после print, cat получает SIGPIPE → script exits 141 под
# set -o pipefail. Передача path через env var не использует pipe → no SIGPIPE.
ISSUES_FILE="$ISSUES_TMP" python3 <<'PYEOF'
import json, os, subprocess, sys, datetime
cutoff = int(os.environ.get("CUTOFF", "0"))
if cutoff == 0:
print("[ERROR] CUTOFF env var не выставлен или равен 0 — abort", file=sys.stderr)
sys.exit(1)
# Read pagination output — multiple JSON arrays separated by "---PAGE_END---"
with open(os.environ["ISSUES_FILE"]) as f:
raw = f.read()
pages = [p.strip() for p in raw.split("---PAGE_END---") if p.strip()]
data = []
for p in pages:
try:
data.extend(json.loads(p))
except json.JSONDecodeError:
print(f"[WARN] failed to parse page (len={len(p)}), skipping", file=sys.stderr)
if not data:
print("[OK] 0 wip issues — nothing to do")
sys.exit(0)
released = 0
errors = 0
now_epoch = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
for issue in data:
issue_num = issue["number"]
updated_at = issue["updated_at"]
title = issue["title"]
assignees = [a["login"] for a in (issue.get("assignees") or [])]
updated_epoch = int(datetime.datetime.fromisoformat(updated_at.replace("Z", "+00:00")).timestamp())
age_secs = now_epoch - updated_epoch
age_hours = age_secs // 3600
if updated_epoch >= cutoff:
print(f" · #{issue_num} '{title[:50]}' — age {age_hours}h, fresh, skip")
continue
print(f" ⚠ #{issue_num} '{title[:50]}' — age {age_hours}h, STALE")
print(f" assignees: {assignees}")
print(f" → releasing claim")
repo = os.environ["FORGEJO_REPO"]
base = os.environ["FORGEJO_URL"] + "/api/v1"
token = os.environ["FORGEJO_TOKEN"]
auth = ["-H", f"Authorization: token {token}", "-H", "Content-Type: application/json"]
try:
# 1. Clear assignees
subprocess.run(
["curl", "-sS", "-X", "PATCH", *auth, "-d", '{"assignees": []}',
f"{base}/repos/{repo}/issues/{issue_num}"],
check=True, capture_output=True,
)
# 2. Remove status/wip label FIRST (если падает — issue в neutral state без status/*,
# что менее опасно чем оба status/ready+status/wip одновременно)
subprocess.run(
["curl", "-sS", "-X", "DELETE", *auth,
f"{base}/repos/{repo}/issues/{issue_num}/labels/status%2Fwip"],
check=True, capture_output=True,
)
# 3. Add status/ready label
subprocess.run(
["curl", "-sS", "-X", "POST", *auth, "-d", '{"labels": ["status/ready"]}',
f"{base}/repos/{repo}/issues/{issue_num}/labels"],
check=True, capture_output=True,
)
# 4. Post comment for trace
comment_body = json.dumps({
"body": f"⚠️ Stale claim released: issue stuck in status/wip for {age_hours}h "
f"(previously assigned to {', '.join(assignees) or 'none'}). "
f"Label transition: status/wip → status/ready. Worker likely crashed."
})
subprocess.run(
["curl", "-sS", "-X", "POST", *auth, "-d", comment_body,
f"{base}/repos/{repo}/issues/{issue_num}/comments"],
check=True, capture_output=True,
)
released += 1
except subprocess.CalledProcessError as e:
print(f" ✗ FAILED: {e.stderr.decode() if e.stderr else 'unknown'}")
errors += 1
print()
print(f"[OK] Released {released} stale claim(s), {errors} errors")
sys.exit(1 if errors else 0)
PYEOF