feat(jobs): push background-job completion notifications via per-context queue
- add NotificationQueue/SystemNotification: every context owns a fresh queue (children never inherit the parent's, avoiding first-drainer-wins races between transcripts) - job tasks push job_completed/job_failed events on completion, failure, and timeout; a panic skips the push and is surfaced by the guardrail's finished-handle enumeration and collect's JoinError mapping instead - events for jobs already collected or cancelled are dropped at drain time by filtering against live supervisor registration - replace inject_escalation_notification with single-pass merge_system_channel: pending_escalations (root-only) ordered before system_notifications (any depth) on the last tool result of a batch; byte-identical output when notifications are empty, proven by the unmodified pre-merger characterization tests
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
use fmt::{Debug, Formatter};
|
||||
use serde_json::{Value, json};
|
||||
use std::fmt;
|
||||
|
||||
/// One background-task completion event, delivered to the context that
|
||||
/// started the task by merging a `system_notifications` entry onto the last
|
||||
/// tool result of a batch.
|
||||
#[derive(Clone)]
|
||||
pub struct SystemNotification {
|
||||
pub event: &'static str,
|
||||
pub id: String,
|
||||
pub tool_or_agent: String,
|
||||
pub status: &'static str,
|
||||
pub next_action: String,
|
||||
}
|
||||
|
||||
impl SystemNotification {
|
||||
pub fn to_value(&self) -> Value {
|
||||
json!({
|
||||
"event": self.event,
|
||||
"id": self.id,
|
||||
"tool_or_agent": self.tool_or_agent,
|
||||
"status": self.status,
|
||||
"next_action": self.next_action,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn job_notification(id: &str, tool: &str, success: bool) -> SystemNotification {
|
||||
SystemNotification {
|
||||
event: if success {
|
||||
"job_completed"
|
||||
} else {
|
||||
"job_failed"
|
||||
},
|
||||
id: id.to_string(),
|
||||
tool_or_agent: tool.to_string(),
|
||||
status: if success { "success" } else { "failed" },
|
||||
next_action: format!("job__collect --id {id} for output"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Completion events for background work started by ONE context. Unlike the
|
||||
/// escalation queue (shared, root-owned), every context owns a fresh queue:
|
||||
/// a queue shared between parent and child would race their drains and
|
||||
/// deliver one context's events into the other's transcript.
|
||||
pub struct NotificationQueue {
|
||||
pending: parking_lot::Mutex<Vec<SystemNotification>>,
|
||||
}
|
||||
|
||||
impl NotificationQueue {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pending: parking_lot::Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&self, notification: SystemNotification) {
|
||||
self.pending.lock().push(notification);
|
||||
}
|
||||
|
||||
pub fn drain(&self) -> Vec<SystemNotification> {
|
||||
std::mem::take(&mut *self.pending.lock())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NotificationQueue {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for NotificationQueue {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
let count = self.pending.lock().len();
|
||||
f.debug_struct("NotificationQueue")
|
||||
.field("pending_count", &count)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn job_notification_success_shape() {
|
||||
let event = job_notification("job_a1b2", "execute_command", true);
|
||||
assert_eq!(
|
||||
event.to_value(),
|
||||
json!({
|
||||
"event": "job_completed",
|
||||
"id": "job_a1b2",
|
||||
"tool_or_agent": "execute_command",
|
||||
"status": "success",
|
||||
"next_action": "job__collect --id job_a1b2 for output",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_notification_failure_shape() {
|
||||
let event = job_notification("job_a1b2", "execute_command", false);
|
||||
assert_eq!(event.event, "job_failed");
|
||||
assert_eq!(event.status, "failed");
|
||||
assert_eq!(event.next_action, "job__collect --id job_a1b2 for output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_empties_queue_and_preserves_order() {
|
||||
let queue = NotificationQueue::new();
|
||||
queue.push(job_notification("job_1", "execute_command", true));
|
||||
queue.push(job_notification("job_2", "execute_command", false));
|
||||
|
||||
let drained = queue.drain();
|
||||
|
||||
assert_eq!(drained.len(), 2);
|
||||
assert_eq!(drained[0].id, "job_1");
|
||||
assert_eq!(drained[1].id, "job_2");
|
||||
assert!(queue.drain().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_on_empty_queue_is_a_noop() {
|
||||
let queue = NotificationQueue::default();
|
||||
assert!(queue.drain().is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user