feat: supervisor detects dep deadlocks, stale deps, and dev-agent blocked states
Add three new supervisor checks: - P2c: alert when dev-agent reports "no ready issues" for 6+ consecutive polls - P3b: detect circular dependency deadlocks via DFS cycle detection - P3c: flag backlog issues blocked by deps open >30 days Update supervisor PROMPT.md with guidance for Claude to resolve circular deps by reading code context, and handle stale deps by checking relevance. Gardener prompt now forbids bidirectional deps between sibling issues and requires ## Related (not ## Dependencies) for cross-references. Closes #16, Closes #17 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
27373a16f3
commit
acab6c95c8
3 changed files with 201 additions and 2 deletions
|
|
@ -225,6 +225,22 @@ if [ "${BACKLOG_COUNT:-0}" -gt 0 ] && [ "${IN_PROGRESS:-0}" -eq 0 ]; then
|
|||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# P2c: DEV-AGENT PRODUCTIVITY — all backlog blocked for too long
|
||||
# =============================================================================
|
||||
status "P2: checking dev-agent productivity"
|
||||
|
||||
DEV_LOG_FILE="${FACTORY_ROOT}/dev/dev-agent.log"
|
||||
if [ -f "$DEV_LOG_FILE" ]; then
|
||||
# Check if last 6 poll entries all report "no ready issues" (~1 hour at 10min intervals)
|
||||
RECENT_POLLS=$(tail -100 "$DEV_LOG_FILE" | grep "poll:" | tail -6)
|
||||
TOTAL_RECENT=$(echo "$RECENT_POLLS" | grep -c "." || true)
|
||||
BLOCKED_IN_RECENT=$(echo "$RECENT_POLLS" | grep -c "no ready issues" || true)
|
||||
if [ "$TOTAL_RECENT" -ge 6 ] && [ "$BLOCKED_IN_RECENT" -eq "$TOTAL_RECENT" ]; then
|
||||
p2 "Dev-agent blocked: last ${BLOCKED_IN_RECENT} polls all report 'no ready issues' — all backlog issues may be dep-blocked or have circular deps"
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# P3: FACTORY DEGRADED — derailed PRs, unreviewed PRs
|
||||
# =============================================================================
|
||||
|
|
@ -273,6 +289,150 @@ for pr in $OPEN_PRS; do
|
|||
fi
|
||||
done
|
||||
|
||||
# =============================================================================
|
||||
# P3b: CIRCULAR DEPENDENCIES — deadlock detection
|
||||
# =============================================================================
|
||||
status "P3: checking for circular dependencies"
|
||||
|
||||
BACKLOG_FOR_DEPS=$(codeberg_api GET "/issues?state=open&labels=backlog&type=issues&limit=50" 2>/dev/null || true)
|
||||
if [ -n "$BACKLOG_FOR_DEPS" ] && [ "$BACKLOG_FOR_DEPS" != "null" ] && [ "$(echo "$BACKLOG_FOR_DEPS" | jq 'length' 2>/dev/null || echo 0)" -gt 0 ]; then
|
||||
|
||||
CYCLES=$(echo "$BACKLOG_FOR_DEPS" | python3 -c '
|
||||
import sys, json, re
|
||||
|
||||
issues = json.load(sys.stdin)
|
||||
|
||||
def parse_deps(body):
|
||||
deps = set()
|
||||
in_section = False
|
||||
for line in (body or "").split("\n"):
|
||||
if re.match(r"^##?\s*(Depends on|Blocked by|Dependencies)", line, re.IGNORECASE):
|
||||
in_section = True
|
||||
continue
|
||||
if in_section and re.match(r"^##?\s", line):
|
||||
in_section = False
|
||||
if in_section:
|
||||
deps.update(int(m) for m in re.findall(r"#(\d+)", line))
|
||||
if re.search(r"(depends on|blocked by)", line, re.IGNORECASE):
|
||||
deps.update(int(m) for m in re.findall(r"#(\d+)", line))
|
||||
return deps
|
||||
|
||||
graph = {}
|
||||
for issue in issues:
|
||||
num = issue["number"]
|
||||
deps = parse_deps(issue.get("body", ""))
|
||||
deps.discard(num)
|
||||
if deps:
|
||||
graph[num] = deps
|
||||
|
||||
WHITE, GRAY, BLACK = 0, 1, 2
|
||||
color = {n: WHITE for n in graph}
|
||||
cycles = []
|
||||
|
||||
def dfs(u, path):
|
||||
color[u] = GRAY
|
||||
path.append(u)
|
||||
for v in graph.get(u, set()):
|
||||
if v not in color:
|
||||
continue
|
||||
if color[v] == GRAY:
|
||||
cycles.append(path[path.index(v):] + [v])
|
||||
elif color[v] == WHITE:
|
||||
dfs(v, path)
|
||||
path.pop()
|
||||
color[u] = BLACK
|
||||
|
||||
for node in list(graph.keys()):
|
||||
if color.get(node) == WHITE:
|
||||
dfs(node, [])
|
||||
|
||||
seen = set()
|
||||
for cycle in cycles:
|
||||
key = tuple(sorted(set(cycle)))
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
print(" -> ".join(f"#{n}" for n in cycle))
|
||||
' 2>/dev/null || true)
|
||||
|
||||
if [ -n "$CYCLES" ]; then
|
||||
while IFS= read -r cycle; do
|
||||
[ -z "$cycle" ] && continue
|
||||
p3 "Circular dependency deadlock: ${cycle}"
|
||||
done <<< "$CYCLES"
|
||||
fi
|
||||
|
||||
# ===========================================================================
|
||||
# P3c: STALE DEPENDENCIES — blocked by old open issues (>30 days)
|
||||
# ===========================================================================
|
||||
status "P3: checking for stale dependencies"
|
||||
|
||||
STALE_DEPS=$(echo "$BACKLOG_FOR_DEPS" | CODEBERG_TOKEN="$CODEBERG_TOKEN" CODEBERG_API="$CODEBERG_API" python3 -c '
|
||||
import sys, json, re, os
|
||||
from datetime import datetime, timezone
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
issues = json.load(sys.stdin)
|
||||
token = os.environ.get("CODEBERG_TOKEN", "")
|
||||
api = os.environ.get("CODEBERG_API", "")
|
||||
issue_map = {i["number"]: i for i in issues}
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
def parse_deps(body):
|
||||
deps = set()
|
||||
in_section = False
|
||||
for line in (body or "").split("\n"):
|
||||
if re.match(r"^##?\s*(Depends on|Blocked by|Dependencies)", line, re.IGNORECASE):
|
||||
in_section = True
|
||||
continue
|
||||
if in_section and re.match(r"^##?\s", line):
|
||||
in_section = False
|
||||
if in_section:
|
||||
deps.update(int(m) for m in re.findall(r"#(\d+)", line))
|
||||
if re.search(r"(depends on|blocked by)", line, re.IGNORECASE):
|
||||
deps.update(int(m) for m in re.findall(r"#(\d+)", line))
|
||||
return deps
|
||||
|
||||
checked = {}
|
||||
for issue in issues:
|
||||
num = issue["number"]
|
||||
deps = parse_deps(issue.get("body", ""))
|
||||
deps.discard(num)
|
||||
for dep in deps:
|
||||
if dep in checked:
|
||||
dep_data = checked[dep]
|
||||
elif dep in issue_map:
|
||||
dep_data = issue_map[dep]
|
||||
checked[dep] = dep_data
|
||||
else:
|
||||
try:
|
||||
req = Request(f"{api}/issues/{dep}",
|
||||
headers={"Authorization": f"token {token}"})
|
||||
with urlopen(req, timeout=5) as resp:
|
||||
dep_data = json.loads(resp.read())
|
||||
checked[dep] = dep_data
|
||||
except Exception:
|
||||
continue
|
||||
if dep_data.get("state") != "open":
|
||||
continue
|
||||
created = dep_data.get("created_at", "")
|
||||
try:
|
||||
created_dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
|
||||
age_days = (now - created_dt).days
|
||||
if age_days > 30:
|
||||
dep_title = dep_data.get("title", "")[:50]
|
||||
print(f"#{num} blocked by #{dep} \"{dep_title}\" (open {age_days} days)")
|
||||
except Exception:
|
||||
pass
|
||||
' 2>/dev/null || true)
|
||||
|
||||
if [ -n "$STALE_DEPS" ]; then
|
||||
while IFS= read -r stale; do
|
||||
[ -z "$stale" ] && continue
|
||||
p3 "Stale dependency: ${stale}"
|
||||
done <<< "$STALE_DEPS"
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# P4: HOUSEKEEPING — stale processes
|
||||
# =============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue