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>
188 lines
6.8 KiB
Bash
Executable file
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"
|