Files
IdeA/crates/application/src/workstate/actions.rs

318 lines
11 KiB
Rust

//! Agent-level controlled actions over live sessions (Lot D).
//!
//! Companion to the read-only work-state model: these use cases let the UI drive
//! an **already-running** agent's live session by **agent id**, never by spawning a
//! new process. [`AttachLiveAgent`] rebinds the hosting cell ("a cell is a view");
//! [`StopLiveAgent`] tears a PTY live session down or cancels the current structured
//! turn while preserving the structured session. Neither touches the agent entity, its tickets, the
//! conversation summaries, the handoff or the provider sessions.
use std::sync::Arc;
use async_trait::async_trait;
use domain::input::InputMediator;
use domain::{AgentId, NodeId, Project, RuntimeAgentKey, SessionId};
use crate::error::AppError;
use crate::orchestrator::OrchestratorService;
use crate::terminal::{
stop_live_agent_runtime_in_project, CloseTerminal, LiveRuntimeState, LiveSessionKind,
LiveSessions,
};
/// Input for [`AttachLiveAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttachLiveAgentInput {
/// The owning project (boundary/symmetry; the registries are global by agent).
pub project: Project,
/// The already-running agent whose live session is rebound.
pub agent_id: AgentId,
/// The layout leaf that should host the live session view.
pub node_id: NodeId,
}
/// Output of [`AttachLiveAgent::execute`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AttachLiveAgentOutput {
/// The rebound agent.
pub agent_id: AgentId,
/// The node now hosting the session view.
pub node_id: NodeId,
/// The unchanged live session id.
pub session_id: SessionId,
/// Runtime family that owns the session.
pub kind: LiveSessionKind,
}
/// Rebinds an already-running agent session to a visible layout cell, never
/// spawning a process ("a cell is a view").
///
/// Resolves the live session by agent in the PTY registry first, then the
/// structured one, and updates only the hosting `node_id` (the PTY handle / agent
/// session, its id and scrollback stay intact). Idempotent when the agent is
/// already attached to the same node (the rebind is a no-op that returns the same
/// session). No log/handoff/provider-session is touched.
pub struct AttachLiveAgent {
live: Arc<LiveSessions>,
}
impl AttachLiveAgent {
/// Builds the use case from the aggregated live-session registry.
#[must_use]
pub fn new(live: Arc<LiveSessions>) -> Self {
Self { live }
}
/// Rebinds the agent's live session onto `node_id`.
///
/// # Errors
/// [`AppError::NotFound`] when the agent has no live session in either registry.
pub fn execute(&self, input: AttachLiveAgentInput) -> Result<AttachLiveAgentOutput, AppError> {
// PTY first, then structured (one-live-session-per-agent ⇒ at most one match).
if let Some(session) = self.live.pty.rebind_agent_node_in_project(
input.project.id,
&input.agent_id,
input.node_id,
) {
return Ok(AttachLiveAgentOutput {
agent_id: input.agent_id,
node_id: session.node_id,
session_id: session.id,
kind: LiveSessionKind::Pty,
});
}
if let Some(session) = self.live.structured.rebind_agent_node_in_project(
input.project.id,
&input.agent_id,
input.node_id,
) {
return Ok(AttachLiveAgentOutput {
agent_id: input.agent_id,
node_id: input.node_id,
session_id: session.id(),
kind: LiveSessionKind::Structured,
});
}
Err(AppError::NotFound(format!(
"running session for agent {}",
input.agent_id
)))
}
}
/// Input for [`StopLiveAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StopLiveAgentInput {
/// The owning project (boundary/symmetry; the registries are global by agent).
pub project: Project,
/// The agent whose live session is stopped.
pub agent_id: AgentId,
}
/// Output of [`StopLiveAgent::execute`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StopLiveAgentOutput {
/// The stopped agent.
pub agent_id: AgentId,
/// The id of the session that was torn down.
pub session_id: SessionId,
/// Last known layout node hosting the runtime view.
pub node_id: NodeId,
/// Runtime family that owned the session.
pub kind: LiveSessionKind,
/// Runtime lifecycle projection after the stop request.
pub runtime_state: LiveRuntimeState,
}
/// Best-effort stopper for execution resources owned by one live agent turn.
///
/// Implementations must be idempotent: a missing, already-finished or already-stopped
/// child resource is a successful no-op. Failures are deliberately not propagated by
/// [`StopLiveAgent`], because the product contract is "stop the agent; cascade related
/// work best-effort".
#[async_trait]
pub trait StopAgentExecutionCascade: Send + Sync {
/// Requests cancellation of child execution resources for `agent_id` in `project`.
async fn stop_agent_execution_children(&self, project: &Project, agent_id: AgentId);
}
/// Stops an already-running agent's current work.
///
/// Resolves the session by agent (structured first, then PTY). A structured
/// session keeps its registry entry and receives `cancel_current_turn()`, so the
/// provider conversation/context survives the stop. A PTY session has no finer
/// turn boundary, so it is torn down through the existing [`CloseTerminal`]
/// primitive (registry removal + process kill).
pub struct StopLiveAgent {
live: Arc<LiveSessions>,
close: Arc<CloseTerminal>,
input: Option<Arc<dyn InputMediator>>,
waits: Option<Arc<OrchestratorService>>,
cascade: Option<Arc<dyn StopAgentExecutionCascade>>,
}
impl StopLiveAgent {
/// Builds the use case from the live-session registry and the close primitive.
#[must_use]
pub fn new(live: Arc<LiveSessions>, close: Arc<CloseTerminal>) -> Self {
Self {
live,
close,
input: None,
waits: None,
cascade: None,
}
}
/// Wires cancellation helpers used for user-driven stop cascades.
///
/// Stopping A while A waits on B should stop B too. The orchestrator service is
/// only used as a read-only provider of active wait edges; the input mediator is
/// used to preempt the running turn before the session is torn down.
#[must_use]
pub fn with_cascade(
mut self,
input: Arc<dyn InputMediator>,
waits: Arc<OrchestratorService>,
) -> Self {
self.input = Some(input);
self.waits = Some(waits);
self
}
/// Wires best-effort cancellation of child resources spawned by the running turn.
#[must_use]
pub fn with_execution_cascade(mut self, cascade: Arc<dyn StopAgentExecutionCascade>) -> Self {
self.cascade = Some(cascade);
self
}
/// Stops the agent's current work.
///
/// # Errors
/// - [`AppError::NotFound`] when the agent has no live session,
/// - [`AppError::Process`] when the PTY kill or structured shutdown fails.
pub async fn execute(
&self,
input: StopLiveAgentInput,
) -> Result<StopLiveAgentOutput, AppError> {
self.stop_dependencies(&input.project, input.agent_id)
.await?;
if let Some(mediator) = &self.input {
mediator.preempt(RuntimeAgentKey::new(input.project.id, input.agent_id));
}
if let Some(out) = self
.cancel_structured_turn_in_project(&input.project, input.agent_id)
.await?
{
return Ok(out);
}
self.stop_one_in_project(&input.project, input.agent_id)
.await
}
async fn stop_dependencies(
&self,
project: &Project,
agent_id: AgentId,
) -> Result<(), AppError> {
if let Some(cascade) = &self.cascade {
cascade
.stop_agent_execution_children(project, agent_id)
.await;
}
let Some(waits) = &self.waits else {
return Ok(());
};
for dep in waits.active_wait_dependencies(agent_id) {
if let Some(cascade) = &self.cascade {
cascade.stop_agent_execution_children(project, dep).await;
}
if let Some(mediator) = &self.input {
mediator.preempt(RuntimeAgentKey::new(project.id, dep));
}
if self
.cancel_structured_turn_in_project(project, dep)
.await?
.is_none()
{
let _ = self.stop_one_in_project(project, dep).await;
}
}
Ok(())
}
async fn cancel_structured_turn_in_project(
&self,
project: &Project,
agent_id: AgentId,
) -> Result<Option<StopLiveAgentOutput>, AppError> {
let Some(snapshot) = self
.live
.live_agent_snapshots_for(project.id, &agent_id)
.into_iter()
.find(|snapshot| snapshot.kind == LiveSessionKind::Structured)
else {
return Ok(None);
};
let Some(session) = self
.live
.structured
.session_for_agent_in_project(project.id, &agent_id)
else {
return Ok(None);
};
self.live
.mark_runtime_state(snapshot, LiveRuntimeState::Stopping);
session
.cancel_current_turn()
.await
.map_err(|err| AppError::Process(err.to_string()))?;
self.live
.mark_runtime_state(snapshot, LiveRuntimeState::Stopped);
Ok(Some(StopLiveAgentOutput {
agent_id: snapshot.agent_id,
session_id: snapshot.session_id,
node_id: snapshot.node_id,
kind: snapshot.kind,
runtime_state: LiveRuntimeState::Stopped,
}))
}
async fn stop_one_in_project(
&self,
project: &Project,
agent_id: AgentId,
) -> Result<StopLiveAgentOutput, AppError> {
stop_live_agent_runtime_in_project(project, agent_id, &self.live, &self.close)
.await?
.into_iter()
.next()
.map(|stopped| StopLiveAgentOutput {
agent_id: stopped.agent_id,
session_id: stopped.session_id,
node_id: stopped.node_id,
kind: stopped.kind,
runtime_state: stopped.state,
})
.or_else(|| {
self.live
.runtime_status_for(project.id, &agent_id)
.filter(|status| {
matches!(
status.state,
LiveRuntimeState::Stopping | LiveRuntimeState::Stopped
)
})
.map(|status| StopLiveAgentOutput {
agent_id: status.agent_id,
session_id: status.session_id,
node_id: status.node_id,
kind: status.kind,
runtime_state: status.state,
})
})
.ok_or_else(|| AppError::NotFound(format!("running session for agent {agent_id}")))
}
}