Files
Claude e0d6febd5b feat(cli): parallel backlog scheduler for the shared Buzz agent channel
Evolve `amy buzz agent serve` from a sequential responder into a scheduler that
manages a shared feature-request backlog by itself — the model where a whole team
drives an AI in one channel, not a 1:1 chat.

- Parallel execution with isolation: `--parallel N` runs up to N jobs at once,
  each in its own `git worktree` + branch (`--worktree REPODIR`, off `--base-ref`,
  named `<branch-prefix><jobid>`) so concurrent autonomous runs never clobber one
  working tree. `--parallel > 1` requires `--worktree`; worktree add/remove is
  mutex-serialized while the agent work runs concurrently. Branch/worktree/base-ref
  are exported to `--exec` (BUZZ_BRANCH/WORKTREE/BASE_REF) so it commits, pushes the
  branch, and opens the PR. Merge stays on GitHub — never here.
- Group-driven priority: BuzzJobAggregator now folds kind-7 upvotes (distinct
  reactors, dislikes excluded) into JobView.upvotes, and `byPriority` orders the
  backlog most-upvoted-first, oldest-first tiebreak. The stack reprioritizes itself
  as the channel reacts. `buzz job list/show` surface upvotes.
- Channel-as-allowlist: `--accept-from-channel` obeys any member of the channel's
  kind-39002 roster ("anyone in the channel can drive"), union with explicit
  `--accept-from` npubs.
- Harness cli/tests/buzz/job-loop.sh gains a parallel case (3 jobs, --parallel 3,
  one branch/worktree each, cleaned up); aggregator gains upvote + priority tests.
  11/11 harness checks + all unit tests green.

Fits entirely inside amy: amy is the scheduler, the coding agent is whatever
`--exec` points at, GitHub owns merge. Plan doc updated with the model + the
"can this live in Amy" architecture note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
2026-07-25 18:19:40 +00:00

