Symptôme: stopper un agent (ex Main sur UnityPlugin) laissait des travaux poursuivre, la cascade n'atteignait pas les agents lancés et la pastille restait orange pendant que l'agent continuait à en appeler d'autres. - barrière d'admission par génération d'exécution (ExecutionControl): un stop ferme l'admission de tout le sous-arbre propriété/attente AVANT toute annulation asynchrone — plus aucune nouvelle action inter-agents après stop - arbre de propriété durable en-process (launched_by) + dépendances d'attente actives: la cascade couvre transitivement agents et travaux lancés - commandes d'un agent arrêté rejetées (AppError::AgentStopped); stop lui-même admis pour rester idempotent; réactivation sur relance humaine (génération +1) et enregistrement du propriétaire au spawn - admission exposée aux adaptateurs hors commandes cœur (tickets, templates, plugins) via ensure_agent_execution_active; MCP server/tools, web-server et commandes Tauri alignés - cohérence live-state: statut Idle avec intent « Arrêté » ou « Arrêt incomplet — Certains travaux sont encore actifs » selon l'issue de la cascade; workstate et surfaces UI (AgentsPanel, LayoutGrid, ProjectWorkStatePanel) reflètent l'état réel, plus de pastille orange fantôme - tests: orchestrator_service (cascade/générations/idempotence), workstate_actions, background_task_cancel, error_codes, agents, layout, workstate QA verte sur fix/320-agent-stop-cascade. Aucun push.
356 lines
13 KiB
Rust
356 lines
13 KiB
Rust
//! [`AppError`] — the single error type returned by every use case.
|
|
//!
|
|
//! Per-port errors from the domain ([`domain::ports::FsError`],
|
|
//! [`domain::ports::StoreError`], …) are mapped into this application-level
|
|
//! error so that the presentation layer (Tauri commands) only ever has to deal
|
|
//! with one error shape when building its `ErrorDTO`.
|
|
|
|
use domain::ports::{
|
|
AgentSessionError, ChatAttachmentStoreError, EmbedderError, FsError, GitError, MemoryError,
|
|
ModelServerError, ProcessError, PtyError, RemoteError, RuntimeError, SecretStoreError,
|
|
StoreError,
|
|
};
|
|
use domain::{AgentId, ManagedTempError, NodeId};
|
|
use domain::{IssueStoreError, SprintStoreError};
|
|
|
|
/// Errors surfaced by application use cases.
|
|
///
|
|
/// Each variant carries a stable, machine-readable `code` (see [`AppError::code`])
|
|
/// so the presentation layer can map it to an `ErrorDTO` without string matching.
|
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
|
pub enum AppError {
|
|
/// A requested resource was not found.
|
|
#[error("not found: {0}")]
|
|
NotFound(String),
|
|
|
|
/// The input failed a domain/application invariant.
|
|
#[error("invalid input: {0}")]
|
|
Invalid(String),
|
|
|
|
/// An optimistic-concurrency `if_match` did not match the resource's current
|
|
/// version. Carries the current version so the caller can retry.
|
|
#[error("concurrency conflict: {0}")]
|
|
Conflict(String),
|
|
|
|
/// A filesystem operation failed.
|
|
#[error("filesystem error: {0}")]
|
|
FileSystem(String),
|
|
|
|
/// A persistence (store) operation failed.
|
|
#[error("store error: {0}")]
|
|
Store(String),
|
|
|
|
/// A process/PTY/runtime operation failed.
|
|
#[error("process error: {0}")]
|
|
Process(String),
|
|
|
|
/// A local model server could not be prepared for launch.
|
|
#[error("model server error ({code}): {message}")]
|
|
ModelServer {
|
|
/// Stable model-server error code.
|
|
code: String,
|
|
/// Human-readable message.
|
|
message: String,
|
|
},
|
|
|
|
/// A git operation failed.
|
|
#[error("git error: {0}")]
|
|
Git(String),
|
|
|
|
/// A remote (SSH/WSL) operation failed.
|
|
#[error("remote error: {0}")]
|
|
Remote(String),
|
|
|
|
/// An agent is already running in a live cell and cannot be launched again
|
|
/// (the "one live session per agent" invariant). Reuse goes through
|
|
/// templates (instantiate → distinct agents); IdeA never multi-instances a
|
|
/// single agent.
|
|
#[error("agent {agent_id} is already running in cell {node_id}")]
|
|
AgentAlreadyRunning {
|
|
/// The agent that is already live.
|
|
agent_id: AgentId,
|
|
/// The layout cell (leaf) currently hosting that agent's live session.
|
|
node_id: NodeId,
|
|
},
|
|
|
|
/// No live runtime can be restarted for the requested agent.
|
|
#[error("runtime not found for agent {agent_id}")]
|
|
RestartRuntimeNotFound {
|
|
/// Agent whose runtime was requested.
|
|
agent_id: AgentId,
|
|
},
|
|
|
|
/// The requested cell does not host the requested live agent runtime.
|
|
#[error("agent {agent_id} is not bound to cell {node_id}")]
|
|
AgentNotBoundToCell {
|
|
/// Agent supplied by the caller.
|
|
agent_id: AgentId,
|
|
/// Cell supplied by the caller.
|
|
node_id: NodeId,
|
|
},
|
|
|
|
/// More than one live runtime/cell currently represents the same agent.
|
|
#[error("agent {agent_id} is visible in more than one live cell")]
|
|
ConflictAgentVisibleElsewhere {
|
|
/// Ambiguous agent.
|
|
agent_id: AgentId,
|
|
},
|
|
|
|
/// Runtime teardown did not reach its terminal boundary in time.
|
|
#[error("timed out while stopping agent {agent_id} for restart")]
|
|
RestartStopTimeout {
|
|
/// Agent whose old runtime could not be stopped conclusively.
|
|
agent_id: AgentId,
|
|
},
|
|
|
|
/// The agent execution generation was explicitly stopped. New work from or
|
|
/// toward that generation is rejected until an explicit launch reactivates it.
|
|
#[error("agent {agent_id} is stopped and cannot accept new work")]
|
|
AgentStopped {
|
|
/// Agent protected by the stop barrier.
|
|
agent_id: AgentId,
|
|
},
|
|
|
|
/// The delegation target finished its structured/headless turn without producing
|
|
/// a [`ReplyEvent::Final`](domain::ports::ReplyEvent::Final), so the rendezvous
|
|
/// could not capture an inline answer. Surfaced promptly instead of blocking the
|
|
/// caller until the long safety timeout. **Retryable**: the caller may re-ask for a
|
|
/// concise final response. Carries the target's display name.
|
|
#[error(
|
|
"agent {0} finished without a structured Final response (no inline answer was \
|
|
captured); retry the request and ask for a concise final response"
|
|
)]
|
|
TargetReturnedNoReply(String),
|
|
|
|
/// The delegation target is **still actively working** (its transcript kept
|
|
/// growing across liveness probes) but the rendezvous **absolute ceiling** was
|
|
/// reached, so the synchronous wait was abandoned. **Distinct** from
|
|
/// [`Self::TargetReturnedNoReply`]: the target is *not* silent, so a blind retry
|
|
/// would stack a second heavy turn on a target already mid-task. **Not** a mute
|
|
/// timeout — the caller should check the target's in-progress work/branch (it may
|
|
/// still finish and call `idea_reply`) before re-soliciting lightly. Carries the
|
|
/// target's display name.
|
|
#[error(
|
|
"target {0} is still actively working but the rendezvous ceiling was reached; \
|
|
do not retry blindly — check the target's in-progress work/branch (it may still \
|
|
finish and call idea_reply), then re-solicit lightly if needed"
|
|
)]
|
|
TargetCeilingActive(String),
|
|
|
|
/// An unexpected internal error.
|
|
#[error("internal error: {0}")]
|
|
Internal(String),
|
|
}
|
|
|
|
impl AppError {
|
|
/// A stable, machine-readable code for this error, intended for the
|
|
/// `ErrorDTO` so the frontend can branch without parsing messages.
|
|
#[must_use]
|
|
pub fn code(&self) -> &'static str {
|
|
match self {
|
|
Self::NotFound(_) => "NOT_FOUND",
|
|
Self::Invalid(_) => "INVALID",
|
|
Self::Conflict(_) => "CONFLICT",
|
|
Self::FileSystem(_) => "FILESYSTEM",
|
|
Self::Store(_) => "STORE",
|
|
Self::Process(_) => "PROCESS",
|
|
Self::ModelServer { .. } => "MODEL_SERVER",
|
|
Self::Git(_) => "GIT",
|
|
Self::Remote(_) => "REMOTE",
|
|
Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING",
|
|
Self::RestartRuntimeNotFound { .. } => "RUNTIME_NOT_FOUND",
|
|
Self::AgentNotBoundToCell { .. } => "AGENT_NOT_BOUND_TO_CELL",
|
|
Self::ConflictAgentVisibleElsewhere { .. } => "CONFLICT_AGENT_VISIBLE_ELSEWHERE",
|
|
Self::RestartStopTimeout { .. } => "STOP_TIMEOUT",
|
|
Self::AgentStopped { .. } => "AGENT_STOPPED",
|
|
Self::TargetReturnedNoReply(_) => "TARGET_RETURNED_NO_REPLY",
|
|
Self::TargetCeilingActive(_) => "RENDEZVOUS_CEILING_ACTIVE",
|
|
Self::Internal(_) => "INTERNAL",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<FsError> for AppError {
|
|
fn from(e: FsError) -> Self {
|
|
match e {
|
|
FsError::NotFound(p) => Self::NotFound(p),
|
|
other => Self::FileSystem(other.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<StoreError> for AppError {
|
|
fn from(e: StoreError) -> Self {
|
|
match e {
|
|
StoreError::NotFound => Self::NotFound("store item".to_owned()),
|
|
StoreError::Invalid(message) => Self::Invalid(message),
|
|
other => Self::Store(other.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<IssueStoreError> for AppError {
|
|
fn from(e: IssueStoreError) -> Self {
|
|
match e {
|
|
IssueStoreError::NotFound => Self::NotFound("issue".to_owned()),
|
|
IssueStoreError::VersionConflict { .. } => Self::Invalid(e.to_string()),
|
|
IssueStoreError::Invalid(message) => Self::Invalid(message),
|
|
IssueStoreError::Store(message) => Self::Store(message),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<SprintStoreError> for AppError {
|
|
fn from(e: SprintStoreError) -> Self {
|
|
match e {
|
|
SprintStoreError::NotFound => Self::NotFound("sprint".to_owned()),
|
|
SprintStoreError::VersionConflict { .. } => Self::Invalid(e.to_string()),
|
|
SprintStoreError::Invalid(message) => Self::Invalid(message),
|
|
SprintStoreError::Store(message) => Self::Store(message),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<ChatAttachmentStoreError> for AppError {
|
|
fn from(e: ChatAttachmentStoreError) -> Self {
|
|
match e {
|
|
ChatAttachmentStoreError::NotFound => Self::NotFound("chat attachment".to_owned()),
|
|
ChatAttachmentStoreError::Invalid(message) => Self::Invalid(message),
|
|
ChatAttachmentStoreError::Store(message) => Self::Store(message),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<ManagedTempError> for AppError {
|
|
fn from(e: ManagedTempError) -> Self {
|
|
match e {
|
|
ManagedTempError::NotFound => Self::NotFound("managed temp".to_owned()),
|
|
ManagedTempError::Invalid(message) => Self::Invalid(message),
|
|
ManagedTempError::Store(message) => Self::Store(message),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<MemoryError> for AppError {
|
|
fn from(e: MemoryError) -> Self {
|
|
match e {
|
|
MemoryError::NotFound => Self::NotFound("memory note".to_owned()),
|
|
MemoryError::Frontmatter(m) => Self::Invalid(m),
|
|
other => Self::Store(other.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<EmbedderError> for AppError {
|
|
/// Maps to [`AppError::Store`] — an embedder is a *derived* recall detail; its
|
|
/// failure must degrade the recall (fallback to naïve), never fail hard. This
|
|
/// mapping exists for completeness; recall adapters degrade *before* an
|
|
/// embedder error ever reaches a use case.
|
|
fn from(e: EmbedderError) -> Self {
|
|
Self::Store(e.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<PtyError> for AppError {
|
|
fn from(e: PtyError) -> Self {
|
|
Self::Process(e.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<ProcessError> for AppError {
|
|
fn from(e: ProcessError) -> Self {
|
|
Self::Process(e.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<ModelServerError> for AppError {
|
|
fn from(e: ModelServerError) -> Self {
|
|
Self::ModelServer {
|
|
code: model_server_error_code(&e).to_owned(),
|
|
message: e.to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn model_server_error_code(err: &ModelServerError) -> &'static str {
|
|
match err {
|
|
ModelServerError::NotConfigured => "not_configured",
|
|
ModelServerError::Invalid(_) => "invalid",
|
|
ModelServerError::PermissionDenied(_) => "permission_denied",
|
|
ModelServerError::PathNotAccessible(_) => "path_not_accessible",
|
|
ModelServerError::PortOccupied(_) => "port_occupied",
|
|
ModelServerError::InUse(_) => "model_server_in_use",
|
|
ModelServerError::Probe(_) => "probe",
|
|
ModelServerError::Process(_) => "process",
|
|
ModelServerError::Store(_) => "store",
|
|
ModelServerError::Cancelled => "cancelled",
|
|
ModelServerError::Timeout => "timeout",
|
|
}
|
|
}
|
|
|
|
impl From<RuntimeError> for AppError {
|
|
fn from(e: RuntimeError) -> Self {
|
|
Self::Process(e.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<GitError> for AppError {
|
|
fn from(e: GitError) -> Self {
|
|
Self::Git(e.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<RemoteError> for AppError {
|
|
fn from(e: RemoteError) -> Self {
|
|
Self::Remote(e.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<SecretStoreError> for AppError {
|
|
/// Maps to [`AppError::Store`] — a [`domain::ports::SecretStore`] failure is a
|
|
/// persistence failure like any other store, coherent with [`StoreError`].
|
|
fn from(e: SecretStoreError) -> Self {
|
|
Self::Store(e.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<AgentSessionError> for AppError {
|
|
/// Maps a structured [`AgentSessionError`] (ARCHITECTURE §17.1) onto the single
|
|
/// application error shape. `Start`/`Io`/`Decode`/`Timeout` are all execution
|
|
/// failures of a live structured session (a process/SDK conversation), so they
|
|
/// fold into [`AppError::Process`] — coherent with [`PtyError`]/[`ProcessError`],
|
|
/// the byte-stream twins. The raw CLI JSON never travels through `Decode` (the
|
|
/// adapter already redacted it), so this is safe to surface.
|
|
fn from(e: AgentSessionError) -> Self {
|
|
Self::Process(e.to_string())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// La cible-active-au-plafond porte un code **stable et distinct** du no-reply
|
|
/// (l'appelant peut brancher sans parser le message) et un message « ne retente
|
|
/// pas à l'aveugle » différent du retour-au-prompt-sans-reply.
|
|
#[test]
|
|
fn ceiling_active_code_is_stable_and_distinct() {
|
|
let ceiling = AppError::TargetCeilingActive("architect".to_owned());
|
|
assert_eq!(ceiling.code(), "RENDEZVOUS_CEILING_ACTIVE");
|
|
|
|
let no_reply = AppError::TargetReturnedNoReply("architect".to_owned());
|
|
assert_eq!(no_reply.code(), "TARGET_RETURNED_NO_REPLY");
|
|
|
|
// Deux issues TYPÉES distinctes (le cœur du cadrage Architect) : un plafond
|
|
// atteint sur cible active n'est jamais confondu avec un silence/no-reply.
|
|
assert_ne!(ceiling.code(), no_reply.code());
|
|
// Et distinct d'un timeout muet (`Process` via AgentSessionError::Timeout).
|
|
assert_ne!(
|
|
ceiling.code(),
|
|
AppError::from(AgentSessionError::Timeout).code()
|
|
);
|
|
// Le message guide explicitement vers « ne pas retenter à l'aveugle ».
|
|
assert!(ceiling.to_string().contains("do not retry blindly"));
|
|
}
|
|
}
|