gendesign/scripts/cleanup-stale-claims.sh
lekss361 5673dd6298 chore(claude): extend self-extending tripwire + pause-bots guard for stale-claims
Two safety follow-ups к autonomous-pipeline (flagged в multiagent-аудите):

- Self-extending guard расширен: reviewer-bot NEVER merge'ит PR, меняющий
  _autonomous_pickup.md (claim/kill-switch/merge-FSM contract) или любой
  work-as-*.md (persona activation) — раньше защищались только git-pr.md
  Auto-merge policy + CLAUDE.md + auto-code-reviewer.md. Закрывает дыру, где
  bot мог изменить claim/kill-switch logic без human. Правки в git-pr.md,
  auto-code-reviewer.md, work-as-reviewer.md.
- cleanup-stale-claims.sh: pause-bots early-exit. Без него cron освобождал
  wip-claim worker'а, приостановленного mid-work (он держит claim до un-pause
  per _autonomous_pickup.md), теряя его работу.
2026-05-29 17:36:20 +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 _autonomous_pickup.md
# «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