From e9df9d6bddb692f76fb4a563ab3afd3bf97dc715 Mon Sep 17 00:00:00 2001 From: Felix Faerber Date: Mon, 6 Jul 2026 20:11:45 +0300 Subject: [PATCH] =?UTF-8?q?agents:=20review=20round=202=20=E2=80=94=20spli?= =?UTF-8?q?t-PR=20flow,=20thread=20recency,=20mention=20boundaries,=20lear?= =?UTF-8?q?nings=20cap,=20pm=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - publish.sh: resolve_prs matches ai/issue-N AND ai/issue-N- split branches (exact-only silently stalled DELEGATE:@qa and autopilot MERGE_PR on slugged work); qa hand-off lists all open PRs; autopilot refuses to merge multi-PR issues (drops the label, hands to the human) — merging one of several open PRs is half a change deployed. Retro's state=all resolve matches slugs too. - fetch-thread.sh: paginate and keep the NEWEST 100 comments — a bare limit=100 kept the OLDEST page and dropped recent decisions on long threads. - route.sh: word-boundary mention matching ("@internal" no longer routes to @intern); a comment with no real agent mention now SKIPS gracefully (mode=skip outputs + step guards in agent.yml) instead of a red run. - run-agent.sh: LEARNINGS.md cap is line-aware and keeps the NEWEST entries (retros append at the bottom; the old head -c cut the latest lessons first). - agents.json/README: @pm gemma4 -> minimax-m3 — the retro rule demands an investigating PM, and gemma4 posted 0 tool calls on every run; minimax is the model @qa demonstrably uses tools with. - agent.yml: document the PR-thread concurrency-group caveat. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/agent.yml | 15 +++++++++++++- .gitea/workflows/scripts/agents.json | 2 +- .gitea/workflows/scripts/fetch-thread.sh | 25 +++++++++++++++++------- .gitea/workflows/scripts/publish.sh | 24 +++++++++++++++++------ .gitea/workflows/scripts/route.sh | 15 ++++++++++++-- .gitea/workflows/scripts/run-agent.sh | 12 +++++++++++- README.md | 2 +- 7 files changed, 76 insertions(+), 19 deletions(-) diff --git a/.gitea/workflows/agent.yml b/.gitea/workflows/agent.yml index 0345229..8d87a66 100644 --- a/.gitea/workflows/agent.yml +++ b/.gitea/workflows/agent.yml @@ -11,6 +11,10 @@ jobs: # 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. + # KNOWN CAVEAT: a run triggered on the PR thread groups under the PR number, not the origin + # issue (that mapping is only resolved later, in route.sh) — so an issue-thread run and a + # PR-thread run for the SAME work item can overlap. Accepted: they post to different threads, + # and the branch is only mutated by dev runs, which resume serially per thread. concurrency: group: ai-agent-${{ github.repository }}-${{ github.event.issue.number }} cancel-in-progress: false @@ -116,6 +120,7 @@ jobs: run: bash "$SCRIPTS/route.sh" - name: Install opencode + provider config (+ Playwright MCP for browser agents) + if: steps.prep.outputs.mode != 'skip' env: SCRIPTS: ${{ runner.temp }}/agents-scripts OLLAMA_URL: ${{ secrets.OLLAMA_URL }} @@ -125,6 +130,7 @@ jobs: run: bash "$SCRIPTS/install-opencode.sh" - name: Install caller-provided skills (from the caller repo's .gitea/agent-skills/) + if: steps.prep.outputs.mode != 'skip' # Framework skill-plugin hook. A consuming repo can ship its OWN opencode skills under # `.gitea/agent-skills//` (SKILL.md + skill.json + optional setup.sh) — e.g. homelab's # "ssh into the deploy host" skill. This installs the ones allowed for the running agent, so @@ -139,6 +145,7 @@ jobs: run: bash "$SCRIPTS/install-caller-skills.sh" - name: Set up `gitea-api` skill (let agents read/write issues, PRs, Actions across repos) + if: steps.prep.outputs.mode != 'skip' # Emits an opencode Skill file. The skill uses SELF_TOKEN — the running agent's OWN token # (e.g. TOKEN_PM for @pm), injected into the Run-agent step below — so each agent talks to # Gitea as itself. This step only writes the doc; permission.skill scopes who may load it. @@ -147,6 +154,7 @@ jobs: run: bash "$SCRIPTS/skill-gitea-api.sh" - name: Set up `gitea-admin` skill (@ops only — administer the Gitea instance) + if: steps.prep.outputs.mode != 'skip' # Instance administration (orgs/users/repos/labels/secrets/scoped tokens). The SKILL.md is # written ONLY for @ops (skill-gitea-admin.sh gates on NAME) and permission.skill also denies # it to every other agent. It uses SELF_TOKEN (which for @ops is TOKEN_OPS), injected into the @@ -157,6 +165,7 @@ jobs: run: bash "$SCRIPTS/skill-gitea-admin.sh" - name: Inspect / fetch image attachments (download only for vision agents) + if: steps.prep.outputs.mode != 'skip' id: imgs env: SCRIPTS: ${{ runner.temp }}/agents-scripts @@ -166,6 +175,7 @@ jobs: run: bash "$SCRIPTS/fetch-images.sh" - name: Fetch the full issue thread (shared memory) + if: steps.prep.outputs.mode != 'skip' env: SCRIPTS: ${{ runner.temp }}/agents-scripts GT: ${{ secrets.GITEA_TOKEN }} @@ -173,6 +183,7 @@ jobs: run: bash "$SCRIPTS/fetch-thread.sh" - name: Run agent + if: steps.prep.outputs.mode != 'skip' id: run env: SCRIPTS: ${{ runner.temp }}/agents-scripts @@ -198,6 +209,7 @@ jobs: run: bash "$SCRIPTS/run-agent.sh" - name: Build run report (tool calls + input/output tokens + $ cost) from the event stream + if: steps.prep.outputs.mode != 'skip' id: log env: SCRIPTS: ${{ runner.temp }}/agents-scripts @@ -206,6 +218,7 @@ jobs: run: bash "$SCRIPTS/build-activity-log.sh" - name: Publish — PR (dev agents) or comment (pm), always reply in the issue + if: steps.prep.outputs.mode != 'skip' env: SCRIPTS: ${{ runner.temp }}/agents-scripts GT: ${{ secrets.GITEA_TOKEN }} @@ -232,7 +245,7 @@ jobs: # This best-effort step opens a PR for the pushed branch so nothing is silently lost. Runs from # $SCRIPTS (outside the workspace) so it works even if the tree was mangled by the agent. - name: Rescue — open a PR for pushed work if the run failed - if: failure() + if: failure() && steps.prep.outputs.mode != 'skip' env: SCRIPTS: ${{ runner.temp }}/agents-scripts GT: ${{ secrets.GITEA_TOKEN }} diff --git a/.gitea/workflows/scripts/agents.json b/.gitea/workflows/scripts/agents.json index 9cff416..38fb05c 100644 --- a/.gitea/workflows/scripts/agents.json +++ b/.gitea/workflows/scripts/agents.json @@ -1,6 +1,6 @@ { "pm": { - "model": "ollama-cloud/gemma4:cloud", + "model": "ollama-cloud/minimax-m3:cloud", "vision": true, "mode": "comment", "skills": [ diff --git a/.gitea/workflows/scripts/fetch-thread.sh b/.gitea/workflows/scripts/fetch-thread.sh index 99549ef..0956bb3 100755 --- a/.gitea/workflows/scripts/fetch-thread.sh +++ b/.gitea/workflows/scripts/fetch-thread.sh @@ -4,14 +4,25 @@ # 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. # +# PAGINATION: Gitea returns comments ASCENDING and `limit` caps a single page — a bare ?limit=100 +# used to keep the OLDEST 100 comments and silently drop the newest (the exact opposite of what an +# agent needs on a long thread). Fetch all pages (up to 10 = 500 comments) and keep the LAST 100. +# # Required env (provided by the workflow step): GT NUM GITHUB_SERVER_URL GITHUB_REPOSITORY 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 (.user.login == "ffaerber") then "@ffaerber (the maintainer)" - else "@" + .user.login end ) as $who | - "### comment by \($who):\n\(.body | gsub("\\s*"; ""))\n"' \ - > /tmp/thread.md 2>/dev/null || true -echo "thread comments fetched: $(grep -c '^### comment by ' /tmp/thread.md 2>/dev/null || echo 0)" +: > /tmp/thread_pages.json +for page in $(seq 1 10); do + pg=$(curl -sS -H "Authorization: token $GT" "$API/issues/$NUM/comments?limit=50&page=$page" 2>/dev/null) || pg='[]' + n=$(printf '%s' "$pg" | jq 'if type=="array" then length else 0 end' 2>/dev/null || echo 0) + [ "${n:-0}" -gt 0 ] && printf '%s\n' "$pg" >> /tmp/thread_pages.json + [ "${n:-0}" -lt 50 ] && break +done +jq -rs ' + add // [] | .[-100:] | .[] | + ( if (.user.login == "ffaerber") then "@ffaerber (the maintainer)" + else "@" + .user.login end ) as $who | + "### comment by \($who):\n\(.body | gsub("\\s*"; ""))\n"' \ + /tmp/thread_pages.json > /tmp/thread.md 2>/dev/null || : > /tmp/thread.md +echo "thread comments fetched: $(grep -c '^### comment by ' /tmp/thread.md 2>/dev/null || echo 0) (newest 100 kept)" diff --git a/.gitea/workflows/scripts/publish.sh b/.gitea/workflows/scripts/publish.sh index 8c9bc9b..c7cff0d 100755 --- a/.gitea/workflows/scripts/publish.sh +++ b/.gitea/workflows/scripts/publish.sh @@ -44,8 +44,14 @@ trig() { if [ -z "$TTOK" ]; then echo "no trigger token — cannot fire on #$1"; # Origin issue for this run (route.sh resolves it from the branch on PR threads), and a resolver for # the open PR built from its branch (ai/issue-). Lets @pm/@qa cross between the issue and PR. ISSN="${ISSNUM:-$NUM}" -resolve_pr() { curl -sS "${hdr[@]}" "$API/pulls?state=open&limit=50" \ - | jq -r --arg br "ai/issue-$ISSN" 'if type=="array" then (map(select(.head.ref==$br))|.[0].number // empty) else empty end' 2>/dev/null; } +# All OPEN PRs belonging to this issue, oldest→newest. Matches ai/issue-N AND the ai/issue-N- +# split branches AGENTS.md tells devs to use — an exact-only match silently stalled the flow on +# slugged branches (DELEGATE:@qa found "no open PR"; autopilot MERGE_PR couldn't merge). +resolve_prs() { curl -sS "${hdr[@]}" "$API/pulls?state=open&limit=50" \ + | jq -r --arg br "ai/issue-$ISSN" 'if type=="array" then + ([ .[] | select(.head.ref==$br or (.head.ref|startswith($br+"-"))) | .number ] | sort | join(" ")) + else "" end' 2>/dev/null; } +resolve_pr() { resolve_prs | awk '{print $NF}'; } # newest open PR (empty if none) # Remove the 'autopilot' label from an issue by resolving its ID first (Gitea's DELETE label # endpoint is by ID, not name). Arg $1 = issue number. Used as the autopilot kill switch. del_autopilot_label() { @@ -194,9 +200,13 @@ if [ "$MODE" != "pr" ]; then # (@qa never merges — it approves and hands back here.) Merge with the PAT (TTOK), not the built-in # token, so the push to main fires the deploy. TOKEN_PM must carry write:repository. if [ "$NAME" = "pm" ] && [ "$AUTOPILOT" = "true" ] && grep -qiE '^[[:space:]]*MERGE_PR[[:space:]]*$' /tmp/agent_out.md; then - PRN=$(resolve_pr) + PRS=$(resolve_prs); PRN=${PRS##* }; CNT=$(echo "$PRS" | wc -w) if [ -z "$PRN" ]; then echo "MERGE_PR but no open PR found for issue #$ISSN" + elif [ "$CNT" -gt 1 ]; then + # Split-PR work: auto-merging just one of several open PRs is half a change deployed. + del_autopilot_label "$ISSN" + post_to "$ISSN" "⚠️ This issue has $CNT open PRs (#${PRS// /, #}) — autopilot only merges single-PR work. Removed the autopilot label; @ffaerber please review and merge them in order." else echo "@pm autopilot: merging PR #$PRN (issue #$ISSN)" mc=$(curl -sS -o /tmp/merge_resp.txt -w '%{http_code}' -X POST \ @@ -221,7 +231,7 @@ if [ "$MODE" != "pr" ]; then # pm → qa → merge), and run-agent.sh injects LEARNINGS.md into every future prompt — closing the loop. if [ "$NAME" = "pm" ] && grep -qiE '^[[:space:]]*RETRO[[:space:]]*$' /tmp/agent_out.md; then PRN=$(curl -sS "${hdr[@]}" "$API/pulls?state=all&limit=50" \ - | jq -r --arg br "ai/issue-$ISSN" 'if type=="array" then ([.[]|select(.head.ref==$br)] | sort_by(.number) | last | .number // empty) else empty end' 2>/dev/null) + | jq -r --arg br "ai/issue-$ISSN" 'if type=="array" then ([.[]|select(.head.ref==$br or (.head.ref|startswith($br+"-")))] | sort_by(.number) | last | .number // empty) else empty end' 2>/dev/null) rbody=$(printf 'Retrospective for issue #%s%s.\n\nRead the FULL issue thread%s using the gitea-api skill (issue comments%s and the PR diff). Identify what went wrong, slow, or needed human correction — missed wiring, review misses, bounced rounds, unclear delegation, missing context.\n\nThen APPEND the distilled learnings to `LEARNINGS.md` at the repo root (create it with a short header if missing). Rules for entries:\n- 3 to 6 bullets max, each ONE line: `symptom -> rule for next time`.\n- Concrete and checkable (name the file/step/marker), not generic advice.\n- Do not repeat an existing bullet; refine it instead.\n- Do not rewrite unrelated parts of the file.\n\nThese learnings are injected into every future agent prompt, so quality over quantity.' \ "$ISSN" "${PRN:+ / PR #$PRN}" "${PRN:+ and PR #$PRN thread}" "${PRN:+, PR comments}") rnum=$(curl -sS -X POST "${hdr[@]}" "$API/issues" \ @@ -242,8 +252,10 @@ if [ "$MODE" != "pr" ]; then # the PR. Chain terminates: normal → @pm tells the creator (no marker); autopilot → @pm merges above. if [ -n "$target" ] && [ "$target" != "$NAME" ]; then if [ "$target" = "qa" ]; then - PRN=$(resolve_pr) - if [ -n "$PRN" ]; then + PRS=$(resolve_prs); PRN=${PRS##* }; CNT=$(echo "$PRS" | wc -w) + if [ -n "$PRN" ] && [ "$CNT" -gt 1 ]; then + trig "$ISSN" "@qa please review the $CNT open PRs for issue #$ISSN (#${PRS// /, #}) — put your recommendations on each PR; approve only when ALL are good." + elif [ -n "$PRN" ]; then trig "$ISSN" "@qa please review PR #$PRN for issue #$ISSN — put your recommendations on the PR, or approve." else echo "DELEGATE:@qa but no open PR yet for issue #$ISSN — not firing" diff --git a/.gitea/workflows/scripts/route.sh b/.gitea/workflows/scripts/route.sh index f96cb5a..75dc430 100755 --- a/.gitea/workflows/scripts/route.sh +++ b/.gitea/workflows/scripts/route.sh @@ -31,11 +31,22 @@ name="" # 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. +# WORD-BOUNDARY match, not substring: "@internal" or "x@internet.com" must NOT route to @intern +# (the workflow gate can only do contains(), so this is where its false positives get filtered). for a in pm junior senior lead qa ops intern; do - case "$scan" in *"@$a"*) name=$a; break;; esac + if printf '%s' "$scan" | grep -qE "(^|[^[:alnum:]_])@$a([^[:alnum:]_-]|\$)"; then name=$a; break; fi done if [ -z "$name" ]; then - if [ -z "$CID" ]; then name=pm; else echo "no known agent mentioned"; exit 1; fi + if [ -z "$CID" ]; then + name=pm + else + # Not an agent task (e.g. the gate's contains() matched "@internal"). Skip GRACEFULLY: emit + # mode=skip so every later step no-ops — a red run for a non-agent comment is just noise. + echo "no known agent mentioned (word-boundary) — skipping run" + { echo "name=none"; echo "model=none"; echo "vision=false"; echo "mode=skip"; echo "skills=[]"; + echo "branch=main"; echo "new=false"; echo "autopilot=false"; echo "issnum=$NUM"; } >> "$GITHUB_OUTPUT" + exit 0 + fi fi model=$(jq -r --arg a "$name" '.[$a].model' /tmp/agents.json) vision=$(jq -r --arg a "$name" '.[$a].vision' /tmp/agents.json) diff --git a/.gitea/workflows/scripts/run-agent.sh b/.gitea/workflows/scripts/run-agent.sh index 993adc6..3b19183 100755 --- a/.gitea/workflows/scripts/run-agent.sh +++ b/.gitea/workflows/scripts/run-agent.sh @@ -109,8 +109,18 @@ fi # TEAM LEARNINGS — distilled from past retros (see the RETRO flow in publish.sh). Lives at the # CALLER repo root as LEARNINGS.md, maintained by retro PRs. Injected into EVERY agent's prompt # (capped) so past mistakes actually change future behavior — this is the feedback loop. +# Cap is LINE-aware and keeps the NEWEST entries: retros append at the bottom, so a byte-cap from +# the top would silently drop the latest lessons first (and cut mid-bullet). LEARN="" -[ -s LEARNINGS.md ] && LEARN=$(head -c 4000 LEARNINGS.md) +if [ -s LEARNINGS.md ]; then + if [ "$(wc -l < LEARNINGS.md)" -gt 80 ]; then + LEARN=$(printf '%s\n_(older learnings truncated — full list in LEARNINGS.md)_\n%s' \ + "$(head -n 3 LEARNINGS.md)" "$(tail -n 70 LEARNINGS.md)") + else + LEARN=$(cat LEARNINGS.md) + fi + LEARN=$(printf '%s' "$LEARN" | head -c 8000) +fi [ -n "$LEARN" ] && LEARN=" TEAM LEARNINGS (distilled from past retros in this repo — APPLY them; they exist because a previous task went wrong without them): diff --git a/README.md b/README.md index bb4aafe..5696fc7 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Shared **AI dev-team** workflow for Gitea Actions, reusable across repos. It giv | Agent | Model | Vision | Mode | Skills | Role | |-------|-------|:------:|------|--------|------| -| `@pm` | `ollama-cloud/gemma4:cloud` | yes | comment | `gitea-api` | Product manager & orchestrator — plans, picks the dev, hands finished PRs to `@qa`, reports back to the issue creator (autopilot: merges approved PRs itself). Issue thread only; never edits files, never reads the PR diff. | +| `@pm` | `ollama-cloud/minimax-m3:cloud` | yes | comment | `gitea-api` | Product manager & orchestrator — plans, picks the dev, hands finished PRs to `@qa`, reports back to the issue creator (autopilot: merges approved PRs itself). Issue thread only; never edits files, never reads the PR diff. | | `@junior` | `ollama-cloud/kimi-k2.7-code:cloud` | no | pr | — | Junior dev — small, low-risk changes (mostly YAML/compose/config). Text-only, cannot read images. Defers complex or image tasks to `@senior` or `@lead`. | | `@senior` | `ollama-cloud/glm-5.2:cloud` | no | pr | `gitea-api` | Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only). | | `@lead` | `anthropic/claude-opus-4-8` | yes | pr | `gitea-api` | Tech lead — the hardest problems, architecture, and final calls. | -- 2.54.0