agents: fix review findings — thread attribution, races, rescue stall, docs, CI
ci / lint (push) Skipped

Fixes from a full repo review:

- fetch-thread.sh: attribute每 comment to its REAL author (@pm/@qa/… — agents
  post as their own users now); the old "🤖 @name line at the top" hint pointed
  at headers we removed, leaving every teammate comment anonymous. Also strip
  the hidden loop-prevention marker from bodies (prompt noise).
- agent.yml: per-issue concurrency group (queued, no cancel) — two quick
  comments on one issue no longer race the same ai/issue-N branch.
- rescue-pr.sh: after opening a rescue PR, hand it back into the flow with an
  unmarked @pm trigger (the pm→qa choreography previously stalled silently on
  any rescued run); drop the old "🤖 **@name**" header style; add ops to the
  token case; mark the status note with the hidden marker.
- README: token table said "@qa merges / TOKEN_QA needs write:repository" —
  inverted since the PM-orchestrated flow (@pm merges, autopilot only; @qa is
  read-only). Updated the agent table (descs, node1-ssh moved to homelab) and
  added a "How a task flows" section.
- agents.json: pm/qa descs now describe the orchestrator/reviewer roles (these
  feed the roster prompt agents route by).
- NEW ci.yml: bash -n + shellcheck(-S error) on every script, YAML-parse on
  every workflow, agents.json schema check — the ${x:-{}} brace bug would have
  been caught here before it shipped.
- install-opencode.sh: pin opencode (default 1.17.13, override via
  OPENCODE_VERSION) — a breaking release no longer takes down every agent.
- build-activity-log.sh: ollama/ollama-cloud models are subscription-billed
  (no $/token exists) — label the footer "subscription" instead of a
  misleading $0.0000; metered (anthropic) models keep the real dollar cost.
- route.sh: document that mention-priority is list-order and load-bearing for
  the flow's trigger comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Felix Faerber
