From 63537572b8c1d06d80c849687509287d68c7d96e Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 8 Sep 2026 22:04:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(mcp,orchestrator,frontend):=20tool=20idea?= =?UTF-8?q?=5Frestart=5Fself,=20redemarrage=20autonome=20d'un=20agent=20?= =?UTF-8?q?=E2=80=94=20#288=20(tool=20core=20self-only=20via=20requester?= =?UTF-8?q?=20MCP=20handshake,=20retour=20immediat=20{restartId,=20status:?= =?UTF-8?q?=20scheduled}=20puis=20orchestration=20detachee:=20snapshot=20r?= =?UTF-8?q?eprise,=20stop=20PTY/structured,=20liberation=20Busy/mailbox,?= =?UTF-8?q?=20relance=20LaunchAgent=20meme=20conversation=5Fid=20fresh=5Fs?= =?UTF-8?q?tart=3Dfalse,=20injection=20interne=20invisible;=20events=20dom?= =?UTF-8?q?ain/wire=20agentRestartRequested/PhaseChanged/Completed/Failed?= =?UTF-8?q?=20phases=20stopping/releasingInput/launching/resuming;=20CLI?= =?UTF-8?q?=20custom:=20separateurs=20exacts=20redemarrage=20de=20l'agent?= =?UTF-8?q?=20+=20etat=20termine/echoue,=20deduplication=20(restartId,=20e?= =?UTF-8?q?vent),=20verrouillage=20composer;=20TUI=20native:=20overlay=20A?= =?UTF-8?q?gentRestartOverlay=20sans=20injection=20xterm,=20progress/annon?= =?UTF-8?q?ces=20projetes=20jusqu'a=20idle;=20QA=20verte:=20mcp=5Fserver?= =?UTF-8?q?=20restart=5Fself=201/1,=20restart-self=203/3,=20backend=20self?= =?UTF-8?q?=5Frestart=5Fcontract=20+=20events=20wire=202/2,=20frontend=203?= =?UTF-8?q?=20suites=2092/92,=20npm=20build,=20cargo=20check=20--workspace?= =?UTF-8?q?,=20fmt=20--check,=20diff=20--check;=20reserves=20hors=20perime?= =?UTF-8?q?tre:=20web-server=20103/110=20fixture=20PTY=20preexistante,=203?= =?UTF-8?q?4=20echecs=20npm=20test=20globaux=20listProjectSettings/web=20h?= =?UTF-8?q?ors=20#288;=20scripts/=20hors=20perimetre)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ideai/tickets/288/carnet.md | 49 ++- .ideai/tickets/288/issue.md | 6 +- .ideai/tickets/index.json | 4 +- crates/app-tauri/src/commands.rs | 6 + crates/application/src/lib.rs | 9 +- crates/application/src/orchestrator/mod.rs | 5 +- .../application/src/orchestrator/service.rs | 48 +++ crates/backend/src/events.rs | 99 ++++++ crates/backend/src/lib.rs | 297 +++++++++++++++++- crates/domain/src/events.rs | 49 +++ crates/domain/src/orchestrator.rs | 16 + .../src/orchestrator/mcp/tools.rs | 67 ++++ crates/infrastructure/tests/mcp_server.rs | 56 +++- crates/web-server/src/lib.rs | 6 + frontend/src/domain/index.ts | 26 ++ .../agents/CustomAgentChatView.test.tsx | 86 +++++ .../features/agents/CustomAgentChatView.tsx | 67 +++- .../announcements/AgentRestartOverlay.tsx | 80 +++++ .../AnnouncementsProvider.test.tsx | 86 +++++ .../announcements/AnnouncementsProvider.tsx | 136 +++++++- frontend/src/features/announcements/index.ts | 11 + .../LayoutGrid.cellControlsLayering.test.tsx | 22 ++ frontend/src/features/layout/LayoutGrid.tsx | 13 +- 23 files changed, 1207 insertions(+), 37 deletions(-) create mode 100644 frontend/src/features/announcements/AgentRestartOverlay.tsx diff --git a/.ideai/tickets/288/carnet.md b/.ideai/tickets/288/carnet.md index 9aaf6d20..68346b6a 100644 --- a/.ideai/tickets/288/carnet.md +++ b/.ideai/tickets/288/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#288" -version: 2 -updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1788895080146 +version: 5 +updatedBy: {"kind":"agent","agent_id":"qa"} +updatedAt: 1788897533882 --- #288 — Tool MCP de redemarrage autonome d'un agent @@ -22,5 +22,46 @@ Ajouter un tool MCP permettant a un agent de demander son propre redemarrage apr - Invariant: liberer Busy/mailbox meme si la connexion MCP disparait quand l'agent est tue. - Memoire durable ajoutee: `ticket288-agent-self-restart-architecture`. +## DevBackend — livre +- Tool MCP `idea_restart_self({ reason? })` implemente. +- Identite issue du requester MCP; requester absent/malformed/non live refuse; restart concurrent refuse. +- Retour immediat `scheduled` puis orchestration detachee avec delai de flush avant arret du bridge. +- Snapshot node/conversation/dimensions/mode structure; stop PTY/structured; nettoyage mailbox/Busy; relance via LaunchAgent avec meme conversation_id et fresh_start=false; injection interne invisible. +- Events frontend exposes sur `domain://event`: `agentRestartRequested`, `agentRestartPhaseChanged`, `agentRestartCompleted`, `agentRestartFailed`; phases `stopping`, `releasingInput`, `launching`, `resuming`. +- Fichiers touches annonces: `crates/domain/src/{events,orchestrator}.rs`, `crates/application/src/{lib,orchestrator/mod,orchestrator/service}.rs`, `crates/infrastructure/src/orchestrator/mcp/tools.rs`, `crates/infrastructure/tests/mcp_server.rs`, `crates/backend/src/{lib,events}.rs`, `crates/app-tauri/src/commands.rs`, `crates/web-server/src/lib.rs`. +- Validations DevBackend: domaine orchestrateur 45/45, mapping MCP 27/27, serveur MCP 41/41, orchestrateur applicatif 81/81, backend 87 passants + 7 socket ignores, app-tauri 82 passants + 1 socket ignore, `cargo check --workspace`, `cargo fmt --check`, `git diff --check` verts. +- Reserve: web-server 103/110 en serie; 7 echecs preexistants/fixture PTY `error` au lieu de `terminal.attached`, hors #288 selon DevBackend; un lancement parallele a aussi touche `EMFILE`. + ## Etat -Ticket cree et passe inProgress par Main. En attente branche Git puis implementation DevBackend/DevFrontend et validation QA. +Backend et frontend livres. En attente validation QA. + +## DevFrontend — livre +- Ajout des quatre events de restart au `DomainEvent` frontend et de la phase typee. +- Projection ephemere partagee dans le provider projet, conservee a travers le demontage/remontage de la cellule pendant la relance. +- CLI custom: separateurs UX exacts requested/completed/failed, message d'echec court, deduplication par `(restartId, event type)` et verrouillage du composer durant le cycle. +- TUI native: overlay prioritaire sans injection de separateur dans xterm, libelles de phase exacts et annonces/progress conserves jusqu'au signal idle. L'ordre `busy:false` puis `completed` du runtime structure est traite sans overlay bloque. +- Aucun ajout au transcript: les marqueurs restent un etat de presentation frontend; le prompt interne de reprise n'est pas consomme ni projete par le frontend. +- Tests: provider/overlay (phases, reprise, idle, echec), CLI custom (separateurs, deduplication, echec), integration LayoutGrid (priorite du voile et absence de separateur TUI). +- Validations: `npm run test -- --run src/features/announcements/AnnouncementsProvider.test.tsx src/features/agents/CustomAgentChatView.test.tsx src/features/layout/LayoutGrid.cellControlsLayering.test.tsx` -> 92/92; `npm run build` -> OK; `git diff --check` -> OK. +- Reserve validation globale: `npm test` termine avec 34 echecs hors fichiers #288 (notamment suites web en timeout et tests agents lies au gateway plugin `listProjectSettings` du chantier deja present); les 3 suites ciblees #288 restent vertes. + +## QA — validation independante + +Verdict: VERT pour #288 sur `feature/288-agent-self-restart-mcp`, avec reserves hors perimetre confirmees. + +Commandes executees depuis `/home/anthony/Documents/Projects/IdeA`: +- `git status --short --branch` -> branche `feature/288-agent-self-restart-mcp`, changements non committes dans le perimetre #288 + metadata ticket. +- `cargo test -p infrastructure --test mcp_server restart_self` -> 1/1 passant. +- `cargo test -p infrastructure restart_self` -> 3 tests restart-self passants: mapping/catalogue self-only, requester absent/malforme rejete, payload scheduled via handshake. +- `cargo test -p backend self_restart_contract_tests` -> 1/1 passant; relance conserve `conversation_id` et `fresh_start=false`. +- `cargo test -p backend agent_restart_events_expose_stable_camel_case_contract` -> 1/1 passant; events/phases wire `agentRestartPhaseChanged`, `agentRestartFailed`, `releasingInput`, `launching`. +- `npm run test -- --run src/features/announcements/AnnouncementsProvider.test.tsx src/features/agents/CustomAgentChatView.test.tsx src/features/layout/LayoutGrid.cellControlsLayering.test.tsx` depuis `frontend` -> 3 fichiers / 92 tests passants. +- `npm run build` depuis `frontend` -> OK. +- `cargo check --workspace` -> OK. +- `cargo fmt --check` -> OK. +- `git diff --check` -> OK. + +Reserves verifiees: +- `cargo test -p web-server` en parallele a echoue avec EMFILE (`Too many open files`) sur 38 tests; ce run n'est pas probant pour #288. +- `cargo test -p web-server -- --test-threads=1` reproduit la reserve stable: 103/110, 7 echecs WebSocket/PTY `left: "error" right: "terminal.attached"`; aucun test restart/self-restart dans la liste. +- `npm test` depuis `frontend` reproduit 34 echecs globaux hors fichiers #288: `agents.test.tsx` (`listProjectSettings` absent dans le mock), `ProjectsView.ls7.test.tsx`, `WebAgentCell.test.tsx`, `WebApp.test.tsx`, `WebDesktopWorkspace.test.tsx`, `WebMenus.test.tsx`, `WebTicketsSprints.test.tsx`. Les suites #288 ciblees restent vertes. diff --git a/.ideai/tickets/288/issue.md b/.ideai/tickets/288/issue.md index 0377c111..886d9a32 100644 --- a/.ideai/tickets/288/issue.md +++ b/.ideai/tickets/288/issue.md @@ -2,7 +2,7 @@ id: "3d6c1342-58cc-4bc8-bed5-a176f155b9ef" number: 288 title: "Tool MCP de redémarrage autonome d'un agent" -status: "inProgress" +status: "qa" priority: "high" sprint: null links: [] @@ -11,7 +11,7 @@ attachments: [] createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} createdAt: 1788894923196 -updatedAt: 1788895080146 -version: 2 +updatedAt: 1788897287730 +version: 4 --- Ajouter un outil MCP permettant à un agent de demander son propre redémarrage pour récupérer de nouvelles capacités (ex. plugin ajouté). Comportement attendu : IdeA arrête l'agent demandeur, le relance, lui injecte le contexte/handoff nécessaire pour reprendre son travail, masque le message interne de reprise, affiche dans la CLI custom des séparateurs de redémarrage puis les progress à la suite, et pour la TUI native affiche un overlay pendant le redémarrage puis y projette les progress jusqu'à fin de travail. \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 8343b021..711d34dc 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -3940,7 +3940,7 @@ "issueRef": "#288", "path": "288", "title": "Tool MCP de redémarrage autonome d'un agent", - "status": "inProgress", + "status": "qa", "priority": "high", "sprint": null, "assignedAgentIds": [], @@ -3948,7 +3948,7 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1788895080146 + "updatedAt": 1788897287730 } ] } \ No newline at end of file diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index e141efb5..1b9d5079 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -2243,6 +2243,12 @@ pub async fn launch_agent( project: resume_project, rows: request.rows, cols: request.cols, + node_id, + conversation_id: request + .conversation_id + .clone() + .or_else(|| output.assigned_conversation_id.clone()), + require_structured: request.cell_kind == Some(CellKind::Chat), }, ); } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 5fca08b6..1d2d164d 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -165,10 +165,11 @@ pub use model_server::{ }; pub use orchestrator::{ resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog, - AgentWakeService, AskLivenessProbe, ContextGuardUseCases, LiveStateProvider, - LiveStateReadProvider, McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, - ProposeContext, ReadContext, ReadMemory, RecordTurnProvider, WakeSessionProvider, - WatchdogOutcome, WriteMemory, DEFAULT_RENDEZVOUS_CEILING, DEFAULT_RENDEZVOUS_WINDOW, + AgentRestartScheduled, AgentSelfRestartScheduler, AgentWakeService, AskLivenessProbe, + ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider, + OrchestratorOutcome, OrchestratorService, ProposeContext, ReadContext, ReadMemory, + RecordTurnProvider, WakeSessionProvider, WatchdogOutcome, WriteMemory, + DEFAULT_RENDEZVOUS_CEILING, DEFAULT_RENDEZVOUS_WINDOW, }; pub use permission::{ GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput, diff --git a/crates/application/src/orchestrator/mod.rs b/crates/application/src/orchestrator/mod.rs index ce1a8fae..9d808012 100644 --- a/crates/application/src/orchestrator/mod.rs +++ b/crates/application/src/orchestrator/mod.rs @@ -19,7 +19,8 @@ pub use rendezvous::{ WatchdogOutcome, DEFAULT_RENDEZVOUS_CEILING, DEFAULT_RENDEZVOUS_WINDOW, }; pub use service::{ - AskLivenessProbe, ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, - McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider, + AgentRestartScheduled, AgentSelfRestartScheduler, AskLivenessProbe, ContextGuardUseCases, + LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider, OrchestratorOutcome, + OrchestratorService, RecordTurnProvider, }; pub use wake::{AgentWakeService, WakeSessionProvider}; diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 80ae5693..d64ea14e 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -63,6 +63,25 @@ use crate::workstate::{ GetLiveStateLean, StopAgentExecutionCascade, UpdateLiveState, UpdateLiveStateInput, }; +/// Immediate acknowledgement returned after a self-restart has been accepted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentRestartScheduled { + /// Correlation id emitted on all subsequent restart events. + pub restart_id: String, +} + +/// Composition-root owned scheduler for the destructive, detached restart flow. +/// The application orchestrator only authenticates and dispatches the intent. +pub trait AgentSelfRestartScheduler: Send + Sync { + /// Validates liveness/concurrency synchronously, then owns the detached work. + fn schedule( + &self, + project: &Project, + agent: AgentId, + reason: Option, + ) -> Result; +} + /// Default terminal geometry for an orchestrator-launched agent cell. The UI /// resizes the PTY to the real cell size on attach; these are sane starting rows /// /cols so the spawn never fails on a zero-sized terminal. @@ -685,6 +704,8 @@ pub struct OrchestratorService { /// Use case B8 qui produit une tâche de fond command-backed depuis la surface /// agent MCP `idea_run_in_background`. `None` ⇒ outil non configuré. spawn_background_command: Option>, + /// Detached self-restart coordinator, wired at the composition root. + self_restart: Option>, } /// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés @@ -784,6 +805,7 @@ impl OrchestratorService { background_runner: None, session_limits: None, spawn_background_command: None, + self_restart: None, } } @@ -808,6 +830,13 @@ impl OrchestratorService { self } + /// Wires the requester-scoped detached self-restart coordinator. + #[must_use] + pub fn with_self_restart(mut self, restart: Arc) -> Self { + self.self_restart = Some(restart); + self + } + /// Branche le store des tâches de fond pour tracer les rendez-vous inter-agents /// comme [`BackgroundTaskKind::HeadlessRendezvous`] (B6). /// @@ -1456,6 +1485,25 @@ impl OrchestratorService { command: OrchestratorCommand, ) -> Result { match command { + OrchestratorCommand::RestartSelf { agent, reason } => { + let scheduled = self + .self_restart + .as_ref() + .ok_or_else(|| { + AppError::Invalid("idea_restart_self not configured".to_owned()) + })? + .schedule(project, agent, reason)?; + let reply = serde_json::json!({ + "restartId": scheduled.restart_id, + "status": "scheduled", + "message": "Agent restart scheduled." + }) + .to_string(); + Ok(OrchestratorOutcome { + detail: "agent restart scheduled".to_owned(), + reply: Some(reply), + }) + } OrchestratorCommand::SpawnAgent { name, profile, diff --git a/crates/backend/src/events.rs b/crates/backend/src/events.rs index 9d6734e3..1fb72dd5 100644 --- a/crates/backend/src/events.rs +++ b/crates/backend/src/events.rs @@ -113,6 +113,34 @@ pub struct AgentLaunchFailedDto { #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum DomainEventDto { + /// A requester-scoped agent self-restart was accepted. + #[serde(rename_all = "camelCase")] + AgentRestartRequested { + agent_id: String, + restart_id: String, + reason: Option, + }, + /// A self-restart changed phase. + #[serde(rename_all = "camelCase")] + AgentRestartPhaseChanged { + agent_id: String, + restart_id: String, + phase: String, + }, + /// A self-restart completed. + #[serde(rename_all = "camelCase")] + AgentRestartCompleted { + agent_id: String, + restart_id: String, + }, + /// A self-restart failed after scheduling. + #[serde(rename_all = "camelCase")] + AgentRestartFailed { + agent_id: String, + restart_id: String, + phase: String, + message: String, + }, /// A project was created. #[serde(rename_all = "camelCase")] ProjectCreated { @@ -787,6 +815,42 @@ fn rendezvous_conversation_id(rendezvous: &Option) -> Option< impl From<&DomainEvent> for DomainEventDto { fn from(e: &DomainEvent) -> Self { match e { + DomainEvent::AgentRestartRequested { + agent_id, + restart_id, + reason, + } => Self::AgentRestartRequested { + agent_id: agent_id.to_string(), + restart_id: restart_id.clone(), + reason: reason.clone(), + }, + DomainEvent::AgentRestartPhaseChanged { + agent_id, + restart_id, + phase, + } => Self::AgentRestartPhaseChanged { + agent_id: agent_id.to_string(), + restart_id: restart_id.clone(), + phase: restart_phase_label(*phase).to_owned(), + }, + DomainEvent::AgentRestartCompleted { + agent_id, + restart_id, + } => Self::AgentRestartCompleted { + agent_id: agent_id.to_string(), + restart_id: restart_id.clone(), + }, + DomainEvent::AgentRestartFailed { + agent_id, + restart_id, + phase, + message, + } => Self::AgentRestartFailed { + agent_id: agent_id.to_string(), + restart_id: restart_id.clone(), + phase: restart_phase_label(*phase).to_owned(), + message: message.clone(), + }, DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated { project_id: project_id.to_string(), }, @@ -1312,6 +1376,15 @@ impl From<&DomainEvent> for DomainEventDto { } } +fn restart_phase_label(phase: domain::events::AgentRestartPhase) -> &'static str { + match phase { + domain::events::AgentRestartPhase::Stopping => "stopping", + domain::events::AgentRestartPhase::ReleasingInput => "releasingInput", + domain::events::AgentRestartPhase::Launching => "launching", + domain::events::AgentRestartPhase::Resuming => "resuming", + } +} + #[cfg(test)] mod tests { use super::*; @@ -1336,6 +1409,32 @@ mod tests { ConversationId::from_uuid(uuid::Uuid::from_u128(n)) } + #[test] + fn agent_restart_events_expose_stable_camel_case_contract() { + let id = agent(288); + let phase = DomainEventDto::from(&DomainEvent::AgentRestartPhaseChanged { + agent_id: id, + restart_id: "restart-288".to_owned(), + phase: domain::events::AgentRestartPhase::ReleasingInput, + }); + let phase = serde_json::to_value(phase).expect("serialisable"); + assert_eq!(phase["type"], "agentRestartPhaseChanged"); + assert_eq!(phase["agentId"], id.to_string()); + assert_eq!(phase["restartId"], "restart-288"); + assert_eq!(phase["phase"], "releasingInput"); + + let failed = DomainEventDto::from(&DomainEvent::AgentRestartFailed { + agent_id: id, + restart_id: "restart-288".to_owned(), + phase: domain::events::AgentRestartPhase::Launching, + 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["message"], "launch failed"); + } + #[test] fn model_server_status_changed_relays_ready_to_dto_and_wire() { let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged { diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 0827a400..797a2d50 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -13,16 +13,16 @@ use std::sync::{Arc, Mutex}; use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext; use application::{ - AddIssueAttachment, AgentResumer, AgentWakeService, AllocateManagedTemp, AppError, - AssignIssueAgent, AssignSkillToAgent, AssignTicketToSprint, AssignedSkillResolver, - AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive, BulkDeleteIssues, - BulkUpdateIssuePriority, BulkUpdateIssueStatus, CancelBackgroundTask, ChangeAgentProfile, - CheckEmbedderSuggestion, ClearChatDraft, CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, - CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, - ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, - CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint, CreateTemplate, - DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory, - DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, + AddIssueAttachment, AgentRestartScheduled, AgentResumer, AgentSelfRestartScheduler, + AgentWakeService, AllocateManagedTemp, AppError, AssignIssueAgent, AssignSkillToAgent, + AssignTicketToSprint, AssignedSkillResolver, AttachLiveAgent, AuthenticateSession, + BackgroundCommandArchive, BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus, + CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion, ClearChatDraft, + CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal, + CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, + CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, + CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, + DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand, FirstRunState, GcManagedTemp, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, GetProjectPermissions, @@ -64,6 +64,7 @@ use application::{ WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; +use domain::events::AgentRestartPhase; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore, AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner, @@ -1003,6 +1004,12 @@ pub struct ResumeContext { pub rows: u16, /// Largeur de la cellule au dernier lancement (colonnes PTY). pub cols: u16, + /// Exact hosting node supplied by the last driving launch (`None` = background). + pub node_id: Option, + /// Logical conversation id supplied by the hosting cell. + pub conversation_id: Option, + /// Whether the last driving launch selected the structured chat runtime. + pub require_structured: bool, } /// Registre partagé `(project_id, agent_id) → ResumeContext` (composition root ↔ commande @@ -1030,6 +1037,265 @@ struct AppAgentResumer { contexts: ResumeContexts, } +const SELF_RESTART_PROMPT: &str = + "IdeA vient de te redémarrer. Reprends immédiatement le travail en cours là où tu l'avais laissé."; + +struct AppAgentSelfRestarter { + launch_agent: Arc, + close_terminal: Arc, + live: Arc, + input: Arc, + mailbox: Arc, + events: Arc, + contexts: ResumeContexts, + active: Arc>>, +} + +fn self_restart_launch_input( + context: ResumeContext, + agent_id: AgentId, + mcp_runtime: Option, +) -> LaunchAgentInput { + LaunchAgentInput { + project: context.project, + agent_id, + rows: context.rows, + cols: context.cols, + node_id: context.node_id, + conversation_id: context.conversation_id, + mcp_runtime, + allow_structured_alongside_pty: false, + require_structured: context.require_structured, + fresh_start: false, + managed_temp_exposure: application::ManagedTempExposureRequest::default(), + } +} + +#[cfg(test)] +mod self_restart_contract_tests { + use super::*; + + #[test] + fn relaunch_preserves_conversation_and_never_requests_fresh_start() { + let project = Project::new( + ProjectId::from_uuid(Uuid::from_u128(288)), + "restart", + domain::project::ProjectPath::new("/tmp/idea-restart-contract").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: 31, + cols: 97, + node_id: Some(node), + conversation_id: Some("conversation-288".to_owned()), + require_structured: true, + }, + agent, + None, + ); + + assert_eq!(input.agent_id, agent); + assert_eq!(input.node_id, Some(node)); + assert_eq!(input.conversation_id.as_deref(), Some("conversation-288")); + assert!(input.require_structured); + assert!(!input.fresh_start); + } +} + +impl AgentSelfRestartScheduler for AppAgentSelfRestarter { + fn schedule( + &self, + project: &Project, + agent: AgentId, + 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" + ))); + } + { + 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 restart_id = Uuid::new_v4().to_string(); + self.events.publish(DomainEvent::AgentRestartRequested { + agent_id: agent, + restart_id: restart_id.clone(), + reason, + }); + + let launch_agent = Arc::clone(&self.launch_agent); + let close_terminal = Arc::clone(&self.close_terminal); + let live = Arc::clone(&self.live); + let input = Arc::clone(&self.input); + let mailbox = Arc::clone(&self.mailbox); + let events = Arc::clone(&self.events); + let contexts = Arc::clone(&self.contexts); + let active = Arc::clone(&self.active); + let restart_id_task = restart_id.clone(); + let project = project.clone(); + spawn_detached(async move { + // Leave the MCP server enough time to serialize and flush the scheduled + // acknowledgement before teardown kills the requester's bridge process. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let publish_phase = |phase| { + events.publish(DomainEvent::AgentRestartPhaseChanged { + agent_id: agent, + restart_id: restart_id_task.clone(), + phase, + }); + }; + let context = contexts.lock().ok().and_then(|map| map.get(&key).cloned()); + let snapshot = snapshots[0]; + let context = context.unwrap_or(ResumeContext { + project: project.clone(), + rows: 24, + cols: 80, + node_id: None, + conversation_id: live + .structured + .meta_for_session(&snapshot.session_id) + .and_then(|(_, _, _, conversation)| conversation), + require_structured: snapshot.kind == application::LiveSessionKind::Structured, + }); + + publish_phase(AgentRestartPhase::Stopping); + input.preempt(key); + let stop_result = application::terminal::stop_live_agent_runtime_in_project( + &project, + agent, + live.as_ref(), + close_terminal.as_ref(), + ) + .await; + + publish_phase(AgentRestartPhase::ReleasingInput); + if let Some(ticket) = input.busy_state(key).ticket() { + mailbox.cancel_head(key, ticket); + } + input.mark_idle(key); + + let fail = |phase, error: AppError| { + events.publish(DomainEvent::AgentRestartFailed { + agent_id: agent, + restart_id: restart_id_task.clone(), + phase, + message: error.to_string(), + }); + }; + match stop_result { + Ok(stopped) if !stopped.is_empty() => {} + Ok(_) => { + fail( + AgentRestartPhase::Stopping, + AppError::NotFound(format!("running runtime for agent {agent}")), + ); + active.lock().ok().map(|mut set| set.remove(&key)); + return; + } + Err(error) => { + fail(AgentRestartPhase::Stopping, error); + active.lock().ok().map(|mut set| set.remove(&key)); + return; + } + } + + publish_phase(AgentRestartPhase::Launching); + let mcp_runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime { + exe, + endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id) + .as_cli_arg() + .to_owned(), + project_id: project.id.as_uuid().simple().to_string(), + requester: agent.to_string(), + }); + let launched = launch_agent + .execute(self_restart_launch_input(context, agent, mcp_runtime)) + .await; + let launched = match launched { + Ok(output) => output, + Err(error) => { + fail(AgentRestartPhase::Launching, error); + active.lock().ok().map(|mut set| set.remove(&key)); + return; + } + }; + + publish_phase(AgentRestartPhase::Resuming); + let resume_result = if let Some(session) = live + .structured + .session_for_agent_in_project(project.id, &agent) + { + let turn = StructuredHumanTurnGuard::start( + Arc::clone(&input), + Arc::clone(&mailbox), + project.id, + agent, + SELF_RESTART_PROMPT, + ); + let result = session + .send(SELF_RESTART_PROMPT) + .await + .map(|stream| stream.for_each(drop)) + .map_err(|error| AppError::Process(error.to_string())); + turn.finish(); + result + } else if let Some(handle) = live.pty.handle(&launched.session.id) { + if let Some(profile) = launched.profile.as_ref() { + input.bind_handle_with_submit( + key, + handle, + domain::input::SubmitConfig::new( + profile.submit_sequence.clone(), + profile.submit_delay_ms, + ), + ); + } else { + input.bind_handle(key, handle); + } + let ticket = domain::mailbox::Ticket::new( + domain::mailbox::TicketId::new_random(), + "IdeA", + SELF_RESTART_PROMPT, + ); + let _pending = input.enqueue(key, ticket); + Ok(()) + } else { + Err(AppError::Process(format!( + "agent {agent} has no live input after relaunch" + ))) + }; + + match resume_result { + Ok(()) => events.publish(DomainEvent::AgentRestartCompleted { + agent_id: agent, + restart_id: restart_id_task.clone(), + }), + Err(error) => fail(AgentRestartPhase::Resuming, error), + } + active.lock().ok().map(|mut set| set.remove(&key)); + }); + + Ok(AgentRestartScheduled { restart_id }) + } +} + /// Guard for a direct human turn sent to a structured agent session. /// /// Direct chat adapters (`agent_send`, web `chat.send`) still drive the provider @@ -3230,6 +3496,16 @@ impl BackendCore { Arc::clone(&events_port), resumer, )); + let self_restart = Arc::new(AppAgentSelfRestarter { + launch_agent: Arc::clone(&launch_agent), + close_terminal: Arc::clone(&close_terminal), + live: Arc::clone(&live_sessions), + input: Arc::clone(&input_mediator), + mailbox: Arc::clone(&mailbox), + events: Arc::clone(&events_port), + contexts: Arc::clone(&resume_contexts), + active: Arc::new(Mutex::new(HashSet::new())), + }) as Arc; // 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` @@ -3299,6 +3575,7 @@ impl BackendCore { .with_project_store(Arc::clone(&store_port)) .with_conversations(Arc::clone(&conversation_registry)) .with_events(Arc::clone(&events_port)) + .with_self_restart(self_restart) // Faits OS/runtime (exe $APPIMAGE/current_exe + endpoint loopback) pour // que les (re)lancements issus du chemin `ask` écrivent la déclaration MCP // réelle ⇒ le pont MCP de la cible se spawne et `idea_reply` débloque le round-trip. diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 1ec11984..0b026890 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -49,6 +49,42 @@ pub enum OrchestrationSource { /// channel rather than serialised here. #[derive(Debug, Clone, PartialEq)] pub enum DomainEvent { + /// An agent requested a self-restart through its MCP handshake identity. + AgentRestartRequested { + /// Agent authenticated by the MCP handshake. + agent_id: AgentId, + /// Correlation id shared by all events in this restart. + restart_id: String, + /// Optional model-provided diagnostic reason. + reason: Option, + }, + /// A scheduled self-restart moved to another runtime phase. + AgentRestartPhaseChanged { + /// Restarting agent. + agent_id: AgentId, + /// Restart correlation id. + restart_id: String, + /// Newly entered phase. + phase: AgentRestartPhase, + }, + /// A self-restart relaunched the agent and submitted its internal resume turn. + AgentRestartCompleted { + /// Restarted agent. + agent_id: AgentId, + /// Restart correlation id. + restart_id: String, + }, + /// A self-restart failed after it had already been acknowledged as scheduled. + AgentRestartFailed { + /// Agent whose restart failed. + agent_id: AgentId, + /// Restart correlation id. + restart_id: String, + /// Phase that failed. + phase: AgentRestartPhase, + /// Human-readable failure safe to expose in the UI. + message: String, + }, /// A project was created. ProjectCreated { /// The new project. @@ -667,6 +703,19 @@ pub enum DomainEvent { }, } +/// Stable phases exposed by the agent self-restart event stream. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentRestartPhase { + /// Existing runtime teardown. + Stopping, + /// Busy state and mailbox cleanup. + ReleasingInput, + /// Activation through `LaunchAgent`. + Launching, + /// Submission of the internal continuation turn. + Resuming, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/domain/src/orchestrator.rs b/crates/domain/src/orchestrator.rs index e177b261..328940bf 100644 --- a/crates/domain/src/orchestrator.rs +++ b/crates/domain/src/orchestrator.rs @@ -260,6 +260,13 @@ pub struct OrchestratorRequest { /// proof the request was well-formed (Parse, don't validate). #[derive(Debug, Clone, PartialEq, Eq)] pub enum OrchestratorCommand { + /// Schedule a restart of the MCP requester itself. + RestartSelf { + /// Requesting agent, strictly parsed from the MCP handshake identity. + agent: AgentId, + /// Optional diagnostic reason. It is never used as an identity or target. + reason: Option, + }, /// Create the agent if unknown (with `profile` + optional initial context), /// then launch it — exactly as the UI would. SpawnAgent { @@ -476,6 +483,15 @@ impl OrchestratorRequest { pub fn validate(&self) -> Result { let action = self.action_name()?; match action { + "agent.restart_self" => Ok(OrchestratorCommand::RestartSelf { + agent: self.require_from(action)?, + reason: self + .task + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned), + }), "spawn_agent" => Ok(OrchestratorCommand::SpawnAgent { name: self.require_name(action)?, profile: Some(self.require("profile", action, self.profile.as_deref())?), diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index 247b36b6..785cfb80 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -72,6 +72,7 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[ "idea_run_in_background", "idea_launch_agent", "idea_stop_agent", + "idea_restart_self", "idea_update_context", "idea_context_propose", "idea_context_update", @@ -155,6 +156,7 @@ pub fn tool_returns_reply(tool: &str) -> bool { | "idea_memory_read" | "idea_skill_read" | "idea_run_in_background" + | "idea_restart_self" | "idea_workstate_read" ) || is_ticket_tool(tool) || is_template_tool(tool) @@ -193,6 +195,17 @@ pub fn is_plugin_tool(tool: &str) -> bool { #[must_use] pub fn catalogue() -> Vec { let mut tools = vec![ + ToolDef { + name: "idea_restart_self", + description: "Restart this requesting agent and resume its current work with freshly resolved capabilities.", + input_schema: json!({ + "type": "object", + "properties": { + "reason": { "type": "string" } + }, + "additionalProperties": false + }), + }, ToolDef { name: "idea_project_list", description: "List known IdeA projects for cross-project agent discovery.", @@ -543,6 +556,15 @@ pub fn map_tool_call( // Translate the tool into the wire-level request shape the watcher already // uses (v2 `type`), then let `validate` enforce the invariants once. let request = match name { + "idea_restart_self" => { + reject_unknown_keys(name, args, &["reason"])?; + OrchestratorRequest { + request_type: Some("agent.restart_self".to_owned()), + requested_by: Some(requester.to_owned()), + task: s("reason"), + ..base() + } + } "idea_list_agents" => OrchestratorRequest { request_type: Some("agent.list".to_owned()), ..base() @@ -845,6 +867,51 @@ mod tests { assert!(tool_returns_reply("idea_ask_agents")); } + #[test] + fn restart_self_is_catalogued_and_uses_only_handshake_requester() { + let definition = catalogue() + .into_iter() + .find(|tool| tool.name == "idea_restart_self") + .expect("restart-self tool advertised"); + assert_eq!( + tool_access(definition.name), + Some(McpToolAccess::WriteAction) + ); + assert!(tool_returns_reply(definition.name)); + + let expected = domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()); + assert_eq!( + map_tool_call( + "idea_restart_self", + &json!({ "reason": "plugin installed" }), + REQ, + ) + .unwrap(), + OrchestratorCommand::RestartSelf { + agent: expected, + reason: Some("plugin installed".to_owned()), + } + ); + assert_eq!( + map_tool_call( + "idea_restart_self", + &json!({ "target": "someone-else" }), + REQ, + ), + Err(ToolMapError::BadArguments("idea_restart_self".to_owned())) + ); + } + + #[test] + fn restart_self_rejects_missing_or_malformed_requester() { + for requester in ["", "not-an-agent-id"] { + assert!(matches!( + map_tool_call("idea_restart_self", &json!({}), requester), + Err(ToolMapError::Invalid(_)) + )); + } + } + #[test] fn project_tools_are_classified_and_return_inline_replies() { for tool in [ diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index ec5bd736..cff93377 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -53,8 +53,9 @@ use domain::{ use uuid::Uuid; use application::{ - CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, - OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext, + AgentRestartScheduled, AgentSelfRestartScheduler, AppError, CloseTerminal, + CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, OrchestratorService, + StructuredSessions, TerminalSessions, UpdateAgentContext, }; use infrastructure::orchestrator::mcp::jsonrpc::error_codes; use infrastructure::orchestrator::mcp::tools::classified_tool_names; @@ -578,6 +579,25 @@ fn capturing_events() -> ( (publish, captured) } +#[derive(Default)] +struct FakeSelfRestart { + calls: Mutex)>>, +} + +impl AgentSelfRestartScheduler for FakeSelfRestart { + fn schedule( + &self, + project: &Project, + agent: AgentId, + reason: Option, + ) -> Result { + self.calls.lock().unwrap().push((project.id, agent, reason)); + Ok(AgentRestartScheduled { + restart_id: "restart-test-1".to_owned(), + }) + } +} + // --- JSON-RPC framing helpers --------------------------------------------- /// A `tools/call` request envelope for `tool` with `arguments`. @@ -762,6 +782,7 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { "idea_ask_agent", "idea_ask_agents", "idea_run_in_background", + "idea_restart_self", "idea_launch_agent", "idea_stop_agent", "idea_update_context", @@ -812,8 +833,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { assert!(!names.contains(&"idea_reply")); assert_eq!( tools.len(), - 49, - "exactly the forty-nine exposed idea_* tools; got {names:?}" + 50, + "exactly the fifty exposed idea_* tools; got {names:?}" ); // Every tool advertises an object input schema. @@ -988,6 +1009,33 @@ async fn cross_project_actions_are_advertised_only_when_allowed_and_denial_is_ac // 2. tools/call → the right OrchestratorCommand (observed through the fakes) // --------------------------------------------------------------------------- +#[tokio::test] +async fn restart_self_returns_scheduled_payload_and_uses_handshake_identity() { + let (base, _sessions) = build_service(FakeContexts::new()); + let restart = Arc::new(FakeSelfRestart::default()); + let service = Arc::try_unwrap(base) + .unwrap_or_else(|_| panic!("freshly built service must be uniquely owned")) + .with_self_restart(Arc::clone(&restart) as Arc); + let requester = AgentId::from_uuid(Uuid::from_u128(288)); + let response = server(Arc::new(service)) + .for_requester(requester.to_string()) + .handle_raw(&tools_call( + 288, + "idea_restart_self", + json!({ "reason": "new plugin" }), + )) + .await + .expect("restart call replies before detached teardown"); + let result = response.result.expect("successful tool result"); + let payload: Value = serde_json::from_str(result_text(&result)).unwrap(); + assert_eq!(payload["restartId"], "restart-test-1"); + assert_eq!(payload["status"], "scheduled"); + assert_eq!( + restart.calls.lock().unwrap().as_slice(), + &[(project().id, requester, Some("new plugin".to_owned()))] + ); +} + #[tokio::test] async fn general_agent_without_mcp_override_is_read_only_for_tools_call() { let contexts = FakeContexts::new(); diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index eb9bfbf5..e5826f00 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -1658,6 +1658,12 @@ async fn execute_launch_agent_for_ws( project: resume_project, rows: request.rows, cols: request.cols, + node_id, + conversation_id: request + .conversation_id + .clone() + .or_else(|| output.assigned_conversation_id.clone()), + require_structured: request.cell_kind == Some(backend::dto::CellKind::Chat), }, ); } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 841a820d..64ab0f3d 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -280,6 +280,12 @@ export interface EmbeddedServerStatus { } /** A domain event relayed from the backend (tagged union on `type`). */ +export type AgentRestartPhase = + | "stopping" + | "releasingInput" + | "launching" + | "resuming"; + export type DomainEvent = | { type: "projectCreated"; projectId: string } | { type: "pluginInstalled"; pluginId: string; version: string } @@ -317,6 +323,26 @@ export type DomainEvent = } | { type: "agentProfileChanged"; agentId: string; profileId: string } | { type: "agentBusyChanged"; agentId: string; busy: boolean } + | { + type: "agentRestartRequested"; + agentId: string; + restartId: string; + reason: string | null; + } + | { + type: "agentRestartPhaseChanged"; + agentId: string; + restartId: string; + phase: AgentRestartPhase; + } + | { type: "agentRestartCompleted"; agentId: string; restartId: string } + | { + type: "agentRestartFailed"; + agentId: string; + restartId: string; + phase: AgentRestartPhase; + message: string; + } | { type: "backgroundTaskChanged"; projectId: string; diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index aa87633f..02920ebc 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { DIProvider } from "@/app/di"; import type { AgentProfile, ReplyChunk } from "@/domain"; import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock"; +import { AnnouncementsProvider } from "@/features/announcements"; import { PluginRuntimeProvider } from "@/features/plugins"; import { __resetProjectCliInteractionActivityForTests, @@ -145,6 +146,91 @@ describe("CustomAgentChatView", () => { __resetProjectCliInteractionActivityForTests(); }); + it("projects self-restart lifecycle separators in the custom CLI only once", async () => { + const system = new MockSystemGateway(); + const agent = { + launchAgentChat: vi.fn(async () => ({ sessionId: "chat-session-restart" })), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + + + + + , + ); + await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalled()); + + act(() => { + system.emit({ + type: "agentRestartRequested", + agentId: "agent-1", + restartId: "restart-1", + reason: null, + }); + system.emit({ + type: "agentRestartCompleted", + agentId: "agent-1", + restartId: "restart-1", + }); + }); + + expect( + await screen.findByText("------- redemarrage de l'agent -------"), + ).not.toBeNull(); + expect( + screen.getByText("------- redemarrage de l'agent termine -------"), + ).not.toBeNull(); + expect(screen.getAllByTestId("agent-restart-separator")).toHaveLength(2); + + act(() => { + system.emit({ + type: "agentRestartCompleted", + agentId: "agent-1", + restartId: "restart-1", + }); + }); + expect(screen.getAllByTestId("agent-restart-separator")).toHaveLength(2); + + act(() => { + system.emit({ + type: "agentRestartRequested", + agentId: "agent-1", + restartId: "restart-2", + reason: "new capability", + }); + system.emit({ + type: "agentRestartFailed", + agentId: "agent-1", + restartId: "restart-2", + phase: "launching", + message: "runtime unavailable", + }); + }); + expect( + screen.getByText("------- redemarrage de l'agent echoue -------"), + ).not.toBeNull(); + expect(screen.getByText("runtime unavailable")).not.toBeNull(); + }); + it("shows the technical open failure on mobile with retry and native TUI fallback", async () => { const openNativeTui = vi.fn(); const agent = { diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 00554054..c02497fc 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -31,7 +31,11 @@ import type { SlashCommand, } from "@/domain"; import { useGateways } from "@/app/di"; -import { AnnouncementsPreview } from "@/features/announcements"; +import { + AnnouncementsPreview, + shortRestartFailure, + useAgentRestart, +} from "@/features/announcements"; import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider"; import { markProjectCliInteractionFinished, @@ -68,6 +72,7 @@ type ChatTurn = | { role: "progress"; progress: ReplyProgress; settled?: boolean; progressKey?: string } | { role: "final"; text: string; receivedAtMs?: number } | { role: "error"; text: string; receivedAtMs?: number } + | { role: "restart"; key: string; text: string; message?: string; failed?: boolean } | { role: "unknown"; text: string; receivedAtMs?: number }; interface CachedChatView { @@ -1127,9 +1132,14 @@ export function CustomAgentChatView({ currentAgentBusy: false, delegationTargetName: null, }); + const { restart, events: restartEvents } = useAgentRestart(agentId); const delegationActive = requesterDelegation.delegationTargetName !== null; const effectiveBusy = - busy || stopping || requesterDelegation.currentAgentBusy || delegationActive; + busy || + stopping || + Boolean(restart && restart.outcome !== "failed") || + requesterDelegation.currentAgentBusy || + delegationActive; const delegationFallbackText = requesterDelegation.delegationTargetName ? `${agentName} attend ${requesterDelegation.delegationTargetName}` : "Délégation en cours."; @@ -1303,6 +1313,45 @@ export function CustomAgentChatView({ }; }, [agentId, projectId, refreshRequesterDelegation, system]); + useEffect(() => { + if (restartEvents.length === 0) return; + updateChatViewState((previous) => { + const nextTurns = [...previous.turns]; + let changed = false; + for (const event of restartEvents) { + const key = `${event.restartId}:${event.type}`; + if (nextTurns.some((turn) => turn.role === "restart" && turn.key === key)) { + continue; + } + if (event.type === "agentRestartRequested") { + changed = true; + nextTurns.push({ + role: "restart", + key, + text: "------- redemarrage de l'agent -------", + }); + } else if (event.type === "agentRestartCompleted") { + changed = true; + nextTurns.push({ + role: "restart", + key, + text: "------- redemarrage de l'agent termine -------", + }); + } else { + changed = true; + nextTurns.push({ + role: "restart", + key, + text: "------- redemarrage de l'agent echoue -------", + message: shortRestartFailure(event.message), + failed: true, + }); + } + } + return changed ? { ...previous, turns: nextTurns } : previous; + }); + }, [restartEvents, updateChatViewState]); + useEffect(() => { mountedRef.current = true; return () => { @@ -2577,6 +2626,20 @@ function ChatBubble({ expandedProgressKeys: Set; onToggleProgressExpanded: (key: string) => void; }) { + if (turn.role === "restart") { + return ( +
+ {turn.text} + {turn.message && {turn.message}} +
+ ); + } if (turn.role === "tool") { return (
diff --git a/frontend/src/features/announcements/AgentRestartOverlay.tsx b/frontend/src/features/announcements/AgentRestartOverlay.tsx new file mode 100644 index 00000000..eca210b7 --- /dev/null +++ b/frontend/src/features/announcements/AgentRestartOverlay.tsx @@ -0,0 +1,80 @@ +import { useEffect, useRef } from "react"; + +import { Spinner } from "@/shared"; +import { useAgentRestart, useTargetAnnouncements } from "./AnnouncementsProvider"; + +export interface AgentRestartOverlayProps { + agentId: string; +} + +export function restartOverlayLabel( + phase: "requested" | "stopping" | "releasingInput" | "launching" | "resuming", +): string { + switch (phase) { + case "stopping": + case "releasingInput": + return "Arret de l'agent en cours..."; + case "launching": + return "Relance de l'agent..."; + case "resuming": + return "Reprise du travail..."; + default: + return "Redemarrage de l'agent..."; + } +} + +export function shortRestartFailure(message: string): string { + const normalized = message.trim().replace(/\s+/g, " "); + if (!normalized) return "Cause technique non disponible."; + return normalized.length <= 160 ? normalized : `${normalized.slice(0, 157)}...`; +} + +export function AgentRestartOverlay({ agentId }: AgentRestartOverlayProps) { + const { restart } = useAgentRestart(agentId); + const { announcements } = useTargetAnnouncements(agentId); + const feedRef = useRef(null); + + useEffect(() => { + const element = feedRef.current; + if (element) element.scrollTop = element.scrollHeight; + }, [announcements]); + + if (!restart) return null; + + const failed = restart.outcome === "failed"; + return ( +
+
+ {!failed && } + + {failed ? "Redemarrage de l'agent echoue" : restartOverlayLabel(restart.phase)} + +
+
+ {failed ? ( +
+ {shortRestartFailure(restart.message ?? "")} +
+ ) : announcements.length === 0 ? ( +
+ {restartOverlayLabel(restart.phase)} +
+ ) : ( + announcements.map((announcement) => ( +
+ {announcement.text} +
+ )) + )} +
+
+ ); +} diff --git a/frontend/src/features/announcements/AnnouncementsProvider.test.tsx b/frontend/src/features/announcements/AnnouncementsProvider.test.tsx index 27ac6c88..98031ceb 100644 --- a/frontend/src/features/announcements/AnnouncementsProvider.test.tsx +++ b/frontend/src/features/announcements/AnnouncementsProvider.test.tsx @@ -21,6 +21,7 @@ import { DIProvider } from "@/app/di"; import { AnnouncementsProvider } from "./AnnouncementsProvider"; import { AnnouncementsPreview } from "./AnnouncementsPreview"; import { TargetAnnouncementsOverlay } from "./TargetAnnouncementsOverlay"; +import { AgentRestartOverlay } from "./AgentRestartOverlay"; function setup( node: React.ReactNode, @@ -304,6 +305,91 @@ describe("F3 — overlay lifecycle is the target's busy state", () => { }); }); +describe("#288 — native TUI restart overlay", () => { + it("tracks restart phases, keeps progress after resume, then retracts at idle", async () => { + const { system } = setup(); + await flush(); + + act(() => { + system.emit({ + type: "agentRestartRequested", + agentId: "agent-T", + restartId: "restart-1", + reason: null, + }); + }); + expect(screen.getAllByText("Redemarrage de l'agent...").length).toBeGreaterThan(0); + + act(() => { + system.emit({ + type: "agentRestartPhaseChanged", + agentId: "agent-T", + restartId: "restart-1", + phase: "launching", + }); + }); + expect(screen.getAllByText("Relance de l'agent...").length).toBeGreaterThan(0); + + act(() => { + system.emit(busy("agent-T", true)); + system.emit({ + type: "agentRestartCompleted", + agentId: "agent-T", + restartId: "restart-1", + }); + system.emit(announcement({ target: "agent-T", text: "travail repris" })); + }); + expect(screen.getByText("Reprise du travail...")).not.toBeNull(); + expect(screen.getByText("travail repris")).not.toBeNull(); + + act(() => system.emit(busy("agent-T", false))); + expect(screen.queryByTestId("agent-restart-overlay")).toBeNull(); + }); + + it("shows a short failure without CLI separators", async () => { + const { system } = setup(); + await flush(); + act(() => { + system.emit({ + type: "agentRestartRequested", + agentId: "agent-T", + restartId: "restart-2", + reason: "new plugin", + }); + system.emit({ + type: "agentRestartFailed", + agentId: "agent-T", + restartId: "restart-2", + phase: "launching", + message: "runtime unavailable", + }); + }); + expect(screen.getByText("Redemarrage de l'agent echoue")).not.toBeNull(); + expect(screen.getByText("runtime unavailable")).not.toBeNull(); + expect(screen.queryByText(/-------/)).toBeNull(); + }); + + it("does not stick when the structured resumed turn becomes idle before completed", async () => { + const { system } = setup(); + await flush(); + act(() => { + system.emit({ + type: "agentRestartRequested", + agentId: "agent-T", + restartId: "restart-structured", + reason: null, + }); + system.emit(busy("agent-T", false)); + system.emit({ + type: "agentRestartCompleted", + agentId: "agent-T", + restartId: "restart-structured", + }); + }); + expect(screen.queryByTestId("agent-restart-overlay")).toBeNull(); + }); +}); + describe("F2 — AnnouncementsPreview (requester filter)", () => { it("shows a requester-side fallback while waiting without live text", async () => { setup( diff --git a/frontend/src/features/announcements/AnnouncementsProvider.tsx b/frontend/src/features/announcements/AnnouncementsProvider.tsx index 26265c44..60270a6e 100644 --- a/frontend/src/features/announcements/AnnouncementsProvider.tsx +++ b/frontend/src/features/announcements/AnnouncementsProvider.tsx @@ -11,6 +11,10 @@ * 2026-07-04). Fed live by `agentBusyChanged { agentId, busy }` and hydrated * from the reconciled read-model `ProjectWorkState.agents[].busy` at mount / * reboot (via {@link useHydrateAgentBusy}). + * 3. **Self-restart presentation** — the live restart phase plus a small, + * deduplicated journal used by the native-TUI overlay and custom-CLI + * separators. It is deliberately presentation-only and never persisted to + * the human conversation transcript. * * Why busy — not "there are announcements" — drives F3: a turn can end **without** * any completion event (interruption / error / crash / rate-limit). The mediator's @@ -33,11 +37,13 @@ import { useContext, useEffect, useMemo, + useRef, useState, type ReactNode, } from "react"; import { useGateways } from "@/app/di"; +import type { AgentRestartPhase, DomainEvent } from "@/domain"; import { announcementsForRequester, announcementsForTarget, @@ -51,11 +57,33 @@ import { /** Per-agent busy map: `true` while the agent owns a turn (drives the overlay). */ type BusyMap = Record; +export type AgentRestartJournalEvent = Extract< + DomainEvent, + { + type: + | "agentRestartRequested" + | "agentRestartCompleted" + | "agentRestartFailed"; + } +>; + +export interface AgentRestartPresentation { + restartId: string; + phase: AgentRestartPhase | "requested"; + outcome: "running" | "completed" | "failed"; + message?: string; +} + +type RestartMap = Record; +type RestartJournal = Record; + interface AnnouncementsStore { /** Bounded announcement content, indexed by `(target, ticketId)`. */ index: AnnouncementIndex; /** Per-agent busy state — the overlay's lifecycle authority. */ busy: BusyMap; + restarts: RestartMap; + restartJournal: RestartJournal; /** * Seeds an agent's busy state from the read-model, but only when no live signal * is yet known for it: live `agentBusyChanged` events always win over a @@ -67,11 +95,15 @@ interface AnnouncementsStore { const noopSeed = () => {}; const EMPTY_INDEX = emptyAnnouncementIndex(); const EMPTY_BUSY: BusyMap = {}; +const EMPTY_RESTARTS: RestartMap = {}; +const EMPTY_RESTART_JOURNAL: RestartJournal = {}; /** Store shape read outside a provider — a silent, inert default (never throws). */ const DEFAULT_STORE: AnnouncementsStore = { index: EMPTY_INDEX, busy: EMPTY_BUSY, + restarts: EMPTY_RESTARTS, + restartJournal: EMPTY_RESTART_JOURNAL, seedBusy: noopSeed, }; @@ -87,16 +119,24 @@ export function AnnouncementsProvider({ const { system } = useGateways(); const [index, setIndex] = useState(emptyAnnouncementIndex); const [busy, setBusy] = useState({}); + const busyRef = useRef({}); + const [restarts, setRestarts] = useState({}); + const [restartJournal, setRestartJournal] = useState({}); // Hydration seed: apply only when the agent is unknown, so a live event that // already landed (or lands before the async read-model resolves) is not clobbered. const seedBusy = useCallback((agentId: string, value: boolean) => { - setBusy((prev) => (agentId in prev ? prev : { ...prev, [agentId]: value })); + if (agentId in busyRef.current) return; + busyRef.current = { ...busyRef.current, [agentId]: value }; + setBusy((prev) => ({ ...prev, [agentId]: value })); }, []); useEffect(() => { setIndex(emptyAnnouncementIndex()); setBusy({}); + busyRef.current = {}; + setRestarts({}); + setRestartJournal({}); }, [projectId]); useEffect(() => { @@ -124,8 +164,71 @@ export function AnnouncementsProvider({ // it (idle) and clears the target's content so a next turn starts fresh — // even when no completion event was ever emitted (the sticking case). const { agentId, busy: isBusy } = event; + busyRef.current = { ...busyRef.current, [agentId]: isBusy }; setBusy((prev) => ({ ...prev, [agentId]: isBusy })); if (!isBusy) setIndex((prev) => purgeAllForTarget(prev, agentId)); + if (!isBusy) { + setRestarts((prev) => { + const restart = prev[agentId]; + if (!restart || restart.outcome !== "completed") return prev; + const next = { ...prev }; + delete next[agentId]; + return next; + }); + } + } else if (event.type === "agentRestartRequested") { + setRestarts((prev) => ({ + ...prev, + [event.agentId]: { + restartId: event.restartId, + phase: "requested", + outcome: "running", + }, + })); + setRestartJournal((prev) => appendRestartJournal(prev, event)); + } else if (event.type === "agentRestartPhaseChanged") { + setRestarts((prev) => { + const current = prev[event.agentId]; + if (!current || current.restartId !== event.restartId) return prev; + return { + ...prev, + [event.agentId]: { ...current, phase: event.phase }, + }; + }); + } else if (event.type === "agentRestartCompleted") { + setRestarts((prev) => { + const current = prev[event.agentId]; + if (!current || current.restartId !== event.restartId) return prev; + if (busyRef.current[event.agentId] === false) { + const next = { ...prev }; + delete next[event.agentId]; + return next; + } + return { + ...prev, + [event.agentId]: { + ...current, + phase: "resuming", + outcome: "completed", + }, + }; + }); + setRestartJournal((prev) => appendRestartJournal(prev, event)); + } else if (event.type === "agentRestartFailed") { + setRestarts((prev) => { + const current = prev[event.agentId]; + if (!current || current.restartId !== event.restartId) return prev; + return { + ...prev, + [event.agentId]: { + ...current, + phase: event.phase, + outcome: "failed", + message: event.message, + }, + }; + }); + setRestartJournal((prev) => appendRestartJournal(prev, event)); } }) .then((un) => { @@ -143,8 +246,8 @@ export function AnnouncementsProvider({ }, [system, projectId]); const store = useMemo( - () => ({ index, busy, seedBusy }), - [index, busy, seedBusy], + () => ({ index, busy, restarts, restartJournal, seedBusy }), + [index, busy, restarts, restartJournal, seedBusy], ); return ( @@ -154,6 +257,19 @@ export function AnnouncementsProvider({ ); } +function appendRestartJournal( + journal: RestartJournal, + event: AgentRestartJournalEvent, +): RestartJournal { + const key = `${event.restartId}:${event.type}`; + const previous = journal[event.agentId] ?? []; + if (previous.some((item) => `${item.restartId}:${item.type}` === key)) return journal; + return { + ...journal, + [event.agentId]: [...previous, event].slice(-12), + }; +} + /** The store, or an inert default when read outside a provider. */ function useStore(): AnnouncementsStore { return useContext(AnnouncementsContext) ?? DEFAULT_STORE; @@ -183,6 +299,20 @@ export function useTargetAnnouncements(target: string): { ); } +export function useAgentRestart(agentId: string): { + restart: AgentRestartPresentation | null; + events: AgentRestartJournalEvent[]; +} { + const { restarts, restartJournal } = useStore(); + return useMemo( + () => ({ + restart: restarts[agentId] ?? null, + events: restartJournal[agentId] ?? [], + }), + [agentId, restartJournal, restarts], + ); +} + /** * Hydrates the target's busy state from the reconciled read-model at mount/reboot, * so an overlay mounts for a turn already in flight when its cell appears — no live diff --git a/frontend/src/features/announcements/index.ts b/frontend/src/features/announcements/index.ts index 869e466f..488a811a 100644 --- a/frontend/src/features/announcements/index.ts +++ b/frontend/src/features/announcements/index.ts @@ -7,7 +7,18 @@ export { AnnouncementsProvider, useTargetAnnouncements, useRequesterAnnouncements, + useAgentRestart, } from "./AnnouncementsProvider"; +export type { + AgentRestartJournalEvent, + AgentRestartPresentation, +} from "./AnnouncementsProvider"; +export { + AgentRestartOverlay, + restartOverlayLabel, + shortRestartFailure, + type AgentRestartOverlayProps, +} from "./AgentRestartOverlay"; export { TargetAnnouncementsOverlay, type TargetAnnouncementsOverlayProps, diff --git a/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx b/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx index 3c05f0b0..494314ff 100644 --- a/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx @@ -367,4 +367,26 @@ describe("LayoutGrid — ticket #48 cell control layering", () => { ); }, ); + + it("gives the native TUI restart overlay priority over other cell veils", async () => { + const setup = await makeSplitAgentGrid(); + renderGrid(setup.gateways); + await flush(); + + act(() => { + setup.system.emit(busy(setup.agentId, true)); + setup.system.emit({ + type: "agentRestartRequested", + agentId: setup.agentId, + restartId: "restart-1", + reason: null, + }); + }); + + const overlay = await screen.findByTestId("agent-restart-overlay"); + expect(overlay.className).toContain("z-[4]"); + expect(screen.queryByTestId("target-announcements-overlay")).toBeNull(); + expect(screen.queryByText("------- redemarrage de l'agent -------")).toBeNull(); + expect(controlsFor(setup.bId).controls.style.zIndex).toBe("5"); + }); }); diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index b36b7ace..18d59bf0 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -30,7 +30,9 @@ import { TerminalView, useWritePortal } from "@/features/terminals"; import { useGateways } from "@/app/di"; import { useProjectWorkState } from "@/features/workstate/useProjectWorkState"; import { + AgentRestartOverlay, TargetAnnouncementsOverlay, + useAgentRestart, useTargetAnnouncements, } from "@/features/announcements"; import { PluginLayoutCellView } from "@/features/plugins"; @@ -623,6 +625,7 @@ function LeafView({ // write-portal veil can be mutually excluded with the F3 overlay (exactly one // full-cell veil at a time, F3 first). Same source the overlay itself reads. const { active: announcementBusyActive } = useTargetAnnouncements(agentId ?? ""); + const { restart: agentRestart } = useAgentRestart(agentId ?? ""); // Ticket #54 — local model-server launch state for this cell's pinned agent. // Correlated `agentId → profileId → opencode.localModelServerId → status` via // the shared hook (same reduction as `useAgents`). While the bound server is @@ -1466,7 +1469,8 @@ function LeafView({ is being injected into the agent's PTY, a grey veil with a centred message sits above the terminal. Only ever shown for an agent cell — and never together with the F3 overlay (exactly one veil, F3 first). */} - {!modelServerOverlay && + {!agentRestart && + !modelServerOverlay && cellMode !== "custom" && shouldShowWritePortalVeil(agentId != null, Boolean(overlay), busyActive) && (
)} + {cellMode !== "custom" && agentId != null && agentRestart && ( + + )}
{busyNotice && (