mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
+30
-3
@@ -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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user