feat(buzz): notify the requester when their agent job finishes or fails

Finished/failed jobs now land in the Notifications tab, addressed to the requester.

1. NotificationFeedFilter: an early-return branch accepts a JobResultEvent (43004) or
   JobErrorEvent (43006) when it p-tags me (the requester) and isn't my own event —
   mirroring the existing Buzz-DM branch, since I don't "follow" the workspace bot and
   the job kinds aren't in the generic relevance path. It maps to the generic NoteCard,
   so it renders the result (PR URL) / error text.

2. JobErrorEvent now carries the requester as a `p` tag (new requester() accessor + a
   `requester` param on build, mirroring JobResultEvent); the scheduler passes
   job.requester on both error paths. Previously a failed job wasn't addressed to anyone,
   so a failure could never notify. Tests updated.

Relay sourcing (verified): a job outcome reaches LocalCache via the always-on `#h`
joined-group chat tail on the workspace relay (RELAY_GROUP_ALL_TIMELINE_KINDS includes
43001-43006), so for a shared channel the team has joined, results are pulled continuously
and now notify. A channel you haven't joined (or a job event with no `#h`) would still need
a dedicated `#p`=me subscription (mirroring BuzzDmDiscovery) — not added, since the
support-channel model always has members joined.

App compiles (fdroidDebug); quartz + commons tests green.

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-26 15:05:31 +00:00
parent 0b19a89b0c
commit 2e92d787a8
5 changed files with 22 additions and 4 deletions
@@ -32,6 +32,8 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
@@ -454,6 +456,15 @@ class NotificationFeedFilter(
return it.author?.pubkeyHex != loggedInUserHex
}
// A finished or failed agent job I filed: the workspace bot addresses the outcome to me via a
// `p` tag = the requester. Notify me directly — I don't "follow" the bot and the job kinds aren't
// in the generic relevance path, so mirror the Buzz-DM early return above rather than the p-tag
// heuristic. This is activity (not a chat message), so it ignores the Messages toggle.
if (noteEvent is JobResultEvent || noteEvent is JobErrorEvent) {
val requester = (noteEvent as? JobResultEvent)?.requester() ?: (noteEvent as JobErrorEvent).requester()
return requester == loggedInUserHex && it.author?.pubkeyHex != loggedInUserHex
}
if (!showMessages &&
(
noteEvent is ChatMessageEvent ||
@@ -414,7 +414,7 @@ object BuzzAgentCommands {
git(opts.worktreeBase, "worktree", "add", "-B", branch, worktreePath, opts.baseRef)
}
if (add.exit != 0) {
publish(ctx, opts.relay, JobErrorEvent.build(job.jobId, "worktree setup failed: ${add.stderr.take(MAX_BODY)}", channel, "error"))
publish(ctx, opts.relay, JobErrorEvent.build(job.jobId, "worktree setup failed: ${add.stderr.take(MAX_BODY)}", channel, job.requester, "error"))
return mapOf("job_id" to job.jobId, "state" to "failed", "error" to "worktree")
}
workdir = worktreePath
@@ -446,7 +446,7 @@ object BuzzAgentCommands {
mapOf("job_id" to job.jobId, "state" to "completed", "exit" to 0, "branch" to if (opts.worktreeBase != null) branch else null)
} else {
val body = (run.stderr.ifBlank { run.stdout }).ifBlank { "exited ${run.exit}" }
publish(ctx, opts.relay, JobErrorEvent.build(job.jobId, body.take(MAX_BODY), channel, "error"))
publish(ctx, opts.relay, JobErrorEvent.build(job.jobId, body.take(MAX_BODY), channel, job.requester, "error"))
mapOf("job_id" to job.jobId, "state" to "failed", "exit" to run.exit)
}
} finally {
@@ -70,7 +70,7 @@ class BuzzJobAggregatorTest {
}
private fun error(createdAt: Long = 1040): JobErrorEvent {
val t = JobErrorEvent.build(jobId, "build failed", channel, "error", createdAt)
val t = JobErrorEvent.build(jobId, "build failed", channel, status = "error", createdAt = createdAt)
return JobErrorEvent("6".repeat(64), agent, t.createdAt, t.tags, t.content, "sig")
}
@@ -50,6 +50,9 @@ class JobErrorEvent(
/** The channel this job is scoped to - the `h` tag. */
fun channel() = tags.jobChannel()
/** The requester the failure is addressed to - the `p` tag. */
fun requester() = tags.jobParticipant()
/** The optional status token - the `status` tag. */
fun status() = tags.jobStatus()
@@ -63,12 +66,14 @@ class JobErrorEvent(
requestId: HexKey,
error: String,
channelId: String? = null,
requester: HexKey? = null,
status: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<JobErrorEvent>.() -> Unit = {},
) = eventTemplate<JobErrorEvent>(KIND, error, createdAt) {
jobRequest(requestId)
channelId?.let { jobChannel(it) }
requester?.let { jobParticipant(it) }
status?.let { jobStatus(it) }
initializer()
}
@@ -26,16 +26,18 @@ import kotlin.test.assertEquals
class JobErrorEventTest {
private val requestId = "e".repeat(64)
private val channel = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
private val requester = "a".repeat(64)
@Test
fun buildTagsAndContent() {
val t = JobErrorEvent.build(requestId, "tool timeout", channel, "failed")
val t = JobErrorEvent.build(requestId, "tool timeout", channel, requester, "failed")
val ev = JobErrorEvent("00", "f".repeat(64), t.createdAt, t.tags, t.content, "sig")
assertEquals(JobErrorEvent.KIND, ev.kind)
assertEquals("tool timeout", ev.error())
assertEquals(requestId, ev.jobRequest())
assertEquals(channel, ev.channel())
assertEquals(requester, ev.requester())
assertEquals("failed", ev.status())
}
}