116 lines
5.2 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# job-loop.sh — self-contained headless test for the Buzz agent-job loop.
#
# Two `amy` accounts (alice = requester, bot = agent) talk through an embedded
# relay (`amy serve`, i.e. geode — no external binary). Exercises the whole
# drive-an-agent loop end to end:
#
# alice: buzz job request (kind-43001, targeting the bot)
# bot: buzz agent serve --once (accept 43002 → progress 43003 →
# run --exec → result 43004)
# alice: buzz job show (BuzzJobAggregator folds it to state=completed)
#
# It also proves the permission gate: a responder whose --accept-from allowlist
# excludes alice handles NOTHING; only an allowlisted requester is obeyed.
#
# Usage: ./job-loop.sh [--port N] [--no-build]
#
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state-job-loop"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
PORT=7799
BUILD=1
while [[ $# -gt 0 ]]; do
case "$1" in
--port) PORT="$2"; shift 2 ;;
--no-build) BUILD=0; shift ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
RELAY="ws://127.0.0.1:$PORT"
if [[ $BUILD -eq 1 ]]; then
echo "> building amy…" >&2
(cd "$REPO_ROOT" && ./gradlew -q :cli:installDist) || { echo "build failed" >&2; exit 1; }
fi
[[ -x "$AMY_BIN" ]] || { echo "amy not built at $AMY_BIN (drop --no-build)" >&2; exit 1; }
rm -rf "$STATE_DIR"; mkdir -p "$STATE_DIR"
REQ_HOME="$STATE_DIR/alice"; AGENT_HOME="$STATE_DIR/bot"; mkdir -p "$REQ_HOME" "$AGENT_HOME"
RELAY_LOG="$STATE_DIR/relay.log"
PASS=0; FAIL=0
RELAY_PID=""
cleanup() { [[ -n "$RELAY_PID" ]] && kill "$RELAY_PID" 2>/dev/null; }
trap cleanup EXIT
run_req() { HOME="$REQ_HOME" "$AMY_BIN" --account alice --secret-backend plaintext --json "$@" 2>/dev/null; }
run_agent() { HOME="$AGENT_HOME" "$AMY_BIN" --account bot --secret-backend plaintext --json "$@" 2>/dev/null; }
jkey() {
python3 -c '
import sys, json
v = json.load(sys.stdin).get("'"$1"'", "")
print(str(v).lower() if isinstance(v, bool) else v)'
}
check() { # check <label> <condition-desc> <actual> <expected>
if [[ "$3" == "$4" ]]; then echo " ✓ PASS $1 ($3)" >&2; PASS=$((PASS+1));
else echo " ✗ FAIL $1: expected [$4] got [$3]" >&2; FAIL=$((FAIL+1)); fi
}
contains() { # contains <label> <haystack> <needle>
if [[ "$2" == *"$3"* ]]; then echo " ✓ PASS $1" >&2; PASS=$((PASS+1));
else echo " ✗ FAIL $1: [$2] has no [$3]" >&2; FAIL=$((FAIL+1)); fi
}
run_req init >/dev/null; run_agent init >/dev/null
ALICE=$(run_req whoami | jkey hex)
BOT=$(run_agent whoami | jkey hex)
echo "> alice=$ALICE bot=$BOT" >&2
HOME="$AGENT_HOME" "$AMY_BIN" --account bot --secret-backend plaintext serve --port "$PORT" >"$RELAY_LOG" 2>&1 &
RELAY_PID=$!
for _ in $(seq 1 60); do grep -q "relay up" "$RELAY_LOG" && break; sleep 0.5; done
grep -q "relay up" "$RELAY_LOG" || { echo "relay never came up" >&2; cat "$RELAY_LOG" >&2; exit 1; }
echo "> 1. alice files a job targeting the bot" >&2
JOB=$(run_req buzz job request "$RELAY" "hello from alice" --agent "$BOT")
JOB_ID=$(echo "$JOB" | jkey job_id)
check "request published" _ "$(echo "$JOB" | jkey published)" "true"
[[ -n "$JOB_ID" ]] && { echo " ✓ PASS got job id $JOB_ID" >&2; PASS=$((PASS+1)); } || { echo " ✗ FAIL no job id" >&2; FAIL=$((FAIL+1)); }
echo "> 2. UNAUTHORIZED responder (allowlist excludes alice) handles nothing" >&2
UNAUTH=$(run_agent buzz agent serve "$RELAY" --exec 'cat' --accept-from "$BOT" --once)
check "allowlist blocks non-listed requester" _ "$(echo "$UNAUTH" | jkey handled)" "0"
echo "> 3. authorized responder handles it, piping task text through --exec" >&2
AUTH=$(run_agent buzz agent serve "$RELAY" --exec 'printf "PR opened for: "; cat' --accept-from "$ALICE" --once)
check "authorized responder handled 1" _ "$(echo "$AUTH" | jkey handled)" "1"
echo "> 4. alice reads back the folded job" >&2
SHOW=$(run_req buzz job show "$RELAY" "$JOB_ID")
check "state" _ "$(echo "$SHOW" | jkey state)" "completed"
check "requester" _ "$(echo "$SHOW" | jkey requester)" "$ALICE"
check "agent" _ "$(echo "$SHOW" | jkey agent)" "$BOT"
contains "result carries exec stdout with piped stdin" "$(echo "$SHOW" | jkey result)" "PR opened for: hello from alice"
echo "> 5. parallel scheduler: 3 jobs, --parallel 3, one worktree+branch each" >&2
REPO="$STATE_DIR/repo"; mkdir -p "$REPO"
git -C "$REPO" -c init.defaultBranch=main init -q
git -C "$REPO" -c user.email=a@b.c -c user.name=t commit -q --allow-empty -m init
for n in one two three; do run_req buzz job request "$RELAY" "parallel $n" --agent "$BOT" >/dev/null; done
PAR=$(run_agent buzz agent serve "$RELAY" \
--exec 'printf "on %s: " "$BUZZ_BRANCH"; cat' \
--accept-from "$ALICE" --parallel 3 --worktree "$REPO" --base-ref main --once)
check "parallel scheduler handled 3" _ "$(echo "$PAR" | jkey handled)" "3"
BRANCHES=$(git -C "$REPO" branch --list 'claude/job-*' | wc -l | tr -d ' ')
check "created one branch per job" _ "$BRANCHES" "3"
LEFTOVER=$(git -C "$REPO" worktree list | sed 1d | wc -l | tr -d ' ')
check "worktrees cleaned up after runs" _ "$LEFTOVER" "0"
echo "" >&2
echo "> RESULTS: $PASS passed, $FAIL failed" >&2
[[ $FAIL -eq 0 ]] && exit 0 || exit 1