2026-07-06 09:46:53 +03:00
co-authored by Claude Opus 4.8
parent 88237d3f4d
commit 09c716a8c1
9 changed files with 136 additions and 24 deletions
+8 -1
View File
@@ -6,8 +6,14 @@ on:
workflow_call:
jobs:
agent:
# One run at a time PER ISSUE: two quick comments on the same issue would otherwise race —
# both checking out ai/issue-N, pushing (non-fast-forward loss) and double-posting. Queued
# runs wait (no cancel) so every trigger is still processed, just serially.
concurrency:
group: ai-agent-${{ github.repository }}-${{ github.event.issue.number }}
cancel-in-progress: false
# Trusted author only, and only when a known agent is mentioned. This gate is the main
# defense against malicious-issue prompt injection — do not loosen it.
if: >
@@ -190,6 +196,7 @@ jobs:
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
MODE: ${{ steps.prep.outputs.mode }}
MODEL: ${{ steps.prep.outputs.model }} # ollama-cloud models are subscription-billed (no $/token)
run: bash "$SCRIPTS/build-activity-log.sh"
- name: Publish — PR (dev agents) or comment (pm), always reply in the issue
+56
View File
@@ -0,0 +1,56 @@
name: ci
run-name: "ci · ${{ github.event.pull_request.title || github.sha }}"
# Lint the very scripts every agent run executes. A single unchecked shell bug here breaks ALL
# agents in ALL repos at once (e.g. the ${VAR:-{}} brace bug shellcheck flags as SC1083/SC2321),
# so PRs must pass: bash -n + shellcheck on every script, YAML-parse on every workflow, and a
# schema check on agents.json (the routing registry).
on:
pull_request:
push:
branches: [main]
jobs:
lint:
runs-on: ci-runner
steps:
- uses: actions/checkout@v4
- name: Install linters
run: |
command -v shellcheck >/dev/null || (apt-get update -qq && apt-get install -y -qq shellcheck) || \
sudo sh -c 'apt-get update -qq && apt-get install -y -qq shellcheck' || true
python3 -c 'import yaml' 2>/dev/null || pip3 install --quiet pyyaml || \
(apt-get install -y -qq python3-yaml || sudo apt-get install -y -qq python3-yaml) || true
- name: bash -n (syntax) — every script
run: |
set -e
for f in .gitea/workflows/scripts/*.sh; do bash -n "$f" && echo "OK $f"; done
- name: shellcheck — every script
run: |
set -e
if command -v shellcheck >/dev/null; then
# error-severity only: the scripts intentionally use unquoted word-splitting in places;
# errors (real breakage like the ${x:-{}} brace bug) must fail the build.
shellcheck -S error .gitea/workflows/scripts/*.sh && echo "shellcheck clean (severity=error)"
else
echo "shellcheck unavailable on runner — skipped"
fi
- name: YAML-parse every workflow
run: |
set -e
python3 - <<'EOF'
import glob, sys, yaml
for f in sorted(glob.glob('.gitea/workflows/*.yml')):
yaml.safe_load(open(f))
print('OK', f)
EOF
- name: Validate agents.json (registry schema)
run: |
set -e
jq -e 'to_entries | all(.value | (.model|type=="string") and (.mode=="pr" or .mode=="comment")
and (.vision|type=="boolean") and (.skills|type=="array") and (.desc|type=="string"))' \
.gitea/workflows/scripts/agents.json >/dev/null && echo "agents.json OK"
+2 -2
View File
@@ -6,7 +6,7 @@
"skills": [
"gitea-api"
],
"desc": "Product manager — research, plan, ask clarifying questions, and decide which dev should do the work. Comments only; never edits files."
"desc": "Product manager & orchestrator — plans and picks the dev, hands each finished PR to @qa for review, and reports back to the issue creator (in autopilot it merges approved PRs itself). Works from the issue thread only — comments only, never edits files, never reads the PR diff."
},
"junior": {
"model": "ollama-cloud/kimi-k2.7-code:cloud",
@@ -40,7 +40,7 @@
"skills": [
"gitea-api"
],
"desc": "QA — verifies things work. Drives a headless browser (Playwright) to open a URL/web app, click through it, screenshot, and report bugs or confirm behavior. Comments findings; opens no PRs."
"desc": "QA / reviewer — reviews PRs: reads the diff, drives a headless browser (Playwright) to verify behavior, posts specific recommendations on the PR and the pass/fail verdict on the issue. Never edits code, never merges."
},
"ops": {
"model": "anthropic/claude-opus-4-8",
@@ -36,14 +36,20 @@ read -r COST INP OUT CR CW RE < <(jq -rs '
| @tsv' "$E" 2>/dev/null)
COST=${COST:-0}; INP=${INP:-0}; OUT=${OUT:-0}; CR=${CR:-0}; CW=${CW:-0}; RE=${RE:-0}
IN_TOTAL=$(( INP + CR + CW )) # total input context processed
COSTF=$(awk -v c="$COST" 'BEGIN{printf "$%.4f", c+0}')
# Cost label: ollama / ollama-cloud models are SUBSCRIPTION-billed (GPU-time against the plan, no
# $/token price exists), so a "$0.0000" there would be misleading — label it a subscription instead.
# Metered providers (anthropic/…) get the real dollar cost opencode computed.
case "${MODEL:-}" in
ollama*|*"/ollama"*) COSTF="subscription" ;;
*) COSTF=$(awk -v c="$COST" 'BEGIN{printf "$%.4f", c+0}') ;;
esac
echo "usage: in=$IN_TOTAL out=$OUT cost=$COSTF (fresh=$INP cache_r=$CR cache_w=$CW reasoning=$RE); tools=$n"
{
if [ "$n" -gt 0 ]; then
printf '\n\n<details>\n<summary>🔧 %s tool calls · in %s · out %s · %s</summary>\n\n' "$n" "$IN_TOTAL" "$OUT" "$COSTF"
cat /tmp/tools.md
printf '\n\n<sub>tokens — input %s (fresh %s · cache %sw / %sr) · output %s · reasoning %s · **cost %s**</sub>\n</details>' \
printf '\n\n<sub>tokens — input %s (fresh %s · cache %sw / %sr) · output %s · reasoning %s · **%s**</sub>\n</details>' \
"$IN_TOTAL" "$INP" "$CW" "$CR" "$OUT" "$RE" "$COSTF"
else
printf '\n\n<sub>💰 **%s** · in %s · out %s tokens (cache %sw / %sr)</sub>' "$COSTF" "$IN_TOTAL" "$OUT" "$CW" "$CR"
+7 -5
View File
@@ -1,5 +1,8 @@
#!/usr/bin/env bash
# Fetch the full issue thread (shared memory) into /tmp/thread.md.
# Agents post as their OWN Gitea users, so .user.login IS the agent name — attribute each comment
# to its real author (@pm/@qa/@junior/…). Strip the hidden `<!-- 🤖 … -->` loop-prevention marker
# from bodies — it's plumbing, not conversation, and would just waste prompt tokens.
#
# Required env (provided by the workflow step): GT NUM GITHUB_SERVER_URL GITHUB_REPOSITORY
set -eu
@@ -7,9 +10,8 @@ set -eu
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
curl -sS -H "Authorization: token $GT" "$API/issues/$NUM/comments?limit=100" 2>/dev/null \
| jq -r '.[] |
( if (.body | test("delegated by")) then "an automated delegation"
elif (.user.login == "ffaerber") then "ffaerber (the maintainer / you)"
else "an AI teammate — the specific one is named in the 🤖 @name line at the top of the comment"
end ) as $who |
"### comment by \($who):\n\(.body)\n"' > /tmp/thread.md 2>/dev/null || true
( if (.user.login == "ffaerber") then "@ffaerber (the maintainer)"
else "@" + .user.login end ) as $who |
"### comment by \($who):\n\(.body | gsub("\\s*<!-- 🤖 agent reply — do not trigger -->"; ""))\n"' \
> /tmp/thread.md 2>/dev/null || true
echo "thread comments fetched: $(grep -c '^### comment by ' /tmp/thread.md 2>/dev/null || echo 0)"
+5 -1
View File
@@ -5,7 +5,11 @@
# GITHUB_PATH HOME
set -eu
curl -fsSL https://opencode.ai/install | bash
# PIN the opencode version: an unpinned `latest` means a breaking release (CLI flags, or the
# --format json event schema that build-activity-log.sh parses) breaks every agent in every repo
# at once. Bump deliberately by changing this default (or set OPENCODE_VERSION in the step env).
OPENCODE_VERSION="${OPENCODE_VERSION:-1.17.13}"
curl -fsSL https://opencode.ai/install | bash -s -- --version "$OPENCODE_VERSION"
echo "$HOME/.opencode/bin" >> "$GITHUB_PATH"
mkdir -p ~/.config/opencode
# Playwright browser MCP only for agents that need to drive a web app
+18 -4
View File
@@ -6,7 +6,7 @@
# the run. Comment-only roles (pm/qa) push nothing, so they are skipped.
#
# Required env (provided by the workflow step):
# GT TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA
# GT TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA TOKEN_OPS
# NAME MODE NUM TITLE BRANCH GITHUB_SERVER_URL GITHUB_REPOSITORY
set +e
@@ -17,9 +17,11 @@ set +e
# Post/PR as the agent's OWN Gitea user when its token is configured; else the built-in bot.
case "$NAME" in
pm) TOK="$TOKEN_PM";; senior) TOK="$TOKEN_SENIOR";; junior) TOK="$TOKEN_JUNIOR";;
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; *) TOK="";;
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; *) TOK="";;
esac
[ -z "$TOK" ] && TOK="$GT"
# Trigger token: the @pm hand-back below must FIRE a new run, which the built-in token cannot.
TTOK="$TOK"; [ "$TTOK" = "$GT" ] && TTOK=""
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
hdr=(-H "Authorization: token $TOK" -H "Content-Type: application/json")
@@ -38,18 +40,30 @@ resp=$(curl -sS "${hdr[@]}" "$API/pulls?state=open&limit=50" \
| jq -r --arg br "$BRANCH" 'if type=="array" then (map(select(.head.ref==$br)) | .[0] // empty) else empty end' 2>/dev/null)
url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null)
if [ -z "$url" ]; then
body=$(printf 'The run failed before it could publish, but pushed work exists on this branch — opening a PR so it is not lost.\n\n---\nResolves #%s · 🤖 @%s (auto-rescued after a failed run)' "$NUM" "$NAME")
body=$(printf 'The run failed before it could publish, but pushed work exists on this branch — opening a PR so it is not lost.\n\n---\nResolves #%s (auto-rescued after a failed run)' "$NUM")
resp=$(curl -sS -X POST "${hdr[@]}" "$API/pulls" \
-d "$(jq -nc --arg t "@$NAME: $TITLE" --arg h "$BRANCH" --arg b "$body" \
'{title:$t, head:$h, base:"main", body:$b}')")
echo "rescue PR create ($BRANCH): $resp"
url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null)
prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null)
else
prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null)
fi
if [ -n "$url" ]; then
# Status note on the issue (marked — must not trigger)…
curl -sS -X POST "${hdr[@]}" "$API/issues/$NUM/comments" \
-d "$(jq -nc --arg b "$(printf '🤖 **@%s** — ⚠️ the run failed, but your pushed work was not lost — a PR was opened for branch \`%s\`:\n- %s' "$NAME" "$BRANCH" "$url")" '{body:$b}')" \
-d "$(jq -nc --arg b "$(printf '⚠️ The run failed, but the pushed work was not lost — a PR was opened for branch \`%s\`:\n- %s\n\n<!-- 🤖 agent reply — do not trigger -->' "$BRANCH" "$url")" '{body:$b}')" \
-w '\nrescue comment -> HTTP %{http_code}\n' || true
# …then hand the rescued PR back into the flow: without this, the pm→qa choreography would stall
# here (the normal "PR ready" trigger never fired). Unmarked + PAT so it starts @pm's run.
if [ -n "$TTOK" ]; then
curl -sS -X POST -H "Authorization: token $TTOK" -H "Content-Type: application/json" \
"$API/issues/$NUM/comments" \
-d "$(jq -nc --arg b "@pm — PR #${prnum:-?} was auto-rescued after a failed run (issue #$NUM). Please route it for review." '{body:$b}')" \
-w '\nrescue trigger @pm -> HTTP %{http_code}\n' || true
fi
else
echo "rescue: could not open/find a PR for $BRANCH"
fi
+4
View File
@@ -27,6 +27,10 @@ cp "$AGENTS_JSON" /tmp/agents.json
# here — see agent.yml: this reusable workflow sees it as 'workflow_call'.)
if [ -n "$CID" ]; then scan="$BODY"; else scan="$IBODY"; fi
name=""
# FIRST MATCH IN THIS LIST ORDER WINS when a comment mentions several agents. The order is
# load-bearing for the flow's trigger comments: "@pm — @qa approved …" must route to @pm (pm is
# checked first), while "@junior please address @qa's review …" must route to the dev (devs are
# checked before qa). If you add an agent or reword a trigger in publish.sh, re-check this order.
for a in pm junior senior lead qa ops; do
case "$scan" in *"@$a"*) name=$a; break;; esac
done