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
This commit is contained in:
Claude
2026-07-25 18:19:40 +00:00
parent 635e785753
commit e0d6febd5b
9 changed files with 460 additions and 114 deletions
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
/** The lifecycle state of one Buzz agent job, folded from its 43001-43006 events. */
enum class JobState {
@@ -79,6 +80,12 @@ data class JobView(
val error: String?,
/** The 43005 cancel reason, when [state] is CANCELLED. */
val cancelReason: String?,
/**
* Distinct channel members who upvoted this job — a NIP-25 like (kind-7, content not `-`)
* whose `e` tag targets the job's request id. The group's priority signal: a scheduler
* orders the backlog by this, newest-first as a tiebreaker.
*/
val upvotes: Int,
/** The request timestamp, or the earliest correlated event when the request is absent. */
val createdAt: Long,
/** The timestamp of the most recent event in the thread. */
@@ -101,10 +108,21 @@ object BuzzJobAggregator {
fun aggregate(events: List<Event>): List<JobView> {
if (events.isEmpty()) return emptyList()
// Correlate every event to a job id: the request is its own id; a reply carries
val distinct = events.distinctBy { it.id }
// Upvotes: distinct authors of a NIP-25 like (kind-7, content not `-`) per targeted
// event id. Counting distinct pubkeys stops one member inflating priority by spamming.
val upvoters = HashMap<HexKey, MutableSet<HexKey>>()
distinct.forEach { e ->
if (e is ReactionEvent && e.content != ReactionEvent.DISLIKE) {
e.originalPost().forEach { target -> upvoters.getOrPut(target) { mutableSetOf() }.add(e.pubKey) }
}
}
// Correlate every job event to a job id: the request is its own id; a reply carries
// the request id in its `e` tag. Replies we can't correlate (no `e`) are dropped.
val byJob = LinkedHashMap<HexKey, MutableList<Event>>()
events.distinctBy { it.id }.forEach { e ->
distinct.forEach { e ->
val jobId =
when (e) {
is JobRequestEvent -> e.id
@@ -119,13 +137,14 @@ object BuzzJobAggregator {
}
return byJob
.map { (jobId, thread) -> fold(jobId, thread) }
.map { (jobId, thread) -> fold(jobId, thread, upvoters[jobId]?.size ?: 0) }
.sortedByDescending { it.updatedAt }
}
private fun fold(
jobId: HexKey,
thread: List<Event>,
upvotes: Int,
): JobView {
val request = thread.filterIsInstance<JobRequestEvent>().maxByOrNull { it.createdAt }
val accepted = thread.filterIsInstance<JobAcceptedEvent>().maxByOrNull { it.createdAt }
@@ -166,8 +185,16 @@ object BuzzJobAggregator {
result = result?.result(),
error = error?.error(),
cancelReason = cancel?.reason()?.ifBlank { null },
upvotes = upvotes,
createdAt = request?.createdAt ?: thread.minOf { it.createdAt },
updatedAt = thread.maxOf { it.createdAt },
)
}
/**
* Backlog ordering for a scheduler: most-upvoted first (the group's priority signal),
* oldest-first as the tiebreaker so an un-upvoted item still drains FIFO. Applies to the
* caller-provided set (typically the REQUESTED jobs targeting the agent).
*/
fun byPriority(jobs: List<JobView>): List<JobView> = jobs.sortedWith(compareByDescending<JobView> { it.upvotes }.thenBy { it.createdAt })
}