From 7fb9d8bb8624e2ed60482b95ac60dd45b382b27f Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 11 Sep 2026 15:00:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(backend,application,frontend,domain,web-se?= =?UTF-8?q?rver,app-tauri):=20bouton=20Red=C3=A9marrer=20l'agent=20dans=20?= =?UTF-8?q?la=20barre=20sup=C3=A9rieure=20des=20cellules=20=E2=80=94=20#31?= =?UTF-8?q?7=20(QA=20verte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La cellule expose un bouton Redémarrer l'agent valable pour les trois modes de présentation (headless, TUI native, CLI custom), sans jamais prendre la présentation pour vérité runtime. - Application: use case RestartCellAgentRuntime validant le binding exact cellule/runtime via les snapshots live (RestartRuntimeNotFound, ConflictAgentVisibleElsewhere, AgentNotBoundToCell) puis déléguant au scheduler self-restart existant (schedule_cell); CellAgentPresentation purement informationnelle. - Backend/app-tauri/web-server: commande restart_cell_agent sur contrat DTO identique Tauri/HTTP, stop borné par timeout typé (RestartStopTimeout), relance préservant routage TUI natif, géométrie de cellule (node/rows/cols) et conversation; évenements domaine enrichis. - Frontend: bouton dans la barre de cellule avec confirmation, overlay d'état Redémarrage…/échec (libellés par code d'erreur, Fermer/ Réessayer), gardes d'identité de cellule et de restarts externes, gateway/mock/http adapters et ports alignés. - Tests: dto_agents, workstate_actions, contrats web-server, LayoutGrid.cellControlsLayering, adapters agent/mock/http. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/commands.rs | 38 +- crates/app-tauri/src/lib.rs | 1 + crates/app-tauri/tests/dto_agents.rs | 50 +++ crates/application/src/error.rs | 34 ++ crates/application/src/lib.rs | 16 +- .../application/src/orchestrator/service.rs | 16 +- crates/application/src/workstate/actions.rs | 111 +++++- crates/application/src/workstate/mod.rs | 6 +- crates/application/tests/workstate_actions.rs | 160 ++++++++- crates/backend/src/dto.rs | 99 +++++- crates/backend/src/events.rs | 5 + crates/backend/src/lib.rs | 188 ++++++++-- crates/domain/src/events.rs | 2 + crates/web-server/src/lib.rs | 30 ++ frontend/src/adapters/agent.test.ts | 32 ++ frontend/src/adapters/agent.ts | 10 + .../src/adapters/http/agentGateway.test.ts | 39 ++ frontend/src/adapters/http/streamGateways.ts | 10 + frontend/src/adapters/mock/index.ts | 34 ++ frontend/src/adapters/mock/mock.test.ts | 38 +- frontend/src/domain/index.ts | 7 + .../agents/CustomAgentChatView.test.tsx | 1 + .../AnnouncementsProvider.test.tsx | 1 + .../LayoutGrid.cellControlsLayering.test.tsx | 177 ++++++++++ frontend/src/features/layout/LayoutGrid.tsx | 333 +++++++++++++++++- frontend/src/ports/index.ts | 24 ++ 26 files changed, 1404 insertions(+), 58 deletions(-) diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 245dcf3d..668bdf69 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -26,11 +26,12 @@ use application::{ ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, - ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SaveChatDraftInput, - SetActiveLayoutInput, SetAgentMcpToolAllowedInput, SlashCommandEffect, - SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, - UnassignSkillFromAgentInput, UpdateAgentContactableInput, UpdateAgentContextInput, - UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, + ResolveMemoryLinksInput, RestartCellAgentRuntimeInput, RevokeDeviceInput, + RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, + SetAgentMcpToolAllowedInput, SlashCommandEffect, SnapshotRunningAgentsInput, + StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, + UpdateAgentContactableInput, UpdateAgentContextInput, UpdateAgentEffortInput, + UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectOrchestratorInput, UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput, @@ -73,6 +74,7 @@ use crate::dto::{ RemoveProjectResponseDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, + RestartCellAgentRuntimeRequestDto, RestartCellAgentRuntimeResponseDto, RestoreOpenProjectsOutputDto, ResumableAgentListDto, RetryOpenProjectRestoreRequestDto, SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveOpenProjectRestoreOrderRequestDto, @@ -2268,6 +2270,32 @@ pub async fn stop_live_agent( .map_err(ErrorDto::from) } +/// Atomically restarts the runtime bound to an exact visible agent cell. +/// +/// The presentation hint is retained for correlation only. Runtime selection, +/// stop ordering and relaunch are owned by the backend restart coordinator. +#[tauri::command] +pub async fn restart_cell_agent_runtime( + request: RestartCellAgentRuntimeRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + let output = state + .restart_cell_agent_runtime + .execute(RestartCellAgentRuntimeInput { + project, + node_id: parse_node_id(&request.node_id)?, + agent_id: parse_agent_id(&request.agent_id)?, + presentation: request.presentation.into(), + layout_id: request + .layout_id + .as_deref() + .map(parse_layout_id) + .transpose()?, + })?; + Ok(output.into()) +} + /// `read_agent_context` — read an agent's Markdown context. /// /// # Errors diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index a317fc9d..1193d8a9 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -351,6 +351,7 @@ pub fn run() { commands::list_live_agents, commands::attach_live_agent, commands::stop_live_agent, + commands::restart_cell_agent_runtime, commands::read_agent_context, commands::update_agent_context, commands::delete_agent, diff --git a/crates/app-tauri/tests/dto_agents.rs b/crates/app-tauri/tests/dto_agents.rs index 0547f437..fd4368a6 100644 --- a/crates/app-tauri/tests/dto_agents.rs +++ b/crates/app-tauri/tests/dto_agents.rs @@ -741,3 +741,53 @@ fn stop_live_agent_response_serialises_camelcase_with_kind() { assert_eq!(v["alreadyStopped"], false); assert!(v.get("session_id").is_none()); } + +#[test] +fn restart_cell_agent_request_deserialises_all_presentations() { + use app_tauri_lib::dto::{CellAgentPresentationDto, RestartCellAgentRuntimeRequestDto}; + + for (wire, expected) in [ + ("headless", CellAgentPresentationDto::Headless), + ("tui", CellAgentPresentationDto::Tui), + ("customCli", CellAgentPresentationDto::CustomCli), + ("unknown", CellAgentPresentationDto::Unknown), + ] { + let raw = json!({ + "projectId": Uuid::from_u128(1).to_string(), + "nodeId": Uuid::from_u128(2).to_string(), + "agentId": Uuid::from_u128(3).to_string(), + "presentation": wire, + "layoutId": Uuid::from_u128(4).to_string() + }); + let dto: RestartCellAgentRuntimeRequestDto = serde_json::from_value(raw).unwrap(); + assert_eq!(dto.presentation, expected); + assert_eq!(dto.layout_id, Some(Uuid::from_u128(4).to_string())); + } +} + +#[test] +fn restart_cell_agent_response_uses_restart_event_correlation_contract() { + use app_tauri_lib::dto::RestartCellAgentRuntimeResponseDto; + use application::{CellAgentPresentation, LiveSessionKind, RestartCellAgentRuntimeOutput}; + + let agent = AgentId::from_uuid(Uuid::from_u128(11)); + let node = NodeId::from_uuid(Uuid::from_u128(12)); + let layout = domain::LayoutId::from_uuid(Uuid::from_u128(13)); + let dto = RestartCellAgentRuntimeResponseDto::from(RestartCellAgentRuntimeOutput { + restart_id: "restart-317".to_owned(), + agent_id: agent, + node_id: node, + kind: LiveSessionKind::Structured, + presentation: CellAgentPresentation::CustomCli, + layout_id: Some(layout), + }); + + let value = serde_json::to_value(dto).unwrap(); + assert_eq!(value["restartId"], "restart-317"); + assert_eq!(value["status"], "scheduled"); + assert_eq!(value["agentId"], agent.to_string()); + assert_eq!(value["nodeId"], node.to_string()); + assert_eq!(value["kind"], "structured"); + assert_eq!(value["presentation"], "customCli"); + assert_eq!(value["layoutId"], layout.to_string()); +} diff --git a/crates/application/src/error.rs b/crates/application/src/error.rs index d1b19a7d..c8a76d53 100644 --- a/crates/application/src/error.rs +++ b/crates/application/src/error.rs @@ -73,6 +73,36 @@ pub enum AppError { 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 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 @@ -120,6 +150,10 @@ impl AppError { 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::TargetReturnedNoReply(_) => "TARGET_RETURNED_NO_REPLY", Self::TargetCeilingActive(_) => "RENDEZVOUS_CEILING_ACTIVE", Self::Internal(_) => "INTERNAL", diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 35891527..5a193e9b 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -280,11 +280,13 @@ pub use window::{ pub use workstate::{ AgentBackgroundTaskState, AgentTicketState, AgentWorkState, AppExitWorkGuardDetail, AppExitWorkGuardState, AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, - BackgroundTaskKindLabel, ConversationLogProvider, ConversationPreviewStatus, - ConversationTurnWorkPreview, ConversationWorkSummary, GetAppExitWorkGuardState, - GetAppExitWorkGuardStateInput, GetLiveStateLean, GetProjectWorkState, GetProjectWorkStateInput, - LeanLiveEntry, LeanLiveState, LiveWorkSession, ProjectWorkState, ReconcileLiveState, - ReconcileLiveStateInput, StopAgentExecutionCascade, StopAgentRuntime, StopLiveAgent, - StopLiveAgentInput, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, UpdateLiveState, - UpdateLiveStateInput, LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS, + BackgroundTaskKindLabel, CellAgentPresentation, ConversationLogProvider, + ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary, + GetAppExitWorkGuardState, GetAppExitWorkGuardStateInput, GetLiveStateLean, GetProjectWorkState, + GetProjectWorkStateInput, LeanLiveEntry, LeanLiveState, LiveWorkSession, ProjectWorkState, + ReconcileLiveState, ReconcileLiveStateInput, RestartCellAgentRuntime, + RestartCellAgentRuntimeInput, RestartCellAgentRuntimeOutput, StopAgentExecutionCascade, + StopAgentRuntime, StopLiveAgent, StopLiveAgentInput, StopLiveAgentOutput, TicketWorkSource, + TicketWorkStatus, UpdateLiveState, UpdateLiveStateInput, LIVE_STATE_MAX_ENTRIES, + LIVE_STATE_TTL_MS, }; diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index f1cda8e7..c432ead7 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -32,7 +32,7 @@ use domain::ports::{ use domain::project::ProjectPath; use domain::{ AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskRendezvousLink, - BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, DomainEvent, + BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, DomainEvent, NodeId, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project, RendezvousId, RuntimeAgentKey, TaskId, }; @@ -83,6 +83,20 @@ pub trait AgentSelfRestartScheduler: Send + Sync { agent: AgentId, reason: Option, ) -> Result; + + /// Schedules the same restart while pinning it to a caller-validated cell. + /// Implementations must revalidate the binding atomically with their + /// concurrency guard before accepting the request. + fn schedule_cell( + &self, + project: &Project, + agent: AgentId, + node: NodeId, + reason: Option, + ) -> Result { + let _ = node; + self.schedule(project, agent, reason) + } } /// Internal continuation prompt used when a self-restart is handed back to the diff --git a/crates/application/src/workstate/actions.rs b/crates/application/src/workstate/actions.rs index 180c4ce1..464a4d5a 100644 --- a/crates/application/src/workstate/actions.rs +++ b/crates/application/src/workstate/actions.rs @@ -12,10 +12,10 @@ use std::sync::Arc; use async_trait::async_trait; use domain::input::InputMediator; use domain::live_state::WorkStatus; -use domain::{AgentId, NodeId, Project, RuntimeAgentKey, SessionId}; +use domain::{AgentId, LayoutId, NodeId, Project, RuntimeAgentKey, SessionId}; use crate::error::AppError; -use crate::orchestrator::{LiveStateProvider, OrchestratorService}; +use crate::orchestrator::{AgentSelfRestartScheduler, LiveStateProvider, OrchestratorService}; use crate::terminal::{ stop_live_agent_runtime_in_project, CloseTerminal, LiveSessionKind, LiveSessions, }; @@ -122,6 +122,113 @@ pub struct StopLiveAgentOutput { pub already_stopped: bool, } +/// Caller-side indication of how the hosting cell presents the agent. +/// +/// This value is never trusted to select the runtime: the live registries and +/// the launch context remain authoritative. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CellAgentPresentation { + /// Agent driven without an interactive human surface. + Headless, + /// Native provider terminal/TUI. + Tui, + /// IdeA structured/custom CLI chat. + CustomCli, + /// Older caller that cannot identify its presentation. + Unknown, +} + +/// Input for [`RestartCellAgentRuntime::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RestartCellAgentRuntimeInput { + /// Owning project resolved by the driving adapter. + pub project: Project, + /// Exact leaf whose runtime is being restarted. + pub node_id: NodeId, + /// Agent expected to be bound to `node_id`. + pub agent_id: AgentId, + /// Informational UI presentation; never used as runtime truth. + pub presentation: CellAgentPresentation, + /// Optional layout identity supplied by clients that already have it. + pub layout_id: Option, +} + +/// Immediate acknowledgement for an accepted atomic cell restart. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RestartCellAgentRuntimeOutput { + /// Restart correlation id used by the existing restart event stream. + pub restart_id: String, + /// Validated agent identity. + pub agent_id: AgentId, + /// Validated hosting node. + pub node_id: NodeId, + /// Actual runtime family resolved from the live registry. + pub kind: LiveSessionKind, + /// Informational presentation echoed for caller correlation. + pub presentation: CellAgentPresentation, + /// Optional layout identity echoed for caller correlation. + pub layout_id: Option, +} + +/// Atomically validates a cell/runtime binding and schedules its replacement +/// through IdeA's existing restart coordinator. +pub struct RestartCellAgentRuntime { + live: Arc, + scheduler: Arc, +} + +impl RestartCellAgentRuntime { + /// Builds the use case over the authoritative runtime registry and restart seam. + #[must_use] + pub fn new(live: Arc, scheduler: Arc) -> Self { + Self { live, scheduler } + } + + /// Validates the exact live binding before the scheduler repeats that check + /// under its per-agent concurrency guard and accepts the detached restart. + pub fn execute( + &self, + input: RestartCellAgentRuntimeInput, + ) -> Result { + let snapshots = self + .live + .live_agent_snapshots_for(input.project.id, &input.agent_id); + let snapshot = match snapshots.as_slice() { + [] => { + return Err(AppError::RestartRuntimeNotFound { + agent_id: input.agent_id, + }); + } + [snapshot] => *snapshot, + _ => { + return Err(AppError::ConflictAgentVisibleElsewhere { + agent_id: input.agent_id, + }); + } + }; + if snapshot.node_id != input.node_id { + return Err(AppError::AgentNotBoundToCell { + agent_id: input.agent_id, + node_id: input.node_id, + }); + } + let scheduled = self.scheduler.schedule_cell( + &input.project, + input.agent_id, + input.node_id, + Some("redémarrage demandé depuis la cellule".to_owned()), + )?; + Ok(RestartCellAgentRuntimeOutput { + restart_id: scheduled.restart_id, + agent_id: input.agent_id, + node_id: input.node_id, + kind: snapshot.kind, + presentation: input.presentation, + layout_id: input.layout_id, + }) + } +} + /// Best-effort stopper for execution resources owned by one live agent turn. /// /// Implementations must be idempotent: a missing, already-finished or already-stopped diff --git a/crates/application/src/workstate/mod.rs b/crates/application/src/workstate/mod.rs index 6245053e..15f4be7c 100644 --- a/crates/application/src/workstate/mod.rs +++ b/crates/application/src/workstate/mod.rs @@ -10,8 +10,10 @@ mod live; mod reconcile; pub use actions::{ - AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, StopAgentExecutionCascade, - StopAgentRuntime, StopLiveAgent, StopLiveAgentInput, StopLiveAgentOutput, + AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, CellAgentPresentation, + RestartCellAgentRuntime, RestartCellAgentRuntimeInput, RestartCellAgentRuntimeOutput, + StopAgentExecutionCascade, StopAgentRuntime, StopLiveAgent, StopLiveAgentInput, + StopLiveAgentOutput, }; pub use live::{ GetLiveStateLean, LeanLiveEntry, LeanLiveState, UpdateLiveState, UpdateLiveStateInput, diff --git a/crates/application/tests/workstate_actions.rs b/crates/application/tests/workstate_actions.rs index 5411fab8..cda9cebd 100644 --- a/crates/application/tests/workstate_actions.rs +++ b/crates/application/tests/workstate_actions.rs @@ -11,9 +11,10 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use application::{ - AttachLiveAgent, AttachLiveAgentInput, CloseTerminal, LiveRuntimeState, LiveSessionKind, - LiveSessions, StopAgentExecutionCascade, StopLiveAgent, StopLiveAgentInput, StructuredSessions, - TerminalSessions, + AgentRestartScheduled, AgentSelfRestartScheduler, AppError, AttachLiveAgent, + AttachLiveAgentInput, CellAgentPresentation, CloseTerminal, LiveRuntimeState, LiveSessionKind, + LiveSessions, RestartCellAgentRuntime, RestartCellAgentRuntimeInput, StopAgentExecutionCascade, + StopLiveAgent, StopLiveAgentInput, StructuredSessions, TerminalSessions, }; use domain::ports::{ AgentSession, AgentSessionError, ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, @@ -21,7 +22,7 @@ use domain::ports::{ }; use domain::Project; use domain::{ - AgentId, NodeId, ProjectId, ProjectPath, PtySize, RemoteRef, SessionId, SessionKind, + AgentId, LayoutId, NodeId, ProjectId, ProjectPath, PtySize, RemoteRef, SessionId, SessionKind, TerminalSession, }; use uuid::Uuid; @@ -131,6 +132,35 @@ struct FakeCascade { calls: Mutex>, } +#[derive(Default)] +struct FakeRestartScheduler { + calls: Mutex>, +} + +impl AgentSelfRestartScheduler for FakeRestartScheduler { + fn schedule( + &self, + _project: &Project, + _agent: AgentId, + _reason: Option, + ) -> Result { + unreachable!("cell restart must use the cell-aware scheduler seam") + } + + fn schedule_cell( + &self, + project: &Project, + agent: AgentId, + node: NodeId, + _reason: Option, + ) -> Result { + self.calls.lock().unwrap().push((project.id, agent, node)); + Ok(AgentRestartScheduled { + restart_id: "restart-cell-test".to_owned(), + }) + } +} + #[async_trait] impl StopAgentExecutionCascade for FakeCascade { async fn stop_agent_execution_children(&self, project: &Project, agent_id: AgentId) { @@ -148,6 +178,8 @@ struct Fixture { pty: Arc, structured: Arc, pty_port: Arc, + restart: RestartCellAgentRuntime, + restart_scheduler: Arc, } fn fixture() -> Fixture { @@ -159,12 +191,32 @@ fn fixture() -> Fixture { Arc::clone(&pty_port) as Arc, Arc::clone(&pty), )); + let restart_scheduler = Arc::new(FakeRestartScheduler::default()); Fixture { attach: AttachLiveAgent::new(Arc::clone(&live)), stop: StopLiveAgent::new(Arc::clone(&live), close), pty, structured, pty_port, + restart: RestartCellAgentRuntime::new( + Arc::clone(&live), + Arc::clone(&restart_scheduler) as Arc, + ), + restart_scheduler, + } +} + +fn restart_input( + agent_id: AgentId, + node_id: NodeId, + presentation: CellAgentPresentation, +) -> RestartCellAgentRuntimeInput { + RestartCellAgentRuntimeInput { + project: project(), + node_id, + agent_id, + presentation, + layout_id: Some(LayoutId::from_uuid(Uuid::from_u128(500))), } } @@ -315,6 +367,106 @@ fn attach_same_node_is_idempotent() { assert_eq!(f.pty.len(), 1, "no extra session created"); } +// --------------------------------------------------------------------------- +// RestartCellAgentRuntime +// --------------------------------------------------------------------------- + +#[test] +fn restart_cell_accepts_tui_and_preserves_exact_binding() { + let f = fixture(); + let agent = aid(10); + let node = nid(100); + insert_pty(&f.pty, sid(1), agent, node); + + let out = f + .restart + .execute(restart_input(agent, node, CellAgentPresentation::Tui)) + .unwrap(); + + assert_eq!(out.restart_id, "restart-cell-test"); + assert_eq!(out.agent_id, agent); + assert_eq!(out.node_id, node); + assert_eq!(out.kind, LiveSessionKind::Pty); + assert_eq!(out.presentation, CellAgentPresentation::Tui); + assert_eq!( + *f.restart_scheduler.calls.lock().unwrap(), + vec![(project().id, agent, node)] + ); +} + +#[test] +fn restart_cell_accepts_structured_custom_cli_and_headless_hints() { + for presentation in [ + CellAgentPresentation::CustomCli, + CellAgentPresentation::Headless, + ] { + let f = fixture(); + let agent = aid(10); + let node = nid(100); + insert_structured(&f.structured, sid(2), agent, node); + + let out = f + .restart + .execute(restart_input(agent, node, presentation)) + .unwrap(); + + assert_eq!(out.kind, LiveSessionKind::Structured); + assert_eq!(out.presentation, presentation); + assert_eq!(f.restart_scheduler.calls.lock().unwrap().len(), 1); + } +} + +#[test] +fn restart_cell_rejects_stale_agent_or_node_without_scheduling() { + let f = fixture(); + let agent = aid(10); + insert_pty(&f.pty, sid(1), agent, nid(100)); + + let error = f + .restart + .execute(restart_input( + agent, + nid(999), + CellAgentPresentation::Unknown, + )) + .unwrap_err(); + + assert_eq!(error.code(), "AGENT_NOT_BOUND_TO_CELL"); + assert!(f.restart_scheduler.calls.lock().unwrap().is_empty()); +} + +#[test] +fn restart_cell_rejects_missing_runtime_without_scheduling() { + let f = fixture(); + let error = f + .restart + .execute(restart_input( + aid(999), + nid(100), + CellAgentPresentation::Unknown, + )) + .unwrap_err(); + + assert_eq!(error.code(), "RUNTIME_NOT_FOUND"); + assert!(f.restart_scheduler.calls.lock().unwrap().is_empty()); +} + +#[test] +fn restart_cell_rejects_agent_visible_in_multiple_live_cells() { + let f = fixture(); + let agent = aid(10); + insert_pty(&f.pty, sid(1), agent, nid(100)); + insert_structured(&f.structured, sid(2), agent, nid(200)); + + let error = f + .restart + .execute(restart_input(agent, nid(100), CellAgentPresentation::Tui)) + .unwrap_err(); + + assert_eq!(error.code(), "CONFLICT_AGENT_VISIBLE_ELSEWHERE"); + assert!(f.restart_scheduler.calls.lock().unwrap().is_empty()); +} + // --------------------------------------------------------------------------- // StopLiveAgent // --------------------------------------------------------------------------- diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index bb2e18f0..bede122c 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -12,13 +12,14 @@ use serde_json::Value; use application::{ AgentBackgroundTaskState, AgentTicketState, AppError, AppExitWorkGuardDetail, - AppExitWorkGuardState, AttachLiveAgentOutput, BackgroundTaskKindLabel, + AppExitWorkGuardState, AttachLiveAgentOutput, BackgroundTaskKindLabel, CellAgentPresentation, ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary, CreateProjectInput, CreateProjectOutput, ExecuteSlashCommandOutput, GitGraphOutput, HealthInput, HealthReport, LayoutKind, ListProjectsOutput, ListSlashCommandsOutput, LiveRuntimeState, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState, - RestoreOpenProjectFailure, RestoreOpenProjectsOutput, SlashCommandEffect, StopLiveAgentOutput, - TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView, + RestartCellAgentRuntimeOutput, RestoreOpenProjectFailure, RestoreOpenProjectsOutput, + SlashCommandEffect, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, TurnPage, + TurnSource, TurnView, }; use domain::ports::{ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage}; use domain::{ @@ -4611,6 +4612,98 @@ pub struct StopLiveAgentRequestDto { pub reason: Option, } +/// Informational presentation supplied by the cell that requests a restart. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CellAgentPresentationDto { + /// Background/headless orchestration surface. + Headless, + /// Native interactive provider terminal. + Tui, + /// IdeA structured/custom CLI chat. + CustomCli, + /// Presentation cannot be determined by the caller. + Unknown, +} + +impl From for CellAgentPresentation { + fn from(value: CellAgentPresentationDto) -> Self { + match value { + CellAgentPresentationDto::Headless => Self::Headless, + CellAgentPresentationDto::Tui => Self::Tui, + CellAgentPresentationDto::CustomCli => Self::CustomCli, + CellAgentPresentationDto::Unknown => Self::Unknown, + } + } +} + +impl From for CellAgentPresentationDto { + fn from(value: CellAgentPresentation) -> Self { + match value { + CellAgentPresentation::Headless => Self::Headless, + CellAgentPresentation::Tui => Self::Tui, + CellAgentPresentation::CustomCli => Self::CustomCli, + CellAgentPresentation::Unknown => Self::Unknown, + } + } +} + +/// Request for an atomic restart of the runtime bound to one agent cell. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RestartCellAgentRuntimeRequestDto { + /// Owning project registry id. + pub project_id: String, + /// Exact hosting layout leaf. + pub node_id: String, + /// Agent expected to be bound to the leaf. + pub agent_id: String, + /// Informational presentation; the backend resolves the real runtime. + #[serde(default = "unknown_cell_agent_presentation")] + pub presentation: CellAgentPresentationDto, + /// Optional named layout id when already known by the caller. + #[serde(default)] + pub layout_id: Option, +} + +fn unknown_cell_agent_presentation() -> CellAgentPresentationDto { + CellAgentPresentationDto::Unknown +} + +/// Immediate acknowledgement for an accepted cell restart. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RestartCellAgentRuntimeResponseDto { + /// Correlation id used by `agentRestart*` events. + pub restart_id: String, + /// Stable acknowledgement state. + pub status: &'static str, + /// Validated agent id. + pub agent_id: String, + /// Validated node id. + pub node_id: String, + /// Actual live runtime family (`pty` or `structured`). + pub kind: LiveWorkSessionKindDto, + /// Informational presentation echoed to the caller. + pub presentation: CellAgentPresentationDto, + /// Optional layout id echoed to the caller. + pub layout_id: Option, +} + +impl From for RestartCellAgentRuntimeResponseDto { + fn from(out: RestartCellAgentRuntimeOutput) -> Self { + Self { + restart_id: out.restart_id, + status: "scheduled", + agent_id: out.agent_id.to_string(), + node_id: out.node_id.to_string(), + kind: out.kind.into(), + presentation: out.presentation.into(), + layout_id: out.layout_id.map(|id| id.to_string()), + } + } +} + /// Response DTO for `stop_live_agent`: the torn-down session's coordinates. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/backend/src/events.rs b/crates/backend/src/events.rs index 1fb72dd5..bca64a90 100644 --- a/crates/backend/src/events.rs +++ b/crates/backend/src/events.rs @@ -139,6 +139,7 @@ pub enum DomainEventDto { agent_id: String, restart_id: String, phase: String, + code: String, message: String, }, /// A project was created. @@ -844,11 +845,13 @@ impl From<&DomainEvent> for DomainEventDto { agent_id, restart_id, phase, + code, message, } => Self::AgentRestartFailed { agent_id: agent_id.to_string(), restart_id: restart_id.clone(), phase: restart_phase_label(*phase).to_owned(), + code: code.clone(), message: message.clone(), }, DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated { @@ -1427,11 +1430,13 @@ mod tests { agent_id: id, restart_id: "restart-288".to_owned(), phase: domain::events::AgentRestartPhase::Launching, + code: "LAUNCH_FAILED".to_owned(), message: "launch failed".to_owned(), }); let failed = serde_json::to_value(failed).expect("serialisable"); assert_eq!(failed["type"], "agentRestartFailed"); assert_eq!(failed["phase"], "launching"); + assert_eq!(failed["code"], "LAUNCH_FAILED"); assert_eq!(failed["message"], "launch failed"); } diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 49f3f7fc..b28bb08f 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -51,21 +51,22 @@ use application::{ ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin, ReloadPluginInput, RemoveProjectFromRegistry, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions, - ResolveAgentSystemPermissions, ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenProjects, - RestoreOpenProjectsOutput, RestoreOpenWindows, RetryBackgroundTask, RetryOpenProjectRestore, - ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft, - SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveOpenProjectRestoreOrder, - SaveProfile, ScheduledProjectTaskService, SessionLimitService, SetActiveLayout, - SetAgentMcpToolAllowed, SetPluginEnabled, SetProjectPluginEnabled, SlashCommandRegistry, - SnapshotOpenWindows, SnapshotRunningAgents, SnapshotRunningAgentsInput, SpawnBackgroundCommand, - StopAgentRuntime, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, - SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, - UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContactable, - UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, - UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, - UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectOrchestrator, - UpdateProjectPermissions, UpdateProjectSettings, UpdateProjectSystemPermissions, UpdateSkill, - UpdateTemplate, UpdateUserSettings, WakeSessionProvider, WriteMemory, WriteToTerminal, + ResolveAgentSystemPermissions, ResolveManagedTemp, ResolveMemoryLinks, RestartCellAgentRuntime, + RestoreOpenProjects, RestoreOpenProjectsOutput, RestoreOpenWindows, RetryBackgroundTask, + RetryOpenProjectRestore, ReviewPluginPackage, RevokeAllDevices, RevokeDevice, + RotateConversationLog, SaveChatDraft, SaveEmbedderProfile, SaveModelServer, + SaveOpenCodeProviderProfile, SaveOpenProjectRestoreOrder, SaveProfile, + ScheduledProjectTaskService, SessionLimitService, SetActiveLayout, SetAgentMcpToolAllowed, + SetPluginEnabled, SetProjectPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, + SnapshotRunningAgents, SnapshotRunningAgentsInput, SpawnBackgroundCommand, StopAgentRuntime, + StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, + TerminalSessions, TouchDevice, UnassignSkillFromAgent, UnassignTicketFromSprint, + UninstallPlugin, UnlinkIssues, UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, + UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions, + UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, + UpdateProjectMcpToolPermissions, UpdateProjectOrchestrator, UpdateProjectPermissions, + UpdateProjectSettings, UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, + UpdateUserSettings, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; @@ -1090,6 +1091,19 @@ struct AppAgentSelfRestarter { rendezvous: Arc, } +async fn await_cell_restart_stop( + agent_id: AgentId, + timeout: std::time::Duration, + stop: F, +) -> Result +where + F: std::future::Future>, +{ + tokio::time::timeout(timeout, stop) + .await + .map_err(|_| AppError::RestartStopTimeout { agent_id })? +} + fn self_restart_launch_input( context: ResumeContext, agent_id: AgentId, @@ -1215,6 +1229,41 @@ mod self_restart_contract_tests { assert!(!input.fresh_start); } + #[test] + fn relaunch_preserves_native_tui_routing_and_cell_geometry() { + let project = Project::new( + ProjectId::from_uuid(Uuid::from_u128(317)), + "restart-tui", + domain::project::ProjectPath::new("/tmp/idea-restart-tui").unwrap(), + domain::remote::RemoteRef::local(), + 0, + ) + .unwrap(); + let agent = AgentId::from_uuid(Uuid::from_u128(1)); + let node = domain::NodeId::from_uuid(Uuid::from_u128(2)); + let input = self_restart_launch_input( + ResumeContext { + project, + rows: 42, + cols: 132, + node_id: Some(node), + conversation_id: Some("conversation-tui".to_owned()), + require_structured: false, + }, + agent, + None, + ); + + assert_eq!(input.node_id, Some(node)); + assert_eq!((input.rows, input.cols), (42, 132)); + assert_eq!( + input.terminal_interface, + Some(domain::DefaultAgentTerminalInterface::NativeTui) + ); + assert!(!input.require_structured); + assert!(!input.fresh_start); + } + #[tokio::test] async fn human_turn_ignores_old_terminal_and_accepts_replacement() { let mailbox = Arc::new(InMemoryMailbox::new()); @@ -1287,30 +1336,67 @@ mod self_restart_contract_tests { assert_eq!(input.busy_state(key), domain::input::AgentBusyState::Idle); assert_eq!(mailbox.pending(&key), 0); } + + #[tokio::test] + async fn cell_restart_stop_timeout_is_typed_and_never_reaches_the_next_step() { + let agent = AgentId::from_uuid(Uuid::from_u128(317)); + let reached_after_stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let result: Result<(), AppError> = await_cell_restart_stop( + agent, + std::time::Duration::from_millis(1), + std::future::pending(), + ) + .await; + if result.is_ok() { + reached_after_stop.store(true, std::sync::atomic::Ordering::SeqCst); + } + + assert_eq!(result.unwrap_err().code(), "STOP_TIMEOUT"); + assert!(!reached_after_stop.load(std::sync::atomic::Ordering::SeqCst)); + } } -impl AgentSelfRestartScheduler for AppAgentSelfRestarter { - fn schedule( +impl AppAgentSelfRestarter { + fn schedule_restart( &self, project: &Project, agent: AgentId, + requested_node: Option, reason: Option, ) -> Result { let key = domain::RuntimeAgentKey::new(project.id, agent); let snapshots = self.live.live_agent_snapshots_for(project.id, &agent); if snapshots.is_empty() { - return Err(AppError::Invalid(format!( - "requester agent {agent} is not live" - ))); + return if requested_node.is_some() { + Err(AppError::RestartRuntimeNotFound { agent_id: agent }) + } else { + Err(AppError::Invalid(format!( + "requester agent {agent} is not live" + ))) + }; + } + if let Some(node_id) = requested_node { + if snapshots.len() != 1 { + return Err(AppError::ConflictAgentVisibleElsewhere { agent_id: agent }); + } + if snapshots[0].node_id != node_id { + return Err(AppError::AgentNotBoundToCell { + agent_id: agent, + node_id, + }); + } } { let mut active = self.active.lock().map_err(|_| { AppError::Internal("agent restart registry lock poisoned".to_owned()) })?; if !active.insert(key) { - return Err(AppError::Invalid(format!( - "a restart is already scheduled for agent {agent}" - ))); + let message = format!("a restart is already scheduled for agent {agent}"); + return if requested_node.is_some() { + Err(AppError::Conflict(message)) + } else { + Err(AppError::Invalid(message)) + }; } } @@ -1346,29 +1432,36 @@ impl AgentSelfRestartScheduler for AppAgentSelfRestarter { }; let context = contexts.lock().ok().and_then(|map| map.get(&key).cloned()); let snapshot = snapshots[0]; - let context = context.unwrap_or(ResumeContext { + let mut context = context.unwrap_or(ResumeContext { project: project.clone(), rows: 24, cols: 80, - node_id: None, + node_id: Some(snapshot.node_id), conversation_id: live .structured .meta_for_session(&snapshot.session_id) .and_then(|(_, _, _, conversation)| conversation), require_structured: snapshot.kind == application::LiveSessionKind::Structured, }); + if let Some(node_id) = requested_node { + context.node_id = Some(node_id); + } publish_phase(AgentRestartPhase::Stopping); if !delegated { input.preempt(key); } - let stop_result = application::terminal::stop_live_agent_runtime_in_project( + let stop = application::terminal::stop_live_agent_runtime_in_project( &project, agent, live.as_ref(), close_terminal.as_ref(), - ) - .await; + ); + let stop_result = if requested_node.is_some() { + await_cell_restart_stop(agent, std::time::Duration::from_secs(10), stop).await + } else { + stop.await + }; publish_phase(AgentRestartPhase::ReleasingInput); if !delegated { @@ -1382,10 +1475,19 @@ impl AgentSelfRestartScheduler for AppAgentSelfRestarter { if delegated { rendezvous.fail(key, &restart_id_task, error.to_string()); } + let code = match (&error, phase) { + (AppError::RestartStopTimeout { .. }, _) => "STOP_TIMEOUT", + (_, AgentRestartPhase::Stopping | AgentRestartPhase::ReleasingInput) => { + "STOP_FAILED" + } + (_, AgentRestartPhase::Launching) => "LAUNCH_FAILED", + (_, AgentRestartPhase::Resuming) => "RESUME_FAILED", + }; events.publish(DomainEvent::AgentRestartFailed { agent_id: agent, restart_id: restart_id_task.clone(), phase, + code: code.to_owned(), message: error.to_string(), }); }; @@ -1395,7 +1497,7 @@ impl AgentSelfRestartScheduler for AppAgentSelfRestarter { } }; match stop_result { - Ok(stopped) if !stopped.is_empty() => {} + Ok(stopped) if !stopped.is_empty() || requested_node.is_some() => {} Ok(_) => { fail( AgentRestartPhase::Stopping, @@ -1541,6 +1643,27 @@ impl AgentSelfRestartScheduler for AppAgentSelfRestarter { } } +impl AgentSelfRestartScheduler for AppAgentSelfRestarter { + fn schedule( + &self, + project: &Project, + agent: AgentId, + reason: Option, + ) -> Result { + self.schedule_restart(project, agent, None, reason) + } + + fn schedule_cell( + &self, + project: &Project, + agent: AgentId, + node: domain::NodeId, + reason: Option, + ) -> Result { + self.schedule_restart(project, agent, Some(node), reason) + } +} + /// Guard for a direct human turn sent to a structured agent session. /// /// Direct chat adapters (`agent_send`, web `chat.send`) still drive the provider @@ -2111,6 +2234,8 @@ pub struct BackendCore { pub attach_live_agent: Arc, /// Tears down an already-running agent's live session by agent id (Lot D). pub stop_live_agent: Arc, + /// Atomically restarts the runtime bound to an exact visible agent cell. + pub restart_cell_agent_runtime: Arc, /// Best-effort inspection of a conversation (last topic + token indicator) /// for the resume popup (T7). Optional/extensible: backed by a `Vec` of /// [`domain::ports::SessionInspector`]s; an empty/missing match yields empty @@ -3976,6 +4101,10 @@ impl BackendCore { active: Arc::new(Mutex::new(HashSet::new())), rendezvous: Arc::clone(&restart_rendezvous), }) as Arc; + let restart_cell_agent_runtime = Arc::new(RestartCellAgentRuntime::new( + Arc::clone(&live_sessions), + Arc::clone(&self_restart), + )); // Drain du scheduler (§21.5-b) : à chaque réveil tiré, `TokioScheduler` pousse une // `ScheduledTask::ResumeAgent` ; on l'exécute via le service (relance + AgentResumed). // Patron `sweep_stalled` : spawner detache (pas `tokio::spawn` — `build` @@ -4333,6 +4462,7 @@ impl BackendCore { rotate_conversation_log, attach_live_agent, stop_live_agent, + restart_cell_agent_runtime, inspect_conversation, project_store, read_user_settings, diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 0b026890..abaa5378 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -82,6 +82,8 @@ pub enum DomainEvent { restart_id: String, /// Phase that failed. phase: AgentRestartPhase, + /// Stable machine-readable failure code. + code: String, /// Human-readable failure safe to expose in the UI. message: String, }, diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 082b3339..bcb349d7 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -100,6 +100,7 @@ use backend::dto::{ RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto, RemoveProjectResponseDto, RenameLayoutRequestDto, ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, + RestartCellAgentRuntimeRequestDto, RestartCellAgentRuntimeResponseDto, RestoreOpenProjectsOutputDto, ResumableAgentListDto, RetryOpenProjectRestoreRequestDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveOpenProjectRestoreOrderRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, @@ -3442,6 +3443,9 @@ async fn invoke( "list_resumable_agents" => invoke_list_resumable_agents(&request.args, &state.app).await, "attach_live_agent" => invoke_attach_live_agent(&request.args, &state.app).await, "stop_live_agent" => invoke_stop_live_agent(&request.args, &state.app).await, + "restart_cell_agent_runtime" => { + invoke_restart_cell_agent_runtime(&request.args, &state.app).await + } "create_agent" => invoke_create_agent(&request.args, &state.app).await, "change_agent_profile" => invoke_change_agent_profile(&request.args, &state.app).await, "read_agent_context" => invoke_read_agent_context(&request.args, &state.app).await, @@ -4128,6 +4132,31 @@ async fn invoke_stop_live_agent(args: &Value, state: &BackendCore) -> Result Result { + let request = + required_request::("restart_cell_agent_runtime", args)?; + let project = resolve_project_readonly(&request.project_id, state).await?; + let output = state + .restart_cell_agent_runtime + .execute(application::RestartCellAgentRuntimeInput { + project, + node_id: parse_node_id(&request.node_id)?, + agent_id: parse_agent_id(&request.agent_id)?, + presentation: request.presentation.into(), + layout_id: request + .layout_id + .as_deref() + .map(parse_layout_id) + .transpose()?, + }) + .map(RestartCellAgentRuntimeResponseDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + async fn invoke_create_agent(args: &Value, state: &BackendCore) -> Result { let request = required_request::("create_agent", args)?; let project = resolve_project_readonly(&request.project_id, state).await?; @@ -10748,6 +10777,7 @@ mod tests { "list_resumable_agents", "attach_live_agent", "stop_live_agent", + "restart_cell_agent_runtime", "create_agent", "change_agent_profile", "read_agent_context", diff --git a/frontend/src/adapters/agent.test.ts b/frontend/src/adapters/agent.test.ts index 775a423e..d5d2cabd 100644 --- a/frontend/src/adapters/agent.test.ts +++ b/frontend/src/adapters/agent.test.ts @@ -97,6 +97,38 @@ describe("TauriAgentGateway invoke payloads", () => { expect(out.stopped[0]?.sessionId).toBe("session-4"); }); + it("restart_cell_agent_runtime forwards the exact cell presentation", async () => { + const response = { + restartId: "restart-1", + status: "scheduled", + agentId: "agent-2", + nodeId: "node-3", + kind: "structured", + presentation: "customCli", + layoutId: "layout-4", + }; + invoke.mockResolvedValueOnce(response); + + await expect( + new TauriAgentGateway().restartCellAgentRuntime({ + projectId: "proj-1", + nodeId: "node-3", + agentId: "agent-2", + presentation: "customCli", + layoutId: "layout-4", + }), + ).resolves.toEqual(response); + expect(invoke).toHaveBeenCalledWith("restart_cell_agent_runtime", { + request: { + projectId: "proj-1", + nodeId: "node-3", + agentId: "agent-2", + presentation: "customCli", + layoutId: "layout-4", + }, + }); + }); + it("change_agent_profile wraps all five fields in the request DTO (camelCase)", async () => { invoke.mockResolvedValueOnce({ agent: { id: "agent-2" } }); await new TauriAgentGateway().changeAgentProfile( diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index 89b9f9dd..ed0a65a4 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -36,6 +36,8 @@ import type { OpenTerminalOptions, ReattachAgentChatResult, ReattachResult, + RestartCellAgentRuntimeInput, + RestartCellAgentRuntimeScheduled, SendAgentChatOptions, StoppedLiveAgent, TerminalHandle, @@ -105,6 +107,14 @@ export class TauriAgentGateway implements AgentGateway { }); } + restartCellAgentRuntime( + input: RestartCellAgentRuntimeInput, + ): Promise { + return invoke("restart_cell_agent_runtime", { + request: input, + }); + } + createAgent(projectId: string, input: CreateAgentInput): Promise { // The `create_agent` command takes a single `request` DTO; `projectId` must // live *inside* it (camelCase), not at the top level. diff --git a/frontend/src/adapters/http/agentGateway.test.ts b/frontend/src/adapters/http/agentGateway.test.ts index 7d3cd030..e985c9d1 100644 --- a/frontend/src/adapters/http/agentGateway.test.ts +++ b/frontend/src/adapters/http/agentGateway.test.ts @@ -141,6 +141,45 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => { }); }); + it("restarts the exact cell runtime through the HTTP command", async () => { + const response = { + restartId: "restart-7", + status: "scheduled", + agentId: "agent-1", + nodeId: "node-2", + kind: "pty", + presentation: "tui", + layoutId: null, + }; + const { gw, calls } = httpAgentGateway(async () => ({ + ok: true, + status: 200, + json: async () => response, + text: async () => JSON.stringify(response), + })); + + await expect( + gw.restartCellAgentRuntime({ + projectId: "proj-1", + nodeId: "node-2", + agentId: "agent-1", + presentation: "tui", + }), + ).resolves.toEqual(response); + const init = calls[0].init as { body: string }; + expect(JSON.parse(init.body)).toEqual({ + command: "restart_cell_agent_runtime", + args: { + request: { + projectId: "proj-1", + nodeId: "node-2", + agentId: "agent-1", + presentation: "tui", + }, + }, + }); + }); + it("updates the project orchestrator through the dedicated request DTO", async () => { const response = { orchestratorAgentId: "agent-2" }; const { gw, calls } = httpAgentGateway(async () => ({ diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index aed3057c..72676004 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -53,6 +53,8 @@ import type { OpenTerminalOptions, ReattachAgentChatResult, ReattachResult, + RestartCellAgentRuntimeInput, + RestartCellAgentRuntimeScheduled, SendAgentChatOptions, StoppedLiveAgent, SystemGateway, @@ -221,6 +223,14 @@ export class HttpAgentGateway implements AgentGateway { request: { projectId, agentId, ...(reason ? { reason } : {}) }, }); } + restartCellAgentRuntime( + input: RestartCellAgentRuntimeInput, + ): Promise { + return this.http.invoke( + "restart_cell_agent_runtime", + { request: input }, + ); + } createAgent(projectId: string, input: CreateAgentInput): Promise { return this.http.invoke("create_agent", { request: { diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 49ba4fcc..7ad51936 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -164,6 +164,8 @@ import type { PluginWorkspaceWriteTextInput, ReattachResult, ReattachAgentChatResult, + RestartCellAgentRuntimeInput, + RestartCellAgentRuntimeScheduled, RemoteGateway, ReviewPluginPackageInput, SetProjectPluginEnabledInput, @@ -406,6 +408,7 @@ export class MockAgentGateway implements AgentGateway { private contexts = new Map(); /** Monotonic session counter for deterministic session ids in tests. */ private sessionSeq = 0; + private restartSeq = 0; /** Live agent PTY sessions, kept across detach so reattach can find them. */ private sessions = new Map(); /** @@ -568,6 +571,37 @@ export class MockAgentGateway implements AgentGateway { return stopped; } + async restartCellAgentRuntime( + input: RestartCellAgentRuntimeInput, + ): Promise { + const known = this.getAgents(input.projectId).some( + (agent) => agent.id === input.agentId, + ); + const sessionId = this.liveSessionByAgent.get(input.agentId); + if (!known || !sessionId) { + throw { + code: "RUNTIME_NOT_FOUND", + message: `agent ${input.agentId} has no live runtime`, + } as GatewayError; + } + if (this.liveByAgent.get(input.agentId) !== input.nodeId) { + throw { + code: "AGENT_NOT_BOUND_TO_CELL", + message: `agent ${input.agentId} is not bound to cell ${input.nodeId}`, + } as GatewayError; + } + this.restartSeq += 1; + return { + restartId: `mock-restart-${this.restartSeq}`, + status: "scheduled", + agentId: input.agentId, + nodeId: input.nodeId, + kind: this.liveKindByAgent.get(input.agentId) ?? "pty", + presentation: input.presentation, + layoutId: input.layoutId ?? null, + }; + } + async createAgent(projectId: string, input: CreateAgentInput): Promise { const list = this.getAgents(projectId); const slug = slugify(input.name) || "agent"; diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index bbfc80e7..61a24090 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect } from "vitest"; import type { DomainEvent } from "@/domain"; import type { Gateways } from "@/ports"; -import { createMockGateways, MockSystemGateway } from "./index"; +import { createMockGateways, MockAgentGateway, MockSystemGateway } from "./index"; // Typechecking this assignment proves every mock implements its port. const gateways: Gateways = createMockGateways(); @@ -90,6 +90,42 @@ describe("MockSystemGateway", () => { }); describe("other mock gateways", () => { + it("schedules a restart only for the agent runtime bound to the exact cell", async () => { + const agentGateway = new MockAgentGateway(); + const agent = await agentGateway.createAgent("p1", { + name: "Worker", + profileId: "profile-1", + }); + await agentGateway.launchAgent( + "p1", + agent.id, + { cwd: "/p1", rows: 24, cols: 80, nodeId: "node-1" }, + () => {}, + ); + + await expect( + agentGateway.restartCellAgentRuntime({ + projectId: "p1", + nodeId: "node-1", + agentId: agent.id, + presentation: "tui", + }), + ).resolves.toMatchObject({ + status: "scheduled", + agentId: agent.id, + nodeId: "node-1", + presentation: "tui", + }); + await expect( + agentGateway.restartCellAgentRuntime({ + projectId: "p1", + nodeId: "stale-node", + agentId: agent.id, + presentation: "tui", + }), + ).rejects.toMatchObject({ code: "AGENT_NOT_BOUND_TO_CELL" }); + }); + it("project.createProject returns a project and listProjects reflects it", async () => { expect(await gateways.project.listProjects()).toEqual([]); const created = await gateways.project.createProject("n", "/root"); diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 17f1945d..250ba853 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -286,6 +286,12 @@ export type AgentRestartPhase = | "launching" | "resuming"; +export type AgentRestartFailureCode = + | "STOP_TIMEOUT" + | "STOP_FAILED" + | "LAUNCH_FAILED" + | "RESUME_FAILED"; + export type DomainEvent = | { type: "projectCreated"; projectId: string } | { type: "pluginInstalled"; pluginId: string; version: string } @@ -341,6 +347,7 @@ export type DomainEvent = agentId: string; restartId: string; phase: AgentRestartPhase; + code: AgentRestartFailureCode; message: string; } | { diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index a1fe0392..e5aa2ec7 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -398,6 +398,7 @@ describe("CustomAgentChatView", () => { agentId: "agent-1", restartId: "restart-2", phase: "launching", + code: "LAUNCH_FAILED", message: "runtime unavailable", }); }); diff --git a/frontend/src/features/announcements/AnnouncementsProvider.test.tsx b/frontend/src/features/announcements/AnnouncementsProvider.test.tsx index 98031ceb..4ed1bcff 100644 --- a/frontend/src/features/announcements/AnnouncementsProvider.test.tsx +++ b/frontend/src/features/announcements/AnnouncementsProvider.test.tsx @@ -361,6 +361,7 @@ describe("#288 — native TUI restart overlay", () => { agentId: "agent-T", restartId: "restart-2", phase: "launching", + code: "LAUNCH_FAILED", message: "runtime unavailable", }); }); diff --git a/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx b/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx index 494314ff..96212146 100644 --- a/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx @@ -194,6 +194,183 @@ function expectedOperation(action: CellAction, cellId: string) { } describe("LayoutGrid — ticket #48 cell control layering", () => { + it("shows restart only for an agent cell and disables its inputs while scheduled", async () => { + const setup = await makeSplitAgentGrid(); + const restart = vi + .spyOn(setup.gateways.agent, "restartCellAgentRuntime") + .mockResolvedValue({ + restartId: "restart-cell-1", + status: "scheduled", + agentId: setup.agentId, + nodeId: setup.bId, + kind: "pty", + presentation: "tui", + layoutId: null, + }); + renderGrid(setup.gateways); + + const button = await screen.findByRole("button", { name: "Redémarrer l’agent" }); + expect(screen.getAllByRole("button", { name: "Redémarrer l’agent" })).toHaveLength(1); + fireEvent.click(button); + + await waitFor(() => + expect(restart).toHaveBeenCalledWith({ + projectId: "p1", + nodeId: setup.bId, + agentId: setup.agentId, + presentation: "tui", + }), + ); + expect( + (screen.getByRole("button", { + name: "Redémarrage de l’agent…", + }) as HTMLButtonElement).disabled, + ).toBe(true); + expect( + (screen.getByLabelText(`agent selector ${setup.bId}`) as HTMLSelectElement) + .disabled, + ).toBe(true); + expect(screen.getByText("Redémarrage de l’agent…")).toBeTruthy(); + + await flush(); + act(() => { + setup.system.emit({ + type: "agentRestartCompleted", + agentId: setup.agentId, + restartId: "restart-cell-1", + }); + }); + await waitFor(() => + expect( + (screen.getByRole("button", { + name: "Redémarrer l’agent", + }) as HTMLButtonElement).disabled, + ).toBe(false), + ); + }); + + it("confirms a busy restart and forwards the custom CLI presentation", async () => { + const setup = await makeSplitAgentGrid(); + window.localStorage.setItem(`idea.agent-cell-mode.p1.${setup.bId}`, "custom"); + setup.workState._setProjectWorkState("p1", { + agents: [ + { + agentId: setup.agentId, + name: "Worker", + profileId: "mock-claude", + busy: { state: "busy", sinceMs: 1 }, + }, + ], + }); + const restart = vi + .spyOn(setup.gateways.agent, "restartCellAgentRuntime") + .mockResolvedValue({ + restartId: "restart-cell-2", + status: "scheduled", + agentId: setup.agentId, + nodeId: setup.bId, + kind: "structured", + presentation: "customCli", + layoutId: null, + }); + renderGrid(setup.gateways); + + fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" })); + const dialog = await screen.findByRole("alertdialog", { + name: "Redémarrer l’agent ?", + }); + expect(restart).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Interrompre et redémarrer" })); + + await waitFor(() => + expect(restart).toHaveBeenCalledWith({ + projectId: "p1", + nodeId: setup.bId, + agentId: setup.agentId, + presentation: "customCli", + }), + ); + expect(dialog.isConnected).toBe(false); + window.localStorage.removeItem(`idea.agent-cell-mode.p1.${setup.bId}`); + }); + + it("distinguishes an asynchronous relaunch failure and offers retry/close", async () => { + const setup = await makeSplitAgentGrid(); + vi.spyOn(setup.gateways.agent, "restartCellAgentRuntime").mockResolvedValue({ + restartId: "restart-cell-3", + status: "scheduled", + agentId: setup.agentId, + nodeId: setup.bId, + kind: "pty", + presentation: "tui", + layoutId: null, + }); + renderGrid(setup.gateways); + fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" })); + await flush(); + + act(() => { + setup.system.emit({ + type: "agentRestartFailed", + agentId: setup.agentId, + restartId: "restart-cell-3", + phase: "launching", + code: "LAUNCH_FAILED", + message: "provider unavailable", + }); + }); + + expect(await screen.findByText("Impossible de relancer l’agent.")).toBeTruthy(); + expect(screen.getByText("provider unavailable")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Fermer" })); + expect(screen.queryByTestId("cell-agent-restart-state")).toBeNull(); + }); + + it("distinguishes an asynchronous stop failure", async () => { + const setup = await makeSplitAgentGrid(); + vi.spyOn(setup.gateways.agent, "restartCellAgentRuntime").mockResolvedValue({ + restartId: "restart-cell-stop", + status: "scheduled", + agentId: setup.agentId, + nodeId: setup.bId, + kind: "pty", + presentation: "tui", + layoutId: null, + }); + renderGrid(setup.gateways); + fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" })); + await flush(); + + act(() => { + setup.system.emit({ + type: "agentRestartFailed", + agentId: setup.agentId, + restartId: "restart-cell-stop", + phase: "stopping", + code: "STOP_TIMEOUT", + message: "stop timed out", + }); + }); + + expect(await screen.findByText("Impossible d’arrêter l’agent.")).toBeTruthy(); + }); + + it("surfaces a synchronous typed binding error", async () => { + const setup = await makeSplitAgentGrid(); + vi.spyOn(setup.gateways.agent, "restartCellAgentRuntime").mockRejectedValue({ + code: "AGENT_NOT_BOUND_TO_CELL", + message: "stale cell binding", + }); + renderGrid(setup.gateways); + fireEvent.click(await screen.findByRole("button", { name: "Redémarrer l’agent" })); + + expect( + await screen.findByText("L’agent n’est plus associé à cette cellule."), + ).toBeTruthy(); + expect(screen.getByText("stale cell binding")).toBeTruthy(); + }); + it("keeps custom CLI mode controls compact inside the cell toolbar (#151)", async () => { const setup = await makeSplitAgentGrid(); diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 92f71e5f..80d2bace 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -275,6 +275,13 @@ interface PendingModeSwitch { target: AgentCellMode; } +interface CellRestartState { + status: "running" | "failed"; + restartId: string | null; + code?: string; + message?: string; +} + interface TrustedCustomCliBinding { agent: Agent; profile: AgentProfile; @@ -382,6 +389,91 @@ function StoppedAgentCellPanel({ ); } +function restartFailureLabel(code?: string): string { + switch (code) { + case "STOP_TIMEOUT": + case "STOP_FAILED": + return "Impossible d’arrêter l’agent."; + case "LAUNCH_FAILED": + case "RESUME_FAILED": + return "Impossible de relancer l’agent."; + case "RUNTIME_NOT_FOUND": + return "Runtime de l’agent introuvable."; + case "AGENT_NOT_BOUND_TO_CELL": + return "L’agent n’est plus associé à cette cellule."; + case "CONFLICT_AGENT_VISIBLE_ELSEWHERE": + return "Cet agent est visible dans une autre cellule."; + case "CONFLICT": + return "Un redémarrage est déjà en cours pour cet agent."; + default: + return "Impossible de redémarrer l’agent."; + } +} + +function CellAgentRestartOverlay({ + state, + onRetry, + onClose, +}: { + state: CellRestartState; + onRetry: () => void; + onClose: () => void; +}) { + const failed = state.status === "failed"; + return ( +
+
+ + {failed ? restartFailureLabel(state.code) : "Redémarrage de l’agent…"} + + {failed && state.message && ( +

+ {state.message} +

+ )} + {failed && ( +
+ + +
+ )} +
+
+ ); +} + /** * Local z-index scale for a single cell (ticket #48). Intra-cell layering only — * unrelated to the global {@link zIndex} scale used for app-level surfaces. @@ -769,6 +861,76 @@ function LeafView({ const busyByWorkState = agentWork?.busy.state === "busy" || Boolean(activeTicket); const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined; const busyActive = announcementBusyActive || busyByWorkState; + const [cellRestart, setCellRestart] = useState(null); + const cellRestartRef = useRef(null); + const cellRestartRequestRef = useRef(0); + const cellRestartOwnedIdRef = useRef(null); + const [restartConfirmationOpen, setRestartConfirmationOpen] = useState(false); + const cellRestartBusy = cellRestart?.status === "running"; + const otherRestartActive = Boolean( + agentRestart && agentRestart.restartId !== cellRestartOwnedIdRef.current, + ); + const cellIdentity = `${projectId}:${layoutId ?? ""}:${id}:${agentId ?? ""}`; + const cellIdentityRef = useRef(cellIdentity); + const previousCellIdentityRef = useRef(cellIdentity); + cellIdentityRef.current = cellIdentity; + + const commitCellRestart = (next: CellRestartState | null) => { + cellRestartRef.current = next; + setCellRestart(next); + }; + + useEffect(() => { + if (previousCellIdentityRef.current === cellIdentity) return; + previousCellIdentityRef.current = cellIdentity; + cellRestartRequestRef.current += 1; + cellRestartOwnedIdRef.current = null; + commitCellRestart(null); + setRestartConfirmationOpen(false); + }, [cellIdentity]); + + async function restartCellAgent(): Promise { + if (!agentId || !agentGateway || cellRestartRef.current?.status === "running") return; + const requestIdentity = cellIdentity; + const sequence = ++cellRestartRequestRef.current; + cellRestartOwnedIdRef.current = null; + const running: CellRestartState = { status: "running", restartId: null }; + commitCellRestart(running); + setRestartConfirmationOpen(false); + try { + const scheduled = await agentGateway.restartCellAgentRuntime({ + projectId, + nodeId: id, + agentId, + presentation: cellMode === "custom" ? "customCli" : "tui", + ...(layoutId ? { layoutId } : {}), + }); + if ( + cellRestartRequestRef.current === sequence && + cellIdentityRef.current === requestIdentity + ) { + cellRestartOwnedIdRef.current = scheduled.restartId; + commitCellRestart({ status: "running", restartId: scheduled.restartId }); + } + } catch (error) { + if ( + cellRestartRequestRef.current === sequence && + cellIdentityRef.current === requestIdentity + ) { + commitCellRestart({ + status: "failed", + restartId: null, + code: gatewayErrorCode(error), + message: describeNotice(error), + }); + } + } + } + + function requestCellAgentRestart(): void { + if (busyActive) setRestartConfirmationOpen(true); + else void restartCellAgent(); + } const targetAnnouncementsFallback = delegatedTicket?.requesterLabel ? `Travaille sur une demande de ${delegatedTicket.requesterLabel}.` : "Travaille sur une demande en cours."; @@ -1042,6 +1204,84 @@ function LeafView({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [liveAgents, agentId, session]); + useEffect(() => { + if (!system || !agentId) return; + let cancelled = false; + let unsubscribe: (() => void) | undefined; + void system + .onDomainEvent((event) => { + if ( + event.type !== "agentRestartRequested" && + event.type !== "agentRestartPhaseChanged" && + event.type !== "agentRestartCompleted" && + event.type !== "agentRestartFailed" + ) return; + if (event.agentId !== agentId) return; + const current = cellRestartRef.current; + if (!current || current.status !== "running") return; + if (event.type === "agentRestartRequested") { + if (!current.restartId) { + cellRestartOwnedIdRef.current = event.restartId; + commitCellRestart({ ...current, restartId: event.restartId }); + } + return; + } + if ( + event.type !== "agentRestartCompleted" && + event.type !== "agentRestartFailed" + ) return; + if (current.restartId && current.restartId !== event.restartId) return; + + if (event.type === "agentRestartFailed") { + cellRestartOwnedIdRef.current = event.restartId; + commitCellRestart({ + status: "failed", + restartId: event.restartId, + code: event.code, + message: event.message, + }); + return; + } + + commitCellRestart(null); + void (async () => { + const live = await agentGateway?.listLiveAgents(projectId); + if (cancelled || !live) return; + setLiveAgents(live); + const restarted = live.find((entry) => entry.agentId === agentId); + if ( + restarted?.kind === "pty" && + restarted.sessionId && + restarted.sessionId !== session + ) { + await vm.attachLiveAgentToCell( + id, + agentId, + restarted.sessionId, + ); + if (!cancelled) setAttachGen((generation) => generation + 1); + } + await refreshWorkState(); + })().catch(() => { + // The restart itself completed; lifecycle refresh remains best-effort. + }); + }) + .then((un) => { + if (cancelled) un(); + else unsubscribe = un; + }) + .catch(() => { + // The synchronous command error remains visible if events are unavailable. + }); + return () => { + cancelled = true; + unsubscribe?.(); + }; + // The subscription follows the exact visible cell identity. Runtime refresh + // functions are deliberately read from that render until the identity changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [system, agentGateway, projectId, layoutId, id, agentId]); + // ── Cell routing (Option 1 — Terminal + MCP) ────────────────────────────── // Every agent cell renders the raw {@link TerminalView}: the human view is the // native interactive PTY (live reasoning + Échap are native CLI behaviours, @@ -1143,6 +1383,7 @@ function LeafView({