feat(background): runner PTY B8 + boucle sink fermée + refactor point-2
Livre le lot backend B8 des tâches de fond : - infrastructure : runner de commandes concret (CommandBackgroundRunner) sur le port BackgroundTaskRunner, tail borné (bounded_tail/BoundedTail), éclatement du module background_task en sous-modules (mod/runner/tail, sink extrait de l'ancien background_task.rs). - application : nouveau module background exposant les cas d'usage SpawnBackgroundCommand, CancelBackgroundTask, RetryBackgroundTask et le port BackgroundCommandArchive. - domain : refactor point-2 de l'arbitrage Architect — sortie du trait BackgroundCommandArchive de la couche domaine vers application. - app-tauri : câblage runtime (commands, dto, state, lib) des commandes spawn/cancel/retry et de la boucle de complétion sink fermée en composition root. Build workspace + tests application/infrastructure verts (QA). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
22
crates/infrastructure/src/background_task/mod.rs
Normal file
22
crates/infrastructure/src/background_task/mod.rs
Normal file
@ -0,0 +1,22 @@
|
||||
//! First-class background task infrastructure.
|
||||
//!
|
||||
//! Three cohesive pieces close the B2→B8 loop:
|
||||
//!
|
||||
//! - [`sink`] — the durable [`BackgroundCompletionSink`], single writer between a
|
||||
//! runner completion and the ready-to-deliver signal (persist-before-signal).
|
||||
//! - [`tail`] — a UTF-8-safe bounded ring buffer for stdout/stderr tails.
|
||||
//! - [`runner`] — [`CommandBackgroundRunner`], the concrete
|
||||
//! [`BackgroundTaskRunner`](domain::ports::BackgroundTaskRunner) that spawns a
|
||||
//! command-backed task over a composed [`PtyPort`](domain::ports::PtyPort) and
|
||||
//! emits exactly one completion per task.
|
||||
|
||||
mod runner;
|
||||
mod sink;
|
||||
mod tail;
|
||||
|
||||
pub use runner::CommandBackgroundRunner;
|
||||
pub use sink::{
|
||||
start_background_ready_inbox_bridge, BackgroundCompletionSink, BackgroundCompletionSinkError,
|
||||
BackgroundCompletionSinkOutcome, BackgroundReadyInboxBridgeHandle, BackgroundTaskReadyToDeliver,
|
||||
};
|
||||
pub use tail::{bounded_tail, tail_cap_bytes, BoundedTail};
|
||||
308
crates/infrastructure/src/background_task/runner.rs
Normal file
308
crates/infrastructure/src/background_task/runner.rs
Normal file
@ -0,0 +1,308 @@
|
||||
//! [`CommandBackgroundRunner`] — the concrete command-backed background task
|
||||
//! runner (B8 sub-task 2).
|
||||
//!
|
||||
//! # Role
|
||||
//!
|
||||
//! This is the `impl` of the frozen [`BackgroundTaskRunner`] port that the
|
||||
//! composition root wires under the [`BackgroundCompletionSink`]. It spawns a
|
||||
//! command-backed task over a **composed** [`PtyPort`] (resolved by the project's
|
||||
//! `RemoteHost`, so local/SSH/WSL are transparent — Liskov) and, for each task,
|
||||
//! emits **exactly one** [`BackgroundTaskCompletion`] on the stream returned by
|
||||
//! [`subscribe_completions`](BackgroundTaskRunner::subscribe_completions).
|
||||
//!
|
||||
//! It writes **neither** the store **nor** the inbox: the sink is the single
|
||||
//! writer that persists the terminal state before signalling delivery
|
||||
//! (persist-before-signal). The runner's only outward effect is the completion
|
||||
//! event.
|
||||
//!
|
||||
//! # Lifecycle of one task
|
||||
//!
|
||||
//! `spawn` opens the PTY and starts a detached worker. The worker watches the
|
||||
//! PTY's output stream for EOF (natural process exit), a cancel notification, or
|
||||
//! the deadline, whichever comes first. It then snapshots the bounded output tail
|
||||
//! from the PTY scrollback, reaps the real exit status via
|
||||
//! [`PtyPort::kill`](domain::ports::PtyPort::kill), maps that to a terminal
|
||||
//! [`BackgroundTaskResult`], and sends the completion.
|
||||
//!
|
||||
//! # Output tail & the live tee
|
||||
//!
|
||||
//! A PTY **merges** stdout and stderr onto one stream, so the completion carries
|
||||
//! the merged output in `stdout_tail` and leaves `stderr_tail` empty. Exit
|
||||
//! detection reads the PTY's single-consumer output subscription; a *live tee*
|
||||
//! that re-subscribes the same PTY (to render it in an xterm cell) would supersede
|
||||
//! the runner's subscription and break EOF detection — teeing a running command
|
||||
//! therefore requires a multi-consumer PTY broadcast, which is out of scope here.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::BackgroundCommandArchive;
|
||||
use domain::ports::{
|
||||
BackgroundCompletionStream, BackgroundTaskCompletion, BackgroundTaskHandle,
|
||||
BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec, Clock, PtyHandle, PtyPort,
|
||||
SpawnSpec,
|
||||
};
|
||||
use domain::terminal::PtySize;
|
||||
use domain::{BackgroundTaskResult, TaskId};
|
||||
|
||||
use super::tail::{bounded_tail, tail_cap_bytes};
|
||||
|
||||
/// Default PTY geometry for a headless background command (no visible cell).
|
||||
const DEFAULT_SIZE: PtySize = PtySize { rows: 24, cols: 80 };
|
||||
|
||||
/// Why the worker's terminal wait resolved.
|
||||
enum WaitOutcome {
|
||||
/// The process exited on its own (stream hit EOF).
|
||||
Exited,
|
||||
/// A [`cancel`](BackgroundTaskRunner::cancel) was requested.
|
||||
Cancelled,
|
||||
/// The deadline elapsed before exit.
|
||||
Expired,
|
||||
}
|
||||
|
||||
/// Live control handles for one running task (kept in the registry).
|
||||
#[derive(Clone)]
|
||||
struct TaskControl {
|
||||
pty_handle: PtyHandle,
|
||||
cancel: Arc<tokio::sync::Notify>,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// Command-backed [`BackgroundTaskRunner`] over a composed [`PtyPort`].
|
||||
pub struct CommandBackgroundRunner {
|
||||
pty: Arc<dyn PtyPort>,
|
||||
clock: Arc<dyn Clock>,
|
||||
completion_tx: Sender<BackgroundTaskCompletion>,
|
||||
completion_rx: Mutex<Option<Receiver<BackgroundTaskCompletion>>>,
|
||||
running: Arc<Mutex<HashMap<TaskId, TaskControl>>>,
|
||||
/// Retained command invocations, so [`BackgroundCommandArchive::spec_for`]
|
||||
/// can recover a task's command for retry.
|
||||
specs: Arc<Mutex<HashMap<TaskId, SpawnSpec>>>,
|
||||
}
|
||||
|
||||
impl CommandBackgroundRunner {
|
||||
/// Builds a runner over a composed PTY port and clock.
|
||||
#[must_use]
|
||||
pub fn new(pty: Arc<dyn PtyPort>, clock: Arc<dyn Clock>) -> Self {
|
||||
let (completion_tx, completion_rx) = mpsc::channel();
|
||||
Self {
|
||||
pty,
|
||||
clock,
|
||||
completion_tx,
|
||||
completion_rx: Mutex::new(Some(completion_rx)),
|
||||
running: Arc::new(Mutex::new(HashMap::new())),
|
||||
specs: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ms(&self) -> u64 {
|
||||
u64::try_from(self.clock.now_millis().max(0)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Detached worker driving one command to its single completion.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_to_completion(
|
||||
pty: Arc<dyn PtyPort>,
|
||||
clock: Arc<dyn Clock>,
|
||||
completion_tx: Sender<BackgroundTaskCompletion>,
|
||||
running: Arc<Mutex<HashMap<TaskId, TaskControl>>>,
|
||||
task_id: TaskId,
|
||||
control: TaskControl,
|
||||
deadline_ms: Option<u64>,
|
||||
) {
|
||||
let outcome = Self::await_terminal(&pty, &control, deadline_ms, &clock).await;
|
||||
// A cancel that raced a natural EOF still wins: the user asked to cancel.
|
||||
let outcome = if control.cancelled.load(Ordering::SeqCst) {
|
||||
WaitOutcome::Cancelled
|
||||
} else {
|
||||
outcome
|
||||
};
|
||||
|
||||
// Snapshot the merged output tail *before* kill tears the session down.
|
||||
let tail = pty
|
||||
.scrollback(&control.pty_handle)
|
||||
.ok()
|
||||
.map(|bytes| bounded_tail(&bytes, tail_cap_bytes()))
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
// Reap the real exit status (works for a natural exit and a forced kill).
|
||||
let exit_code = pty
|
||||
.kill(&control.pty_handle)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|status| status.code);
|
||||
|
||||
let finished_at_ms = u64::try_from(clock.now_millis().max(0)).unwrap_or(0);
|
||||
let result = match outcome {
|
||||
WaitOutcome::Cancelled => BackgroundTaskResult::Cancelled {
|
||||
finished_at_ms,
|
||||
reason: "cancelled by request".to_owned(),
|
||||
},
|
||||
WaitOutcome::Expired => BackgroundTaskResult::Expired {
|
||||
finished_at_ms,
|
||||
reason: "deadline elapsed before completion".to_owned(),
|
||||
},
|
||||
WaitOutcome::Exited => {
|
||||
if exit_code == Some(0) {
|
||||
BackgroundTaskResult::Success {
|
||||
finished_at_ms,
|
||||
exit_code,
|
||||
summary: "command completed successfully".to_owned(),
|
||||
stdout_tail: tail,
|
||||
stderr_tail: None,
|
||||
}
|
||||
} else {
|
||||
BackgroundTaskResult::Failure {
|
||||
finished_at_ms,
|
||||
exit_code,
|
||||
error: match exit_code {
|
||||
Some(code) => format!("command exited with code {code}"),
|
||||
None => "command terminated without an exit code".to_owned(),
|
||||
},
|
||||
stdout_tail: tail,
|
||||
stderr_tail: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
running.lock().expect("runner registry poisoned").remove(&task_id);
|
||||
let _ = completion_tx.send(BackgroundTaskCompletion { task_id, result });
|
||||
}
|
||||
|
||||
/// Resolves when the process exits, is cancelled, or hits its deadline.
|
||||
async fn await_terminal(
|
||||
pty: &Arc<dyn PtyPort>,
|
||||
control: &TaskControl,
|
||||
deadline_ms: Option<u64>,
|
||||
clock: &Arc<dyn Clock>,
|
||||
) -> WaitOutcome {
|
||||
let eof = Self::wait_for_eof(Arc::clone(pty), control.pty_handle.clone());
|
||||
tokio::pin!(eof);
|
||||
|
||||
let sleep_ms = deadline_ms.map(|deadline| {
|
||||
let now = u64::try_from(clock.now_millis().max(0)).unwrap_or(0);
|
||||
deadline.saturating_sub(now)
|
||||
});
|
||||
|
||||
match sleep_ms {
|
||||
Some(ms) => tokio::select! {
|
||||
() = &mut eof => WaitOutcome::Exited,
|
||||
() = control.cancel.notified() => WaitOutcome::Cancelled,
|
||||
() = tokio::time::sleep(std::time::Duration::from_millis(ms)) => WaitOutcome::Expired,
|
||||
},
|
||||
None => tokio::select! {
|
||||
() = &mut eof => WaitOutcome::Exited,
|
||||
() = control.cancel.notified() => WaitOutcome::Cancelled,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves when the PTY output stream ends (the process has exited).
|
||||
///
|
||||
/// The domain `OutputStream` is a blocking iterator, so it is drained on a
|
||||
/// blocking task and its bytes discarded — the persisted tail is read from the
|
||||
/// PTY scrollback instead, which the reader thread fills regardless of
|
||||
/// subscribers. If the handle is already unknown, resolves immediately so the
|
||||
/// worker proceeds to reap.
|
||||
async fn wait_for_eof(pty: Arc<dyn PtyPort>, handle: PtyHandle) {
|
||||
let Ok(stream) = pty.subscribe_output(&handle) else {
|
||||
return;
|
||||
};
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
for _chunk in stream {}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BackgroundTaskRunner for CommandBackgroundRunner {
|
||||
async fn spawn(
|
||||
&self,
|
||||
spec: BackgroundTaskSpec,
|
||||
) -> Result<BackgroundTaskHandle, BackgroundTaskPortError> {
|
||||
let command = spec.command.clone().ok_or_else(|| {
|
||||
BackgroundTaskPortError::Runner(
|
||||
"command-backed runner requires a command spec".to_owned(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let pty_handle = self
|
||||
.pty
|
||||
.spawn(command.clone(), DEFAULT_SIZE)
|
||||
.await
|
||||
.map_err(|e| BackgroundTaskPortError::Runner(format!("pty spawn failed: {e}")))?;
|
||||
|
||||
self.specs
|
||||
.lock()
|
||||
.expect("runner spec registry poisoned")
|
||||
.insert(spec.task_id, command);
|
||||
|
||||
let control = TaskControl {
|
||||
pty_handle,
|
||||
cancel: Arc::new(tokio::sync::Notify::new()),
|
||||
cancelled: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
self.running
|
||||
.lock()
|
||||
.expect("runner registry poisoned")
|
||||
.insert(spec.task_id, control.clone());
|
||||
|
||||
tokio::spawn(Self::run_to_completion(
|
||||
Arc::clone(&self.pty),
|
||||
Arc::clone(&self.clock),
|
||||
self.completion_tx.clone(),
|
||||
Arc::clone(&self.running),
|
||||
spec.task_id,
|
||||
control,
|
||||
spec.deadline_ms,
|
||||
));
|
||||
|
||||
// Touch `now_ms` only to keep the clock wired for future scheduling; the
|
||||
// authoritative timestamps come from the worker at completion.
|
||||
let _ = self.now_ms();
|
||||
Ok(BackgroundTaskHandle {
|
||||
task_id: spec.task_id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn cancel(&self, task_id: TaskId) -> Result<(), BackgroundTaskPortError> {
|
||||
let control = self
|
||||
.running
|
||||
.lock()
|
||||
.expect("runner registry poisoned")
|
||||
.get(&task_id)
|
||||
.cloned();
|
||||
// Unknown task ⇒ already terminal or never ours: cancellation is idempotent.
|
||||
if let Some(control) = control {
|
||||
control.cancelled.store(true, Ordering::SeqCst);
|
||||
control.cancel.notify_one();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn subscribe_completions(&self) -> BackgroundCompletionStream {
|
||||
let rx = self
|
||||
.completion_rx
|
||||
.lock()
|
||||
.expect("runner completion receiver poisoned")
|
||||
.take()
|
||||
.expect("subscribe_completions must be called exactly once");
|
||||
Box::new(rx.into_iter())
|
||||
}
|
||||
}
|
||||
|
||||
impl BackgroundCommandArchive for CommandBackgroundRunner {
|
||||
fn spec_for(&self, task_id: TaskId) -> Option<SpawnSpec> {
|
||||
self.specs
|
||||
.lock()
|
||||
.expect("runner spec registry poisoned")
|
||||
.get(&task_id)
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
233
crates/infrastructure/src/background_task/sink.rs
Normal file
233
crates/infrastructure/src/background_task/sink.rs
Normal file
@ -0,0 +1,233 @@
|
||||
//! Background task completion sink.
|
||||
//!
|
||||
//! The sink is the durable boundary between a runner completion and later
|
||||
//! mailbox/wake work: it writes the terminal task state to the
|
||||
//! [`BackgroundTaskStore`](domain::ports::BackgroundTaskStore) first, then emits
|
||||
//! a lightweight "ready to deliver" signal. It never wakes an agent and never
|
||||
//! enqueues mailbox items; B4/B5 consume the ready signal.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use thiserror::Error;
|
||||
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use domain::ports::{BackgroundTaskCompletion, BackgroundTaskRunner, BackgroundTaskStore};
|
||||
use domain::{
|
||||
AgentInbox, BackgroundTaskPortError, InboxError, InboxItem, InboxItemKind, InboxReceiptStatus,
|
||||
InboxSource, ProjectId, TaskId,
|
||||
};
|
||||
|
||||
/// Signal emitted only after a completion has been persisted.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackgroundTaskReadyToDeliver {
|
||||
/// Persisted task id.
|
||||
pub task_id: TaskId,
|
||||
/// Owning project, copied from the persisted task.
|
||||
pub project_id: ProjectId,
|
||||
/// Agent that owns the completion delivery.
|
||||
pub owner_agent_id: domain::AgentId,
|
||||
}
|
||||
|
||||
/// Outcome of processing one completion event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BackgroundCompletionSinkOutcome {
|
||||
/// Completion was persisted and signalled as ready to deliver.
|
||||
PersistedAndSignalled(BackgroundTaskReadyToDeliver),
|
||||
/// Task was already terminal, so the duplicate completion was ignored.
|
||||
IgnoredAlreadyTerminal {
|
||||
/// Ignored task id.
|
||||
task_id: TaskId,
|
||||
},
|
||||
/// Another completion for this task was already processed by this sink.
|
||||
IgnoredDuplicate {
|
||||
/// Ignored task id.
|
||||
task_id: TaskId,
|
||||
},
|
||||
/// The task no longer exists in the store.
|
||||
IgnoredMissingTask {
|
||||
/// Ignored task id.
|
||||
task_id: TaskId,
|
||||
},
|
||||
}
|
||||
|
||||
/// Errors raised by the completion sink.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum BackgroundCompletionSinkError {
|
||||
/// Store operation failed.
|
||||
#[error("background completion store failed: {0}")]
|
||||
Store(#[from] BackgroundTaskPortError),
|
||||
/// The ready-to-deliver channel is closed.
|
||||
#[error("background completion ready signal channel is closed")]
|
||||
ReadySignalClosed,
|
||||
}
|
||||
|
||||
/// Consumes runner completions, persists terminal state, then signals delivery.
|
||||
pub struct BackgroundCompletionSink {
|
||||
store: Arc<dyn BackgroundTaskStore>,
|
||||
ready: UnboundedSender<BackgroundTaskReadyToDeliver>,
|
||||
processed: Mutex<HashSet<TaskId>>,
|
||||
}
|
||||
|
||||
/// Handle for the ready-to-inbox bridge task.
|
||||
pub type BackgroundReadyInboxBridgeHandle = JoinHandle<()>;
|
||||
|
||||
impl BackgroundCompletionSink {
|
||||
/// Builds a sink from a task store and ready-to-deliver channel.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn BackgroundTaskStore>,
|
||||
ready: UnboundedSender<BackgroundTaskReadyToDeliver>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
ready,
|
||||
processed: Mutex::new(HashSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts consuming [`BackgroundTaskRunner::subscribe_completions`].
|
||||
///
|
||||
/// The domain completion stream is a blocking iterator, so consumption runs on
|
||||
/// a blocking task and re-enters the current Tokio runtime for persistence.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if called outside a Tokio runtime.
|
||||
#[must_use]
|
||||
pub fn start_from_runner(
|
||||
self: Arc<Self>,
|
||||
runner: Arc<dyn BackgroundTaskRunner>,
|
||||
) -> JoinHandle<()> {
|
||||
let mut stream = runner.subscribe_completions();
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
while let Some(completion) = stream.next() {
|
||||
let _ = runtime.block_on(self.process_completion(completion));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Processes a single completion event.
|
||||
///
|
||||
/// Order is strict: `store.save(terminal_task)` must succeed before the ready
|
||||
/// signal is sent. If persistence fails, no ready signal is emitted and the
|
||||
/// task id is released so a later retry can persist it.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`BackgroundCompletionSinkError`] if the store fails, the task transition
|
||||
/// is invalid, or the ready channel is closed after persistence.
|
||||
pub async fn process_completion(
|
||||
&self,
|
||||
completion: BackgroundTaskCompletion,
|
||||
) -> Result<BackgroundCompletionSinkOutcome, BackgroundCompletionSinkError> {
|
||||
{
|
||||
let mut processed = self.processed.lock().await;
|
||||
if processed.contains(&completion.task_id) {
|
||||
return Ok(BackgroundCompletionSinkOutcome::IgnoredDuplicate {
|
||||
task_id: completion.task_id,
|
||||
});
|
||||
}
|
||||
processed.insert(completion.task_id);
|
||||
}
|
||||
|
||||
let outcome = self.persist_and_signal(completion.clone()).await;
|
||||
if outcome.is_err() {
|
||||
self.processed.lock().await.remove(&completion.task_id);
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
async fn persist_and_signal(
|
||||
&self,
|
||||
completion: BackgroundTaskCompletion,
|
||||
) -> Result<BackgroundCompletionSinkOutcome, BackgroundCompletionSinkError> {
|
||||
let Some(task) = self.store.get(completion.task_id).await? else {
|
||||
return Ok(BackgroundCompletionSinkOutcome::IgnoredMissingTask {
|
||||
task_id: completion.task_id,
|
||||
});
|
||||
};
|
||||
if task.is_terminal() {
|
||||
return Ok(BackgroundCompletionSinkOutcome::IgnoredAlreadyTerminal {
|
||||
task_id: completion.task_id,
|
||||
});
|
||||
}
|
||||
|
||||
let terminal = task.complete(completion.result).map_err(|e| {
|
||||
BackgroundCompletionSinkError::Store(BackgroundTaskPortError::Invalid(e.to_string()))
|
||||
})?;
|
||||
self.store.save(&terminal).await?;
|
||||
|
||||
let ready = BackgroundTaskReadyToDeliver {
|
||||
task_id: terminal.id,
|
||||
project_id: terminal.project_id,
|
||||
owner_agent_id: terminal.owner_agent_id,
|
||||
};
|
||||
self.ready
|
||||
.send(ready.clone())
|
||||
.map_err(|_| BackgroundCompletionSinkError::ReadySignalClosed)?;
|
||||
Ok(BackgroundCompletionSinkOutcome::PersistedAndSignalled(
|
||||
ready,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the B3→B4 bridge: persisted completions become inbox items.
|
||||
///
|
||||
/// Overflow of completion/system items is intentionally non-fatal: the completion
|
||||
/// is already durable and remains delivery-pending for boot reconcile.
|
||||
#[must_use]
|
||||
pub fn start_background_ready_inbox_bridge(
|
||||
mut ready: UnboundedReceiver<BackgroundTaskReadyToDeliver>,
|
||||
inbox: Arc<dyn AgentInbox>,
|
||||
) -> BackgroundReadyInboxBridgeHandle {
|
||||
tokio::spawn(async move {
|
||||
while let Some(ready) = ready.recv().await {
|
||||
let item = InboxItem {
|
||||
id: domain::TicketId::new_random(),
|
||||
agent_id: ready.owner_agent_id,
|
||||
source: InboxSource::BackgroundTask {
|
||||
task_id: ready.task_id,
|
||||
},
|
||||
kind: InboxItemKind::BackgroundCompletion,
|
||||
body: format!("Background task {} completed.", ready.task_id),
|
||||
created_at_ms: now_ms(),
|
||||
correlation_id: Some(ready.task_id.to_string()),
|
||||
};
|
||||
match inbox.enqueue_message(ready.owner_agent_id, item) {
|
||||
Ok(receipt) if receipt.status == InboxReceiptStatus::Deferred => {
|
||||
application::diag!(
|
||||
"[background-task] completion deferred: task={} owner={} queue_depth={}",
|
||||
ready.task_id,
|
||||
ready.owner_agent_id,
|
||||
receipt.depth
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(InboxError::InboxFull { .. }) => {
|
||||
application::diag!(
|
||||
"[background-task] completion inbox full but durable: task={} owner={}",
|
||||
ready.task_id,
|
||||
ready.owner_agent_id
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
application::diag!(
|
||||
"[background-task] completion inbox enqueue failed: task={} owner={} err={err}",
|
||||
ready.task_id,
|
||||
ready.owner_agent_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
149
crates/infrastructure/src/background_task/tail.rs
Normal file
149
crates/infrastructure/src/background_task/tail.rs
Normal file
@ -0,0 +1,149 @@
|
||||
//! UTF-8-safe bounded output tail (B8 sub-task 1).
|
||||
//!
|
||||
//! A background command can print megabytes; the completion contract only keeps a
|
||||
//! bounded *tail* (the most recent bytes). [`BoundedTail`] is a byte ring buffer
|
||||
//! capped at construction; [`bounded_tail`] renders the retained bytes as a
|
||||
//! `String`, trimming any partial UTF-8 sequence at the front so a multi-byte
|
||||
//! character never gets sliced by the cap.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use domain::background_task::BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES;
|
||||
|
||||
/// Default tail cap when `IDEA_BG_TAIL_BYTES` is unset (8 KiB).
|
||||
const DEFAULT_TAIL_BYTES: usize = 8 * 1024;
|
||||
/// Floor so a hostile/typo'd env value can't disable the tail entirely.
|
||||
const MIN_TAIL_BYTES: usize = 1024;
|
||||
|
||||
/// Resolves the tail cap in bytes from `IDEA_BG_TAIL_BYTES`.
|
||||
///
|
||||
/// Defaults to 8 KiB, floored at 1 KiB, and clamped to the domain maximum
|
||||
/// ([`BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES`], 16 KiB) so the persisted result
|
||||
/// always validates.
|
||||
#[must_use]
|
||||
pub fn tail_cap_bytes() -> usize {
|
||||
std::env::var("IDEA_BG_TAIL_BYTES")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.unwrap_or(DEFAULT_TAIL_BYTES)
|
||||
.clamp(MIN_TAIL_BYTES, BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES)
|
||||
}
|
||||
|
||||
/// A byte ring buffer that retains only the last `cap` bytes pushed into it.
|
||||
#[derive(Debug)]
|
||||
pub struct BoundedTail {
|
||||
buf: VecDeque<u8>,
|
||||
cap: usize,
|
||||
}
|
||||
|
||||
impl BoundedTail {
|
||||
/// Builds a tail retaining at most `cap` bytes (a `cap` of `0` retains
|
||||
/// nothing but never panics).
|
||||
#[must_use]
|
||||
pub fn with_cap(cap: usize) -> Self {
|
||||
Self {
|
||||
buf: VecDeque::new(),
|
||||
cap,
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends a chunk, dropping the oldest bytes past the cap.
|
||||
pub fn push(&mut self, chunk: &[u8]) {
|
||||
if self.cap == 0 {
|
||||
return;
|
||||
}
|
||||
// If the incoming chunk alone exceeds the cap, only its tail can survive.
|
||||
let start = chunk.len().saturating_sub(self.cap);
|
||||
self.buf.extend(&chunk[start..]);
|
||||
let overflow = self.buf.len().saturating_sub(self.cap);
|
||||
if overflow > 0 {
|
||||
self.buf.drain(0..overflow);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether any byte is retained.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.buf.is_empty()
|
||||
}
|
||||
|
||||
/// Renders the retained bytes as a UTF-8 string, dropping a leading partial
|
||||
/// multi-byte sequence left dangling by the cap. Returns `None` when empty.
|
||||
#[must_use]
|
||||
pub fn to_tail_string(&self) -> Option<String> {
|
||||
if self.buf.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bytes: Vec<u8> = self.buf.iter().copied().collect();
|
||||
Some(bounded_tail(&bytes, self.cap))
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the last `cap` bytes of `bytes` as a lossless-at-the-boundary
|
||||
/// `String`.
|
||||
///
|
||||
/// The slice is taken from the end, then any partial UTF-8 sequence at the *front*
|
||||
/// (a continuation byte with no lead byte, because the cap cut mid-character) is
|
||||
/// skipped so the result is valid UTF-8 without replacement characters at the
|
||||
/// seam. Invalid bytes in the interior are still replaced lossily.
|
||||
#[must_use]
|
||||
pub fn bounded_tail(bytes: &[u8], cap: usize) -> String {
|
||||
let start = bytes.len().saturating_sub(cap);
|
||||
let mut slice = &bytes[start..];
|
||||
// Only realign when we actually truncated the front (start > 0): otherwise a
|
||||
// legitimately leading continuation byte is just invalid input, handled below.
|
||||
if start > 0 {
|
||||
let mut skip = 0;
|
||||
while skip < slice.len() && is_utf8_continuation(slice[skip]) {
|
||||
skip += 1;
|
||||
}
|
||||
slice = &slice[skip..];
|
||||
}
|
||||
String::from_utf8_lossy(slice).into_owned()
|
||||
}
|
||||
|
||||
/// Whether `b` is a UTF-8 continuation byte (`10xxxxxx`).
|
||||
const fn is_utf8_continuation(b: u8) -> bool {
|
||||
b & 0b1100_0000 == 0b1000_0000
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_only_the_last_cap_bytes() {
|
||||
let mut tail = BoundedTail::with_cap(4);
|
||||
tail.push(b"abcdef");
|
||||
assert_eq!(tail.to_tail_string().as_deref(), Some("cdef"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_bigger_than_cap_keeps_tail() {
|
||||
let mut tail = BoundedTail::with_cap(3);
|
||||
tail.push(b"0123456789");
|
||||
assert_eq!(tail.to_tail_string().as_deref(), Some("789"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_tail_is_none() {
|
||||
let tail = BoundedTail::with_cap(8);
|
||||
assert!(tail.is_empty());
|
||||
assert_eq!(tail.to_tail_string(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_multibyte_char_is_trimmed_at_front() {
|
||||
// "é" is 0xC3 0xA9. Cap of 1 would keep only the trailing 0xA9 (a lone
|
||||
// continuation byte); it must be dropped, yielding an empty string.
|
||||
let bytes = "é".as_bytes();
|
||||
assert_eq!(bounded_tail(bytes, 1), "");
|
||||
// Cap of 2 keeps the whole character.
|
||||
assert_eq!(bounded_tail(bytes, 2), "é");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interior_is_preserved() {
|
||||
assert_eq!(bounded_tail("héllo".as_bytes(), 100), "héllo");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user