diff --git a/agent-loop/README.md b/agent-loop/README.md new file mode 100644 index 0000000..0ee7db5 --- /dev/null +++ b/agent-loop/README.md @@ -0,0 +1,60 @@ +# nugitea daily agent + +Run `./nugitea-daily.sh` when you like (say, once a day): opencode with the +local Qwen3 Coder works through `elixir_nugitea/BACKLOG.md` in the nugitea +repo unattended, and pushes what passes to the `agent/daily` branch on +Forgejo for you to review and merge. It prints as it goes, so you can glance +at it, but it never waits for you. + +## What a run does (`nugitea-daily.sh`) + +1. Checks Ollama is up, resets its own worktree (`~/Code/nugitea-agent`, branch + `agent/daily`), merges in the latest `elixir-prototype`, and requires the + checks to pass before it starts. +2. Takes the first `- [ ]` task in BACKLOG.md and gives it to a fresh opencode + session (`opencode run --standalone --auto`, 45 minutes max), with + `elixir_nugitea/AGENTS.md` as its instructions. +3. Keeps the result only if it: + - changed only files under `elixir_nugitea/`, and none of AGENTS.md, + BACKLOG.md, priv/schema.graphql, mix.exs, mix.lock; + - is under 800 changed lines; + - passes `mix format --check-formatted`, `mix compile --warnings-as-errors` + and `mix test`, with no fewer passing tests than before. +4. If not, a second session gets the failure output and tries to fix it. If + that fails too, the changes are thrown away and the task is marked `[!]`. +5. Commits each finished task (checking it off as `[x]`) as "nugitea agent", + and pushes `agent/daily` with its own deploy key. +6. Up to 3 tasks, or 3 hours, per run. + +Output also goes to `~/Library/Logs/nugitea-agent/.log`, including +each session's own summary. `MAX_TASKS=1 ./nugitea-daily.sh` does just one +task. + +## Guardrails + +- `opencode-agent.json` (passed as `OPENCODE_CONFIG`) denies: files outside the + worktree, web access, questions, sub-agents, and shell commands that touch + git history, the network, packages or services (`git commit/push/reset/...`, + `curl`, `ssh`, `brew`, `npm`, `launchctl`, ...). Everything else in the shell + is allowed: it runs as you, so this is a speed bump, not a sandbox. +- It pushes with a deploy key (`~/.config/nugitea-agent/deploy_key`) that can + only write to quine-global-labs/nugitea, and only ever to `agent/daily` + (though the key itself could push any branch there). +- Nothing it does reaches `elixir-prototype` or `main` until you merge it. + +## Setup (once) + +```sh +git -C ~/Code/nugitea worktree add -b agent/daily ~/Code/nugitea-agent origin/elixir-prototype +mkdir -p ~/.config/nugitea-agent +ssh-keygen -t ed25519 -N "" -f ~/.config/nugitea-agent/deploy_key +# add deploy_key.pub as a write deploy key on quine-global-labs/nugitea +ssh-keyscan -p 2200 forge.quinefoundation.com | grep ed25519 > ~/.config/nugitea-agent/known_hosts +``` + +## Steering it + +Edit `elixir_nugitea/BACKLOG.md` on `elixir-prototype`: add tasks at the end, +reorder, or reset a `[!]` to `[ ]` after making it smaller. Each task should be +finishable in one sitting and say what test proves it. Review `agent/daily`, +merge what's good into `elixir-prototype`; the next run picks up from there. diff --git a/agent-loop/nugitea-daily.sh b/agent-loop/nugitea-daily.sh new file mode 100755 index 0000000..ea5ee0b --- /dev/null +++ b/agent-loop/nugitea-daily.sh @@ -0,0 +1,188 @@ +#!/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" &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 - <