From 423a0608163884e6789d5b1338ff7b8c5d6c00cc Mon Sep 17 00:00:00 2001 From: lead Date: Sat, 4 Jul 2026 07:34:53 +0000 Subject: [PATCH] fix(agent): run step scripts from outside the workspace so agents can't break the run Stage the shared scripts into $RUNNER_TEMP and point $SCRIPTS there for every step, so an agent that commits/deletes the in-tree .agents-workflow checkout no longer destroys the scripts the post-agent steps run (issue #33). Scrub any in-tree .agents-workflow artifact before publishing, and add a failure-safe rescue step that opens a PR for pushed work when a run fails. --- .gitea/workflows/agent.yml | 62 +++++++++++++++++++++++---- .gitea/workflows/scripts/publish.sh | 12 ++++++ .gitea/workflows/scripts/rescue-pr.sh | 56 ++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 9 deletions(-) create mode 100755 .gitea/workflows/scripts/rescue-pr.sh diff --git a/.gitea/workflows/agent.yml b/.gitea/workflows/agent.yml index 0d41bc1..c210a1a 100644 --- a/.gitea/workflows/agent.yml +++ b/.gitea/workflows/agent.yml @@ -42,6 +42,15 @@ jobs: # .gitea/workflows/scripts/) are NOT on disk yet. Check this repo out into a separate subdir # and run the scripts from $SCRIPTS. Pinned to @main to match the caller's # `uses: …/agent.yml@main`, so the scripts and the workflow always move together. + # + # actions/checkout requires `path` to be inside the workspace, so this necessarily lands the + # clone at `.agents-workflow/` INSIDE the tree the dev agent later edits. That checkout dir is + # untracked and looks like a stray artifact: an agent that commits it as a gitlink or + # `rm -rf`s it as "leftover" would destroy the very scripts the post-agent steps run, breaking + # the run with exit 127 and stranding pushed work with no PR (issue #33). To make the run + # immune, the next step copies the scripts to a stable location OUTSIDE the workspace + # (${{ runner.temp }}) and every later step runs from $SCRIPTS there — so nothing the agent + # does to the working tree can break the run's own execution environment. - name: Fetch shared agent scripts (this repo) uses: actions/checkout@v4 with: @@ -50,10 +59,24 @@ jobs: path: .agents-workflow token: ${{ secrets.GITEA_TOKEN }} + # Copy the step scripts out of the workspace so the agent cannot break them (issue #33). + # $SCRIPTS points here for every subsequent step, NOT into the in-tree .agents-workflow/. + - name: Stage shared scripts outside the workspace + env: + SRC: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts + DST: ${{ runner.temp }}/agents-scripts + run: | + set -eu + rm -rf "$DST" + mkdir -p "$DST" + cp -a "$SRC"/. "$DST"/ + chmod -R a+rx "$DST" || true + echo "staged $(ls -1 "$DST" | wc -l) scripts at $DST" + - name: Route agent + prepare branch id: prep env: - SCRIPTS: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts + SCRIPTS: ${{ runner.temp }}/agents-scripts BODY: ${{ github.event.comment.body }} # event text via env, never inline in shell IBODY: ${{ github.event.issue.body }} # Comment-vs-issue discriminator. Do NOT use github.event_name here: this is a REUSABLE @@ -73,7 +96,7 @@ jobs: - name: Install opencode + provider config (+ Playwright MCP for browser agents) env: - SCRIPTS: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts + SCRIPTS: ${{ runner.temp }}/agents-scripts OLLAMA_URL: ${{ secrets.OLLAMA_URL }} OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }} NAME: ${{ steps.prep.outputs.name }} @@ -90,7 +113,7 @@ jobs: # All three secrets are passed via env and never inlined into shell — this shared workflow # runs in repos that don't have them and must not fail there. env: - SCRIPTS: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts + SCRIPTS: ${{ runner.temp }}/agents-scripts SWARM_HOST: ${{ secrets.SWARM_HOST }} SWARM_USER: ${{ secrets.SWARM_USER }} SSH_PRIV_KEY: ${{ secrets.SSH_PRIV_KEY }} @@ -104,14 +127,14 @@ jobs: # Only emitted when AGENT_TOKEN is actually present, so repos without it don't get a # broken skill. The token is passed via env and never inlined into shell. env: - SCRIPTS: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts + SCRIPTS: ${{ runner.temp }}/agents-scripts AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }} run: bash "$SCRIPTS/skill-gitea-api.sh" - name: Inspect / fetch image attachments (download only for vision agents) id: imgs env: - SCRIPTS: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts + SCRIPTS: ${{ runner.temp }}/agents-scripts GT: ${{ secrets.GITEA_TOKEN }} NUM: ${{ github.event.issue.number }} VISION: ${{ steps.prep.outputs.vision }} @@ -119,7 +142,7 @@ jobs: - name: Fetch the full issue thread (shared memory) env: - SCRIPTS: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts + SCRIPTS: ${{ runner.temp }}/agents-scripts GT: ${{ secrets.GITEA_TOKEN }} NUM: ${{ github.event.issue.number }} run: bash "$SCRIPTS/fetch-thread.sh" @@ -127,7 +150,7 @@ jobs: - name: Run agent id: run env: - SCRIPTS: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts + SCRIPTS: ${{ runner.temp }}/agents-scripts ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # AGENT_TOKEN powers the `gitea-api` skill (cross-repo issue/PR/Actions read+write). # It is already a required secret for the delegation step below; exposing it here too @@ -149,13 +172,13 @@ jobs: - name: Build activity log (tool calls + reasoning) from the event stream id: log env: - SCRIPTS: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts + SCRIPTS: ${{ runner.temp }}/agents-scripts MODE: ${{ steps.prep.outputs.mode }} run: bash "$SCRIPTS/build-activity-log.sh" - name: Publish — PR (dev agents) or comment (pm), always reply in the issue env: - SCRIPTS: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts + SCRIPTS: ${{ runner.temp }}/agents-scripts GT: ${{ secrets.GITEA_TOKEN }} AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }} TOKEN_PM: ${{ secrets.TOKEN_PM }} @@ -171,6 +194,27 @@ jobs: NEW: ${{ steps.prep.outputs.new }} run: bash "$SCRIPTS/publish.sh" + # Failure-safe: if any step above failed AFTER a dev agent already pushed commits, the normal + # Publish step never ran, so the work would be stranded on the branch with no PR (issue #33). + # 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() + env: + SCRIPTS: ${{ runner.temp }}/agents-scripts + GT: ${{ secrets.GITEA_TOKEN }} + TOKEN_PM: ${{ secrets.TOKEN_PM }} + TOKEN_SENIOR: ${{ secrets.TOKEN_SENIOR }} + TOKEN_JUNIOR: ${{ secrets.TOKEN_JUNIOR }} + TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }} + TOKEN_QA: ${{ secrets.TOKEN_QA }} + NAME: ${{ steps.prep.outputs.name }} + MODE: ${{ steps.prep.outputs.mode }} + NUM: ${{ github.event.issue.number }} + TITLE: ${{ github.event.issue.title }} + BRANCH: ${{ steps.prep.outputs.branch }} + run: bash "$SCRIPTS/rescue-pr.sh" || true + - name: Mark done with 🚀 (remove 👀) env: GT: ${{ secrets.GITEA_TOKEN }} diff --git a/.gitea/workflows/scripts/publish.sh b/.gitea/workflows/scripts/publish.sh index 17e1850..689089e 100755 --- a/.gitea/workflows/scripts/publish.sh +++ b/.gitea/workflows/scripts/publish.sh @@ -98,6 +98,18 @@ if [ "$MODE" != "pr" ]; then exit 0 fi +# Scrub the runtime scripts checkout (.agents-workflow) from the tree so it never lands in a +# commit/PR and never confuses the git ops below (issue #33). The scripts we run live outside the +# workspace ($SCRIPTS -> runner.temp), so removing this in-tree copy is always safe. Handle every +# way an agent might have left it: untracked dir, tracked files, or a committed gitlink/submodule. +if git ls-files --error-unmatch .agents-workflow >/dev/null 2>&1 || \ + [ -n "$(git ls-files .agents-workflow 2>/dev/null)" ]; then + git rm -r --cached --quiet --ignore-unmatch .agents-workflow 2>/dev/null || true +fi +git config -f .gitmodules --remove-section submodule..agents-workflow 2>/dev/null || true +[ -s .gitmodules ] || rm -f .gitmodules 2>/dev/null || true +rm -rf .agents-workflow 2>/dev/null || true + # The agent may have committed on the starting branch AND/OR created extra # ai/issue-N- branches. Commit any leftover on the current branch, push it, then # open a PR for EVERY ai/issue-N* branch that has commits beyond main. diff --git a/.gitea/workflows/scripts/rescue-pr.sh b/.gitea/workflows/scripts/rescue-pr.sh new file mode 100755 index 0000000..2579696 --- /dev/null +++ b/.gitea/workflows/scripts/rescue-pr.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Failure-safe rescue: when a run FAILED after a dev agent already pushed commits, the normal +# Publish step never ran and the work would be stranded on the branch with no PR (issue #33). +# This opens a PR for the pushed branch so nothing is silently lost. It is strictly best-effort: +# every failure here is swallowed (the caller also appends `|| true`) so it can never itself break +# 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 +# NAME MODE NUM TITLE BRANCH GITHUB_SERVER_URL GITHUB_REPOSITORY +set +e + +# Only dev agents (mode=pr) ever push a branch to rescue. +[ "${MODE:-}" = "pr" ] || { echo "rescue: comment-mode agent, nothing to rescue"; exit 0; } +[ -n "${BRANCH:-}" ] || { echo "rescue: no branch known, skipping"; exit 0; } + +# 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="";; +esac +[ -z "$TOK" ] && TOK="$GT" +API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" +hdr=(-H "Authorization: token $TOK" -H "Content-Type: application/json") + +git fetch -q origin 2>/dev/null || true + +# Nothing to rescue unless the branch exists on the remote with commits beyond main. +ahead=$(git rev-list --count "origin/main..origin/$BRANCH" 2>/dev/null || echo 0) +if [ "${ahead:-0}" -eq 0 ]; then + echo "rescue: no pushed commits on origin/$BRANCH beyond main — nothing to rescue" + exit 0 +fi +echo "rescue: origin/$BRANCH is $ahead commit(s) ahead of main — ensuring a PR exists" + +# Idempotent: Gitea ignores ?head=, so match the head branch client-side. +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") + 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) +fi + +if [ -n "$url" ]; then + 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}')" \ + -w '\nrescue comment -> HTTP %{http_code}\n' || true +else + echo "rescue: could not open/find a PR for $BRANCH" +fi +exit 0 -- 2.54.0