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:
2026-08-25 17:50:28 -06:00
parent 6a694d10db
commit 2d874f1d7c
5 changed files with 571 additions and 27 deletions
+1
View File
@@ -1,5 +1,6 @@
pub mod escalation;
pub mod mailbox;
pub mod notification;
pub mod taskqueue;
use crate::function::jobs::RingBuf;
+128
View File
@@ -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());
}
}