local-llm/agent-loop/nugitea-daily.sh
Philip Peterson 01522a0573 Add the nugitea daily agent loop
nugitea-daily.sh runs opencode with qwen3-coder over elixir_nugitea's
BACKLOG.md, one fresh session per task, and keeps a change only if it
stays in elixir_nugitea/, is under 800 lines and passes format, compile
and tests; then it commits and pushes agent/daily with its own deploy
key. opencode-agent.json denies git history, network, package and
service commands. Run by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:23:30 -08:00

188 lines
6.8 KiB
Bash
Executable file

#!/bin/bash
# The nugitea daily agent, run by hand (`./nugitea-daily.sh`): opencode +
# Qwen work through elixir_nugitea/BACKLOG.md unattended, one task per fresh
# session. Each
# task's result is kept only if it passes format, compile and tests, stays
# inside elixir_nugitea/ and is a reasonable size; then it's committed and
# pushed to the agent/daily branch on Forgejo for review. See README.md.
set -uo pipefail
export PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
here=$(cd "$(dirname "$0")" && pwd)
repo=${NUGITEA_REPO:-$HOME/Code/nugitea}
work=${NUGITEA_AGENT_WORKTREE:-$HOME/Code/nugitea-agent}
base=${NUGITEA_AGENT_BASE:-elixir-prototype}
branch=agent/daily
remote_url=ssh://git@forge.quinefoundation.com:2200/quine-global-labs/nugitea.git
model=ollama/qwen3-coder:30b-128k
max_tasks=${MAX_TASKS:-3} # tasks per run
attempts=2 # sessions per task (the second one sees the failures)
session_timeout=$((45 * 60)) # seconds per opencode session
budget=$((3 * 3600)) # stop starting tasks after this many seconds
max_diff_lines=800 # added + removed lines per task
protected='^elixir_nugitea/(AGENTS\.md|BACKLOG\.md|priv/schema\.graphql|mix\.exs|mix\.lock)$'
keys=$HOME/.config/nugitea-agent
logdir=$HOME/Library/Logs/nugitea-agent
backlog=elixir_nugitea/BACKLOG.md
mkdir -p "$logdir"
log=$logdir/$(date +%F).log
# On screen, and in the day's log.
exec > >(tee -a "$log") 2>&1
say() { echo "[$(date +%T)] $*"; }
start=$(date +%s)
# One run at a time.
lock=$logdir/.lock
mkdir "$lock" 2>/dev/null || { say "another run is going (remove $lock if not)"; exit 0; }
trap 'rmdir "$lock"' EXIT
git_push() {
GIT_SSH_COMMAND="ssh -i $keys/deploy_key -o IdentitiesOnly=yes -o BatchMode=yes -o UserKnownHostsFile=$keys/known_hosts" \
git -C "$work" push -q "$remote_url" "HEAD:refs/heads/$branch"
}
# A commit subject from a backlog task: no markdown, at most 72 characters,
# cut at a word.
subject() {
local s
s=$(echo "$1" | tr -d '`' | sed 's/:.*//')
[ ${#s} -le 72 ] && { echo "$s"; return; }
echo "${s:0:69}" | sed 's/ [^ ]*$//; s/$/.../'
}
agent_commit() {
git -C "$work" -c user.name="nugitea agent" -c user.email="agent@forge.quinefoundation.com" commit -q "$@"
}
# Runs the checks, keeping their output in $checks_out and the passing test
# count in $tests.
checks_out=$logdir/.checks.out
checks() {
(cd "$work/elixir_nugitea" && mix format --check-formatted &&
mix compile --warnings-as-errors && mix test) >"$checks_out" 2>&1
local status=$?
tests=$(grep -oE '[0-9]+ (passed|tests?,)' "$checks_out" | tail -1 | grep -oE '^[0-9]+')
return $status
}
say "=== start"
curl -fs localhost:11434/api/version >/dev/null || { say "Ollama isn't answering; stopping"; exit 1; }
# The worktree is the agent's alone: reset it, then catch up with $base so
# the agent builds on whatever has been merged since.
git -C "$repo" fetch -q origin || { say "fetch failed; stopping"; exit 1; }
[ -d "$work" ] || git -C "$repo" worktree add -q -b "$branch" "$work" "origin/$base"
git -C "$work" reset -q --hard
git -C "$work" clean -qfd
if ! git -C "$work" merge -q --no-edit "origin/$base" >/dev/null; then
git -C "$work" merge --abort
say "agent/daily conflicts with $base; merge it by hand. Stopping."
exit 1
fi
(cd "$work/elixir_nugitea" && mix deps.get >/dev/null 2>&1)
if ! checks; then
tail -30 "$checks_out"
say "the checks fail before the agent has done anything; fix $base first. Stopping."
exit 1
fi
baseline_tests=${tests:-0}
say "baseline: $baseline_tests tests pass"
done_count=0
while [ "$done_count" -lt "$max_tasks" ] && [ $(($(date +%s) - start)) -lt "$budget" ]; do
line=$(grep -m1 -E '^- \[ \] ' "$work/$backlog") || { say "backlog is empty"; break; }
task=${line#- \[ \] }
say "--- task: $task"
ok=false
failure=""
for attempt in $(seq 1 "$attempts"); do
prompt="You are working unattended; nobody will answer questions. Read elixir_nugitea/AGENTS.md first and follow it.
Your task:
$task
When mix format, mix compile --warnings-as-errors and mix test all pass in elixir_nugitea/, stop and summarize what you changed."
if [ -n "$failure" ]; then
prompt="$prompt
A previous attempt left its changes in place, but the checks failed. Fix them. The last part of the output:
$failure"
fi
say "session $attempt"
summary=$(cd "$work" && OPENCODE_CONFIG="$here/opencode-agent.json" \
perl -e 'alarm shift; exec @ARGV' "$session_timeout" \
opencode run --standalone --auto -m "$model" "$prompt" </dev/null 2>&1)
echo "$summary" | tail -40
# Only elixir_nugitea/, never the protected files, not too big.
git -C "$work" add -A
changed=$(git -C "$work" diff --cached --name-only)
if [ -z "$changed" ]; then
failure="You made no changes."
say "no changes"
continue
fi
if outside=$(echo "$changed" | grep -v '^elixir_nugitea/'); then
failure="You changed files outside elixir_nugitea/: $outside"
say "$failure"
git -C "$work" reset -q --hard
continue
fi
if bad=$(echo "$changed" | grep -E "$protected"); then
failure="You changed files you must not change: $bad"
say "$failure"
git -C "$work" reset -q --hard
continue
fi
size=$(git -C "$work" diff --cached --numstat | awk '{n += $1 + $2} END {print n + 0}')
if [ "$size" -gt "$max_diff_lines" ]; then
failure="Your change is $size lines; keep it under $max_diff_lines."
say "$failure"
git -C "$work" reset -q --hard
continue
fi
if checks; then
if [ "${tests:-0}" -ge "$baseline_tests" ]; then
ok=true
break
fi
failure="Fewer tests pass than before (${tests:-0} vs $baseline_tests): don't remove tests."
else
failure=$(tail -40 "$checks_out")
fi
say "checks failed"
echo "$failure" | tail -15
done
if $ok; then
# Check the task off and commit both together.
LINE="$line" perl -i -pe 'if (!$d && $_ eq "$ENV{LINE}\n") { s/^- \[ \]/- [x]/; $d = 1 }' "$work/$backlog"
git -C "$work" add -A
agent_commit -F - <<EOF
$(subject "$task")
Backlog task: $task
Done unattended by opencode with $model; passed mix format,
compile --warnings-as-errors and mix test ($tests tests, was $baseline_tests).
The session's own summary is in $log.
EOF
baseline_tests=$tests
say "done: $(git -C "$work" log --oneline -1)"
else
git -C "$work" reset -q --hard
git -C "$work" clean -qfd
LINE="$line" perl -i -pe 'if (!$d && $_ eq "$ENV{LINE}\n") { s/^- \[ \]/- [!]/; $d = 1 }' "$work/$backlog"
git -C "$work" add "$backlog"
agent_commit -m "Give up on a backlog task for now" -m "$task" -m "$(echo "$failure" | tail -15 | cut -c1-200)"
say "gave up on it"
fi
done_count=$((done_count + 1))
git_push || say "push failed"
done
say "=== end: $(git -C "$work" log --oneline "origin/$base..HEAD" | wc -l | tr -d ' ') commits on $branch ahead of $base"