merge(mcp,orchestrator,frontend): tool MCP idea_restart_self, redemarrage autonome d'un agent — #288 (QA verte: infrastructure mcp_server restart_self 1/1 + restart-self 3/3, backend self_restart_contract 1/1 + events wire 1/1, frontend 3 suites 92/92 + npm build, cargo check --workspace, fmt --check, diff --check; branche feature/288-agent-self-restart-mcp commit 6353757; reserves hors perimetre confirmees: web-server 7 echecs fixture PTY preexistante + 34 echecs npm test globaux chantier listProjectSettings/web; restants: rebuild AppImage + validation live idea_restart_self depuis un agent reel; scripts/ hors perimetre; aucun push)
This commit is contained in:
@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#288"
|
issueRef: "#288"
|
||||||
version: 2
|
version: 5
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
updatedBy: {"kind":"agent","agent_id":"qa"}
|
||||||
updatedAt: 1788895080146
|
updatedAt: 1788897533882
|
||||||
---
|
---
|
||||||
#288 — Tool MCP de redemarrage autonome d'un agent
|
#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.
|
- Invariant: liberer Busy/mailbox meme si la connexion MCP disparait quand l'agent est tue.
|
||||||
- Memoire durable ajoutee: `ticket288-agent-self-restart-architecture`.
|
- 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
|
## 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.
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
id: "3d6c1342-58cc-4bc8-bed5-a176f155b9ef"
|
id: "3d6c1342-58cc-4bc8-bed5-a176f155b9ef"
|
||||||
number: 288
|
number: 288
|
||||||
title: "Tool MCP de redémarrage autonome d'un agent"
|
title: "Tool MCP de redémarrage autonome d'un agent"
|
||||||
status: "inProgress"
|
status: "qa"
|
||||||
priority: "high"
|
priority: "high"
|
||||||
sprint: null
|
sprint: null
|
||||||
links: []
|
links: []
|
||||||
@ -11,7 +11,7 @@ attachments: []
|
|||||||
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
createdAt: 1788894923196
|
createdAt: 1788894923196
|
||||||
updatedAt: 1788895080146
|
updatedAt: 1788897287730
|
||||||
version: 2
|
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.
|
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.
|
||||||
@ -3940,7 +3940,7 @@
|
|||||||
"issueRef": "#288",
|
"issueRef": "#288",
|
||||||
"path": "288",
|
"path": "288",
|
||||||
"title": "Tool MCP de redémarrage autonome d'un agent",
|
"title": "Tool MCP de redémarrage autonome d'un agent",
|
||||||
"status": "inProgress",
|
"status": "qa",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"sprint": null,
|
"sprint": null,
|
||||||
"assignedAgentIds": [],
|
"assignedAgentIds": [],
|
||||||
@ -3948,7 +3948,7 @@
|
|||||||
"kind": "agent",
|
"kind": "agent",
|
||||||
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||||
},
|
},
|
||||||
"updatedAt": 1788895080146
|
"updatedAt": 1788897287730
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -2243,6 +2243,12 @@ pub async fn launch_agent(
|
|||||||
project: resume_project,
|
project: resume_project,
|
||||||
rows: request.rows,
|
rows: request.rows,
|
||||||
cols: request.cols,
|
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),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -165,10 +165,11 @@ pub use model_server::{
|
|||||||
};
|
};
|
||||||
pub use orchestrator::{
|
pub use orchestrator::{
|
||||||
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
|
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
|
||||||
AgentWakeService, AskLivenessProbe, ContextGuardUseCases, LiveStateProvider,
|
AgentRestartScheduled, AgentSelfRestartScheduler, AgentWakeService, AskLivenessProbe,
|
||||||
LiveStateReadProvider, McpRuntimeProvider, OrchestratorOutcome, OrchestratorService,
|
ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider,
|
||||||
ProposeContext, ReadContext, ReadMemory, RecordTurnProvider, WakeSessionProvider,
|
OrchestratorOutcome, OrchestratorService, ProposeContext, ReadContext, ReadMemory,
|
||||||
WatchdogOutcome, WriteMemory, DEFAULT_RENDEZVOUS_CEILING, DEFAULT_RENDEZVOUS_WINDOW,
|
RecordTurnProvider, WakeSessionProvider, WatchdogOutcome, WriteMemory,
|
||||||
|
DEFAULT_RENDEZVOUS_CEILING, DEFAULT_RENDEZVOUS_WINDOW,
|
||||||
};
|
};
|
||||||
pub use permission::{
|
pub use permission::{
|
||||||
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,
|
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,
|
||||||
|
|||||||
@ -19,7 +19,8 @@ pub use rendezvous::{
|
|||||||
WatchdogOutcome, DEFAULT_RENDEZVOUS_CEILING, DEFAULT_RENDEZVOUS_WINDOW,
|
WatchdogOutcome, DEFAULT_RENDEZVOUS_CEILING, DEFAULT_RENDEZVOUS_WINDOW,
|
||||||
};
|
};
|
||||||
pub use service::{
|
pub use service::{
|
||||||
AskLivenessProbe, ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider,
|
AgentRestartScheduled, AgentSelfRestartScheduler, AskLivenessProbe, ContextGuardUseCases,
|
||||||
McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
|
LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider, OrchestratorOutcome,
|
||||||
|
OrchestratorService, RecordTurnProvider,
|
||||||
};
|
};
|
||||||
pub use wake::{AgentWakeService, WakeSessionProvider};
|
pub use wake::{AgentWakeService, WakeSessionProvider};
|
||||||
|
|||||||
@ -63,6 +63,25 @@ use crate::workstate::{
|
|||||||
GetLiveStateLean, StopAgentExecutionCascade, UpdateLiveState, UpdateLiveStateInput,
|
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<String>,
|
||||||
|
) -> Result<AgentRestartScheduled, AppError>;
|
||||||
|
}
|
||||||
|
|
||||||
/// Default terminal geometry for an orchestrator-launched agent cell. The UI
|
/// 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
|
/// 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.
|
/// /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
|
/// 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é.
|
/// agent MCP `idea_run_in_background`. `None` ⇒ outil non configuré.
|
||||||
spawn_background_command: Option<Arc<SpawnBackgroundCommand>>,
|
spawn_background_command: Option<Arc<SpawnBackgroundCommand>>,
|
||||||
|
/// Detached self-restart coordinator, wired at the composition root.
|
||||||
|
self_restart: Option<Arc<dyn AgentSelfRestartScheduler>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
|
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
|
||||||
@ -784,6 +805,7 @@ impl OrchestratorService {
|
|||||||
background_runner: None,
|
background_runner: None,
|
||||||
session_limits: None,
|
session_limits: None,
|
||||||
spawn_background_command: None,
|
spawn_background_command: None,
|
||||||
|
self_restart: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -808,6 +830,13 @@ impl OrchestratorService {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wires the requester-scoped detached self-restart coordinator.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_self_restart(mut self, restart: Arc<dyn AgentSelfRestartScheduler>) -> Self {
|
||||||
|
self.self_restart = Some(restart);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Branche le store des tâches de fond pour tracer les rendez-vous inter-agents
|
/// Branche le store des tâches de fond pour tracer les rendez-vous inter-agents
|
||||||
/// comme [`BackgroundTaskKind::HeadlessRendezvous`] (B6).
|
/// comme [`BackgroundTaskKind::HeadlessRendezvous`] (B6).
|
||||||
///
|
///
|
||||||
@ -1456,6 +1485,25 @@ impl OrchestratorService {
|
|||||||
command: OrchestratorCommand,
|
command: OrchestratorCommand,
|
||||||
) -> Result<OrchestratorOutcome, AppError> {
|
) -> Result<OrchestratorOutcome, AppError> {
|
||||||
match command {
|
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 {
|
OrchestratorCommand::SpawnAgent {
|
||||||
name,
|
name,
|
||||||
profile,
|
profile,
|
||||||
|
|||||||
@ -113,6 +113,34 @@ pub struct AgentLaunchFailedDto {
|
|||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(tag = "type", rename_all = "camelCase")]
|
#[serde(tag = "type", rename_all = "camelCase")]
|
||||||
pub enum DomainEventDto {
|
pub enum DomainEventDto {
|
||||||
|
/// A requester-scoped agent self-restart was accepted.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
AgentRestartRequested {
|
||||||
|
agent_id: String,
|
||||||
|
restart_id: String,
|
||||||
|
reason: Option<String>,
|
||||||
|
},
|
||||||
|
/// 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.
|
/// A project was created.
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
ProjectCreated {
|
ProjectCreated {
|
||||||
@ -787,6 +815,42 @@ fn rendezvous_conversation_id(rendezvous: &Option<RendezvousContext>) -> Option<
|
|||||||
impl From<&DomainEvent> for DomainEventDto {
|
impl From<&DomainEvent> for DomainEventDto {
|
||||||
fn from(e: &DomainEvent) -> Self {
|
fn from(e: &DomainEvent) -> Self {
|
||||||
match e {
|
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 {
|
DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated {
|
||||||
project_id: project_id.to_string(),
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -1336,6 +1409,32 @@ mod tests {
|
|||||||
ConversationId::from_uuid(uuid::Uuid::from_u128(n))
|
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]
|
#[test]
|
||||||
fn model_server_status_changed_relays_ready_to_dto_and_wire() {
|
fn model_server_status_changed_relays_ready_to_dto_and_wire() {
|
||||||
let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged {
|
let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged {
|
||||||
|
|||||||
@ -13,16 +13,16 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext;
|
use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext;
|
||||||
use application::{
|
use application::{
|
||||||
AddIssueAttachment, AgentResumer, AgentWakeService, AllocateManagedTemp, AppError,
|
AddIssueAttachment, AgentRestartScheduled, AgentResumer, AgentSelfRestartScheduler,
|
||||||
AssignIssueAgent, AssignSkillToAgent, AssignTicketToSprint, AssignedSkillResolver,
|
AgentWakeService, AllocateManagedTemp, AppError, AssignIssueAgent, AssignSkillToAgent,
|
||||||
AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive, BulkDeleteIssues,
|
AssignTicketToSprint, AssignedSkillResolver, AttachLiveAgent, AuthenticateSession,
|
||||||
BulkUpdateIssuePriority, BulkUpdateIssueStatus, CancelBackgroundTask, ChangeAgentProfile,
|
BackgroundCommandArchive, BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus,
|
||||||
CheckEmbedderSuggestion, ClearChatDraft, CloneOpenCodeProfileFromSeed, CloneProfileFromSeed,
|
CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion, ClearChatDraft,
|
||||||
CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles,
|
CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal,
|
||||||
ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue,
|
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
|
||||||
CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint, CreateTemplate,
|
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
|
||||||
DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory,
|
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
|
||||||
DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
|
DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
|
||||||
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
|
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
|
||||||
DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand, FirstRunState,
|
DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand, FirstRunState,
|
||||||
GcManagedTemp, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, GetProjectPermissions,
|
GcManagedTemp, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, GetProjectPermissions,
|
||||||
@ -64,6 +64,7 @@ use application::{
|
|||||||
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use domain::events::AgentRestartPhase;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore,
|
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore,
|
||||||
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
|
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
|
||||||
@ -1003,6 +1004,12 @@ pub struct ResumeContext {
|
|||||||
pub rows: u16,
|
pub rows: u16,
|
||||||
/// Largeur de la cellule au dernier lancement (colonnes PTY).
|
/// Largeur de la cellule au dernier lancement (colonnes PTY).
|
||||||
pub cols: u16,
|
pub cols: u16,
|
||||||
|
/// Exact hosting node supplied by the last driving launch (`None` = background).
|
||||||
|
pub node_id: Option<domain::NodeId>,
|
||||||
|
/// Logical conversation id supplied by the hosting cell.
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
/// Whether the last driving launch selected the structured chat runtime.
|
||||||
|
pub require_structured: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registre partagé `(project_id, agent_id) → ResumeContext` (composition root ↔ commande
|
/// Registre partagé `(project_id, agent_id) → ResumeContext` (composition root ↔ commande
|
||||||
@ -1030,6 +1037,265 @@ struct AppAgentResumer {
|
|||||||
contexts: ResumeContexts,
|
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<LaunchAgent>,
|
||||||
|
close_terminal: Arc<CloseTerminal>,
|
||||||
|
live: Arc<LiveSessions>,
|
||||||
|
input: Arc<dyn domain::input::InputMediator>,
|
||||||
|
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
contexts: ResumeContexts,
|
||||||
|
active: Arc<Mutex<HashSet<domain::RuntimeAgentKey>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn self_restart_launch_input(
|
||||||
|
context: ResumeContext,
|
||||||
|
agent_id: AgentId,
|
||||||
|
mcp_runtime: Option<McpRuntime>,
|
||||||
|
) -> 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<String>,
|
||||||
|
) -> Result<AgentRestartScheduled, AppError> {
|
||||||
|
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.
|
/// Guard for a direct human turn sent to a structured agent session.
|
||||||
///
|
///
|
||||||
/// Direct chat adapters (`agent_send`, web `chat.send`) still drive the provider
|
/// Direct chat adapters (`agent_send`, web `chat.send`) still drive the provider
|
||||||
@ -3230,6 +3496,16 @@ impl BackendCore {
|
|||||||
Arc::clone(&events_port),
|
Arc::clone(&events_port),
|
||||||
resumer,
|
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<dyn AgentSelfRestartScheduler>;
|
||||||
// Drain du scheduler (§21.5-b) : à chaque réveil tiré, `TokioScheduler` pousse une
|
// Drain du scheduler (§21.5-b) : à chaque réveil tiré, `TokioScheduler` pousse une
|
||||||
// `ScheduledTask::ResumeAgent` ; on l'exécute via le service (relance + AgentResumed).
|
// `ScheduledTask::ResumeAgent` ; on l'exécute via le service (relance + AgentResumed).
|
||||||
// Patron `sweep_stalled` : spawner detache (pas `tokio::spawn` — `build`
|
// Patron `sweep_stalled` : spawner detache (pas `tokio::spawn` — `build`
|
||||||
@ -3299,6 +3575,7 @@ impl BackendCore {
|
|||||||
.with_project_store(Arc::clone(&store_port))
|
.with_project_store(Arc::clone(&store_port))
|
||||||
.with_conversations(Arc::clone(&conversation_registry))
|
.with_conversations(Arc::clone(&conversation_registry))
|
||||||
.with_events(Arc::clone(&events_port))
|
.with_events(Arc::clone(&events_port))
|
||||||
|
.with_self_restart(self_restart)
|
||||||
// Faits OS/runtime (exe $APPIMAGE/current_exe + endpoint loopback) pour
|
// Faits OS/runtime (exe $APPIMAGE/current_exe + endpoint loopback) pour
|
||||||
// que les (re)lancements issus du chemin `ask` écrivent la déclaration MCP
|
// 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.
|
// réelle ⇒ le pont MCP de la cible se spawne et `idea_reply` débloque le round-trip.
|
||||||
|
|||||||
@ -49,6 +49,42 @@ pub enum OrchestrationSource {
|
|||||||
/// channel rather than serialised here.
|
/// channel rather than serialised here.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum DomainEvent {
|
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<String>,
|
||||||
|
},
|
||||||
|
/// 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.
|
/// A project was created.
|
||||||
ProjectCreated {
|
ProjectCreated {
|
||||||
/// The new project.
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@ -260,6 +260,13 @@ pub struct OrchestratorRequest {
|
|||||||
/// proof the request was well-formed (Parse, don't validate).
|
/// proof the request was well-formed (Parse, don't validate).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum OrchestratorCommand {
|
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<String>,
|
||||||
|
},
|
||||||
/// Create the agent if unknown (with `profile` + optional initial context),
|
/// Create the agent if unknown (with `profile` + optional initial context),
|
||||||
/// then launch it — exactly as the UI would.
|
/// then launch it — exactly as the UI would.
|
||||||
SpawnAgent {
|
SpawnAgent {
|
||||||
@ -476,6 +483,15 @@ impl OrchestratorRequest {
|
|||||||
pub fn validate(&self) -> Result<OrchestratorCommand, OrchestratorError> {
|
pub fn validate(&self) -> Result<OrchestratorCommand, OrchestratorError> {
|
||||||
let action = self.action_name()?;
|
let action = self.action_name()?;
|
||||||
match action {
|
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 {
|
"spawn_agent" => Ok(OrchestratorCommand::SpawnAgent {
|
||||||
name: self.require_name(action)?,
|
name: self.require_name(action)?,
|
||||||
profile: Some(self.require("profile", action, self.profile.as_deref())?),
|
profile: Some(self.require("profile", action, self.profile.as_deref())?),
|
||||||
|
|||||||
@ -72,6 +72,7 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
|
|||||||
"idea_run_in_background",
|
"idea_run_in_background",
|
||||||
"idea_launch_agent",
|
"idea_launch_agent",
|
||||||
"idea_stop_agent",
|
"idea_stop_agent",
|
||||||
|
"idea_restart_self",
|
||||||
"idea_update_context",
|
"idea_update_context",
|
||||||
"idea_context_propose",
|
"idea_context_propose",
|
||||||
"idea_context_update",
|
"idea_context_update",
|
||||||
@ -155,6 +156,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
|
|||||||
| "idea_memory_read"
|
| "idea_memory_read"
|
||||||
| "idea_skill_read"
|
| "idea_skill_read"
|
||||||
| "idea_run_in_background"
|
| "idea_run_in_background"
|
||||||
|
| "idea_restart_self"
|
||||||
| "idea_workstate_read"
|
| "idea_workstate_read"
|
||||||
) || is_ticket_tool(tool)
|
) || is_ticket_tool(tool)
|
||||||
|| is_template_tool(tool)
|
|| is_template_tool(tool)
|
||||||
@ -193,6 +195,17 @@ pub fn is_plugin_tool(tool: &str) -> bool {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn catalogue() -> Vec<ToolDef> {
|
pub fn catalogue() -> Vec<ToolDef> {
|
||||||
let mut tools = 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 {
|
ToolDef {
|
||||||
name: "idea_project_list",
|
name: "idea_project_list",
|
||||||
description: "List known IdeA projects for cross-project agent discovery.",
|
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
|
// Translate the tool into the wire-level request shape the watcher already
|
||||||
// uses (v2 `type`), then let `validate` enforce the invariants once.
|
// uses (v2 `type`), then let `validate` enforce the invariants once.
|
||||||
let request = match name {
|
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 {
|
"idea_list_agents" => OrchestratorRequest {
|
||||||
request_type: Some("agent.list".to_owned()),
|
request_type: Some("agent.list".to_owned()),
|
||||||
..base()
|
..base()
|
||||||
@ -845,6 +867,51 @@ mod tests {
|
|||||||
assert!(tool_returns_reply("idea_ask_agents"));
|
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]
|
#[test]
|
||||||
fn project_tools_are_classified_and_return_inline_replies() {
|
fn project_tools_are_classified_and_return_inline_replies() {
|
||||||
for tool in [
|
for tool in [
|
||||||
|
|||||||
@ -53,8 +53,9 @@ use domain::{
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
|
AgentRestartScheduled, AgentSelfRestartScheduler, AppError, CloseTerminal,
|
||||||
OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext,
|
CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, OrchestratorService,
|
||||||
|
StructuredSessions, TerminalSessions, UpdateAgentContext,
|
||||||
};
|
};
|
||||||
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
||||||
use infrastructure::orchestrator::mcp::tools::classified_tool_names;
|
use infrastructure::orchestrator::mcp::tools::classified_tool_names;
|
||||||
@ -578,6 +579,25 @@ fn capturing_events() -> (
|
|||||||
(publish, captured)
|
(publish, captured)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeSelfRestart {
|
||||||
|
calls: Mutex<Vec<(ProjectId, AgentId, Option<String>)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentSelfRestartScheduler for FakeSelfRestart {
|
||||||
|
fn schedule(
|
||||||
|
&self,
|
||||||
|
project: &Project,
|
||||||
|
agent: AgentId,
|
||||||
|
reason: Option<String>,
|
||||||
|
) -> Result<AgentRestartScheduled, AppError> {
|
||||||
|
self.calls.lock().unwrap().push((project.id, agent, reason));
|
||||||
|
Ok(AgentRestartScheduled {
|
||||||
|
restart_id: "restart-test-1".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- JSON-RPC framing helpers ---------------------------------------------
|
// --- JSON-RPC framing helpers ---------------------------------------------
|
||||||
|
|
||||||
/// A `tools/call` request envelope for `tool` with `arguments`.
|
/// 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_agent",
|
||||||
"idea_ask_agents",
|
"idea_ask_agents",
|
||||||
"idea_run_in_background",
|
"idea_run_in_background",
|
||||||
|
"idea_restart_self",
|
||||||
"idea_launch_agent",
|
"idea_launch_agent",
|
||||||
"idea_stop_agent",
|
"idea_stop_agent",
|
||||||
"idea_update_context",
|
"idea_update_context",
|
||||||
@ -812,8 +833,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
|||||||
assert!(!names.contains(&"idea_reply"));
|
assert!(!names.contains(&"idea_reply"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tools.len(),
|
tools.len(),
|
||||||
49,
|
50,
|
||||||
"exactly the forty-nine exposed idea_* tools; got {names:?}"
|
"exactly the fifty exposed idea_* tools; got {names:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Every tool advertises an object input schema.
|
// 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)
|
// 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<dyn AgentSelfRestartScheduler>);
|
||||||
|
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]
|
#[tokio::test]
|
||||||
async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
|
async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
|
||||||
let contexts = FakeContexts::new();
|
let contexts = FakeContexts::new();
|
||||||
|
|||||||
@ -1658,6 +1658,12 @@ async fn execute_launch_agent_for_ws(
|
|||||||
project: resume_project,
|
project: resume_project,
|
||||||
rows: request.rows,
|
rows: request.rows,
|
||||||
cols: request.cols,
|
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),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -280,6 +280,12 @@ export interface EmbeddedServerStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A domain event relayed from the backend (tagged union on `type`). */
|
/** A domain event relayed from the backend (tagged union on `type`). */
|
||||||
|
export type AgentRestartPhase =
|
||||||
|
| "stopping"
|
||||||
|
| "releasingInput"
|
||||||
|
| "launching"
|
||||||
|
| "resuming";
|
||||||
|
|
||||||
export type DomainEvent =
|
export type DomainEvent =
|
||||||
| { type: "projectCreated"; projectId: string }
|
| { type: "projectCreated"; projectId: string }
|
||||||
| { type: "pluginInstalled"; pluginId: string; version: string }
|
| { type: "pluginInstalled"; pluginId: string; version: string }
|
||||||
@ -317,6 +323,26 @@ export type DomainEvent =
|
|||||||
}
|
}
|
||||||
| { type: "agentProfileChanged"; agentId: string; profileId: string }
|
| { type: "agentProfileChanged"; agentId: string; profileId: string }
|
||||||
| { type: "agentBusyChanged"; agentId: string; busy: boolean }
|
| { 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";
|
type: "backgroundTaskChanged";
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import type { AgentProfile, ReplyChunk } from "@/domain";
|
import type { AgentProfile, ReplyChunk } from "@/domain";
|
||||||
import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
|
import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
|
||||||
|
import { AnnouncementsProvider } from "@/features/announcements";
|
||||||
import { PluginRuntimeProvider } from "@/features/plugins";
|
import { PluginRuntimeProvider } from "@/features/plugins";
|
||||||
import {
|
import {
|
||||||
__resetProjectCliInteractionActivityForTests,
|
__resetProjectCliInteractionActivityForTests,
|
||||||
@ -145,6 +146,91 @@ describe("CustomAgentChatView", () => {
|
|||||||
__resetProjectCliInteractionActivityForTests();
|
__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(
|
||||||
|
<DIProvider gateways={{ agent, system } as unknown as Gateways}>
|
||||||
|
<AnnouncementsProvider projectId="project-1">
|
||||||
|
<CustomAgentChatView
|
||||||
|
projectId="project-1"
|
||||||
|
agentId="agent-1"
|
||||||
|
agentName="Worker"
|
||||||
|
profile={profile}
|
||||||
|
cwd="/repo"
|
||||||
|
nodeId="node-1"
|
||||||
|
sessionId={null}
|
||||||
|
conversationId="conversation-1"
|
||||||
|
onSessionId={vi.fn()}
|
||||||
|
onConversationId={vi.fn()}
|
||||||
|
/>
|
||||||
|
</AnnouncementsProvider>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
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 () => {
|
it("shows the technical open failure on mobile with retry and native TUI fallback", async () => {
|
||||||
const openNativeTui = vi.fn();
|
const openNativeTui = vi.fn();
|
||||||
const agent = {
|
const agent = {
|
||||||
|
|||||||
@ -31,7 +31,11 @@ import type {
|
|||||||
SlashCommand,
|
SlashCommand,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { AnnouncementsPreview } from "@/features/announcements";
|
import {
|
||||||
|
AnnouncementsPreview,
|
||||||
|
shortRestartFailure,
|
||||||
|
useAgentRestart,
|
||||||
|
} from "@/features/announcements";
|
||||||
import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider";
|
import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider";
|
||||||
import {
|
import {
|
||||||
markProjectCliInteractionFinished,
|
markProjectCliInteractionFinished,
|
||||||
@ -68,6 +72,7 @@ type ChatTurn =
|
|||||||
| { role: "progress"; progress: ReplyProgress; settled?: boolean; progressKey?: string }
|
| { role: "progress"; progress: ReplyProgress; settled?: boolean; progressKey?: string }
|
||||||
| { role: "final"; text: string; receivedAtMs?: number }
|
| { role: "final"; text: string; receivedAtMs?: number }
|
||||||
| { role: "error"; 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 };
|
| { role: "unknown"; text: string; receivedAtMs?: number };
|
||||||
|
|
||||||
interface CachedChatView {
|
interface CachedChatView {
|
||||||
@ -1127,9 +1132,14 @@ export function CustomAgentChatView({
|
|||||||
currentAgentBusy: false,
|
currentAgentBusy: false,
|
||||||
delegationTargetName: null,
|
delegationTargetName: null,
|
||||||
});
|
});
|
||||||
|
const { restart, events: restartEvents } = useAgentRestart(agentId);
|
||||||
const delegationActive = requesterDelegation.delegationTargetName !== null;
|
const delegationActive = requesterDelegation.delegationTargetName !== null;
|
||||||
const effectiveBusy =
|
const effectiveBusy =
|
||||||
busy || stopping || requesterDelegation.currentAgentBusy || delegationActive;
|
busy ||
|
||||||
|
stopping ||
|
||||||
|
Boolean(restart && restart.outcome !== "failed") ||
|
||||||
|
requesterDelegation.currentAgentBusy ||
|
||||||
|
delegationActive;
|
||||||
const delegationFallbackText = requesterDelegation.delegationTargetName
|
const delegationFallbackText = requesterDelegation.delegationTargetName
|
||||||
? `${agentName} attend ${requesterDelegation.delegationTargetName}`
|
? `${agentName} attend ${requesterDelegation.delegationTargetName}`
|
||||||
: "Délégation en cours.";
|
: "Délégation en cours.";
|
||||||
@ -1303,6 +1313,45 @@ export function CustomAgentChatView({
|
|||||||
};
|
};
|
||||||
}, [agentId, projectId, refreshRequesterDelegation, system]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
return () => {
|
return () => {
|
||||||
@ -2577,6 +2626,20 @@ function ChatBubble({
|
|||||||
expandedProgressKeys: Set<string>;
|
expandedProgressKeys: Set<string>;
|
||||||
onToggleProgressExpanded: (key: string) => void;
|
onToggleProgressExpanded: (key: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
if (turn.role === "restart") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="agent-restart-separator"
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 flex-col items-center gap-1 py-1 text-xs",
|
||||||
|
turn.failed ? "text-danger" : "text-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="whitespace-pre-wrap text-center">{turn.text}</span>
|
||||||
|
{turn.message && <span className="max-w-[86%] text-center">{turn.message}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (turn.role === "tool") {
|
if (turn.role === "tool") {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 justify-center">
|
<div className="flex min-w-0 justify-center">
|
||||||
|
|||||||
80
frontend/src/features/announcements/AgentRestartOverlay.tsx
Normal file
80
frontend/src/features/announcements/AgentRestartOverlay.tsx
Normal file
@ -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<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = feedRef.current;
|
||||||
|
if (element) element.scrollTop = element.scrollHeight;
|
||||||
|
}, [announcements]);
|
||||||
|
|
||||||
|
if (!restart) return null;
|
||||||
|
|
||||||
|
const failed = restart.outcome === "failed";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="agent-restart-overlay"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="pointer-events-none absolute inset-0 z-[4] flex flex-col bg-canvas/90 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div className="flex shrink-0 items-center gap-2 border-b border-border/60 bg-surface/85 px-4 py-2">
|
||||||
|
{!failed && <Spinner size={14} />}
|
||||||
|
<span className={failed ? "text-sm font-medium text-danger" : "text-sm font-medium text-content"}>
|
||||||
|
{failed ? "Redemarrage de l'agent echoue" : restartOverlayLabel(restart.phase)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={feedRef}
|
||||||
|
className="flex flex-1 flex-col justify-end gap-1.5 overflow-hidden px-6 py-4"
|
||||||
|
>
|
||||||
|
{failed ? (
|
||||||
|
<div className="rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
|
||||||
|
{shortRestartFailure(restart.message ?? "")}
|
||||||
|
</div>
|
||||||
|
) : announcements.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center rounded-md border border-border/70 bg-surface/70 px-3 py-2 text-sm text-muted">
|
||||||
|
{restartOverlayLabel(restart.phase)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
announcements.map((announcement) => (
|
||||||
|
<div key={announcement.seq} className="text-sm text-content/90">
|
||||||
|
<span className="whitespace-pre-wrap break-words">{announcement.text}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -21,6 +21,7 @@ import { DIProvider } from "@/app/di";
|
|||||||
import { AnnouncementsProvider } from "./AnnouncementsProvider";
|
import { AnnouncementsProvider } from "./AnnouncementsProvider";
|
||||||
import { AnnouncementsPreview } from "./AnnouncementsPreview";
|
import { AnnouncementsPreview } from "./AnnouncementsPreview";
|
||||||
import { TargetAnnouncementsOverlay } from "./TargetAnnouncementsOverlay";
|
import { TargetAnnouncementsOverlay } from "./TargetAnnouncementsOverlay";
|
||||||
|
import { AgentRestartOverlay } from "./AgentRestartOverlay";
|
||||||
|
|
||||||
function setup(
|
function setup(
|
||||||
node: React.ReactNode,
|
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(<AgentRestartOverlay agentId="agent-T" />);
|
||||||
|
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(<AgentRestartOverlay agentId="agent-T" />);
|
||||||
|
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(<AgentRestartOverlay agentId="agent-T" />);
|
||||||
|
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)", () => {
|
describe("F2 — AnnouncementsPreview (requester filter)", () => {
|
||||||
it("shows a requester-side fallback while waiting without live text", async () => {
|
it("shows a requester-side fallback while waiting without live text", async () => {
|
||||||
setup(
|
setup(
|
||||||
|
|||||||
@ -11,6 +11,10 @@
|
|||||||
* 2026-07-04). Fed live by `agentBusyChanged { agentId, busy }` and hydrated
|
* 2026-07-04). Fed live by `agentBusyChanged { agentId, busy }` and hydrated
|
||||||
* from the reconciled read-model `ProjectWorkState.agents[].busy` at mount /
|
* from the reconciled read-model `ProjectWorkState.agents[].busy` at mount /
|
||||||
* reboot (via {@link useHydrateAgentBusy}).
|
* 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**
|
* Why busy — not "there are announcements" — drives F3: a turn can end **without**
|
||||||
* any completion event (interruption / error / crash / rate-limit). The mediator's
|
* any completion event (interruption / error / crash / rate-limit). The mediator's
|
||||||
@ -33,11 +37,13 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
|
import type { AgentRestartPhase, DomainEvent } from "@/domain";
|
||||||
import {
|
import {
|
||||||
announcementsForRequester,
|
announcementsForRequester,
|
||||||
announcementsForTarget,
|
announcementsForTarget,
|
||||||
@ -51,11 +57,33 @@ import {
|
|||||||
/** Per-agent busy map: `true` while the agent owns a turn (drives the overlay). */
|
/** Per-agent busy map: `true` while the agent owns a turn (drives the overlay). */
|
||||||
type BusyMap = Record<string, boolean>;
|
type BusyMap = Record<string, boolean>;
|
||||||
|
|
||||||
|
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<string, AgentRestartPresentation>;
|
||||||
|
type RestartJournal = Record<string, AgentRestartJournalEvent[]>;
|
||||||
|
|
||||||
interface AnnouncementsStore {
|
interface AnnouncementsStore {
|
||||||
/** Bounded announcement content, indexed by `(target, ticketId)`. */
|
/** Bounded announcement content, indexed by `(target, ticketId)`. */
|
||||||
index: AnnouncementIndex;
|
index: AnnouncementIndex;
|
||||||
/** Per-agent busy state — the overlay's lifecycle authority. */
|
/** Per-agent busy state — the overlay's lifecycle authority. */
|
||||||
busy: BusyMap;
|
busy: BusyMap;
|
||||||
|
restarts: RestartMap;
|
||||||
|
restartJournal: RestartJournal;
|
||||||
/**
|
/**
|
||||||
* Seeds an agent's busy state from the read-model, but only when no live signal
|
* 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
|
* is yet known for it: live `agentBusyChanged` events always win over a
|
||||||
@ -67,11 +95,15 @@ interface AnnouncementsStore {
|
|||||||
const noopSeed = () => {};
|
const noopSeed = () => {};
|
||||||
const EMPTY_INDEX = emptyAnnouncementIndex();
|
const EMPTY_INDEX = emptyAnnouncementIndex();
|
||||||
const EMPTY_BUSY: BusyMap = {};
|
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). */
|
/** Store shape read outside a provider — a silent, inert default (never throws). */
|
||||||
const DEFAULT_STORE: AnnouncementsStore = {
|
const DEFAULT_STORE: AnnouncementsStore = {
|
||||||
index: EMPTY_INDEX,
|
index: EMPTY_INDEX,
|
||||||
busy: EMPTY_BUSY,
|
busy: EMPTY_BUSY,
|
||||||
|
restarts: EMPTY_RESTARTS,
|
||||||
|
restartJournal: EMPTY_RESTART_JOURNAL,
|
||||||
seedBusy: noopSeed,
|
seedBusy: noopSeed,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -87,16 +119,24 @@ export function AnnouncementsProvider({
|
|||||||
const { system } = useGateways();
|
const { system } = useGateways();
|
||||||
const [index, setIndex] = useState<AnnouncementIndex>(emptyAnnouncementIndex);
|
const [index, setIndex] = useState<AnnouncementIndex>(emptyAnnouncementIndex);
|
||||||
const [busy, setBusy] = useState<BusyMap>({});
|
const [busy, setBusy] = useState<BusyMap>({});
|
||||||
|
const busyRef = useRef<BusyMap>({});
|
||||||
|
const [restarts, setRestarts] = useState<RestartMap>({});
|
||||||
|
const [restartJournal, setRestartJournal] = useState<RestartJournal>({});
|
||||||
|
|
||||||
// Hydration seed: apply only when the agent is unknown, so a live event that
|
// 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.
|
// already landed (or lands before the async read-model resolves) is not clobbered.
|
||||||
const seedBusy = useCallback((agentId: string, value: boolean) => {
|
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(() => {
|
useEffect(() => {
|
||||||
setIndex(emptyAnnouncementIndex());
|
setIndex(emptyAnnouncementIndex());
|
||||||
setBusy({});
|
setBusy({});
|
||||||
|
busyRef.current = {};
|
||||||
|
setRestarts({});
|
||||||
|
setRestartJournal({});
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -124,8 +164,71 @@ export function AnnouncementsProvider({
|
|||||||
// it (idle) and clears the target's content so a next turn starts fresh —
|
// 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).
|
// even when no completion event was ever emitted (the sticking case).
|
||||||
const { agentId, busy: isBusy } = event;
|
const { agentId, busy: isBusy } = event;
|
||||||
|
busyRef.current = { ...busyRef.current, [agentId]: isBusy };
|
||||||
setBusy((prev) => ({ ...prev, [agentId]: isBusy }));
|
setBusy((prev) => ({ ...prev, [agentId]: isBusy }));
|
||||||
if (!isBusy) setIndex((prev) => purgeAllForTarget(prev, agentId));
|
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) => {
|
.then((un) => {
|
||||||
@ -143,8 +246,8 @@ export function AnnouncementsProvider({
|
|||||||
}, [system, projectId]);
|
}, [system, projectId]);
|
||||||
|
|
||||||
const store = useMemo<AnnouncementsStore>(
|
const store = useMemo<AnnouncementsStore>(
|
||||||
() => ({ index, busy, seedBusy }),
|
() => ({ index, busy, restarts, restartJournal, seedBusy }),
|
||||||
[index, busy, seedBusy],
|
[index, busy, restarts, restartJournal, seedBusy],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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. */
|
/** The store, or an inert default when read outside a provider. */
|
||||||
function useStore(): AnnouncementsStore {
|
function useStore(): AnnouncementsStore {
|
||||||
return useContext(AnnouncementsContext) ?? DEFAULT_STORE;
|
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,
|
* 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
|
* so an overlay mounts for a turn already in flight when its cell appears — no live
|
||||||
|
|||||||
@ -7,7 +7,18 @@ export {
|
|||||||
AnnouncementsProvider,
|
AnnouncementsProvider,
|
||||||
useTargetAnnouncements,
|
useTargetAnnouncements,
|
||||||
useRequesterAnnouncements,
|
useRequesterAnnouncements,
|
||||||
|
useAgentRestart,
|
||||||
} from "./AnnouncementsProvider";
|
} from "./AnnouncementsProvider";
|
||||||
|
export type {
|
||||||
|
AgentRestartJournalEvent,
|
||||||
|
AgentRestartPresentation,
|
||||||
|
} from "./AnnouncementsProvider";
|
||||||
|
export {
|
||||||
|
AgentRestartOverlay,
|
||||||
|
restartOverlayLabel,
|
||||||
|
shortRestartFailure,
|
||||||
|
type AgentRestartOverlayProps,
|
||||||
|
} from "./AgentRestartOverlay";
|
||||||
export {
|
export {
|
||||||
TargetAnnouncementsOverlay,
|
TargetAnnouncementsOverlay,
|
||||||
type TargetAnnouncementsOverlayProps,
|
type TargetAnnouncementsOverlayProps,
|
||||||
|
|||||||
@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -30,7 +30,9 @@ import { TerminalView, useWritePortal } from "@/features/terminals";
|
|||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
||||||
import {
|
import {
|
||||||
|
AgentRestartOverlay,
|
||||||
TargetAnnouncementsOverlay,
|
TargetAnnouncementsOverlay,
|
||||||
|
useAgentRestart,
|
||||||
useTargetAnnouncements,
|
useTargetAnnouncements,
|
||||||
} from "@/features/announcements";
|
} from "@/features/announcements";
|
||||||
import { PluginLayoutCellView } from "@/features/plugins";
|
import { PluginLayoutCellView } from "@/features/plugins";
|
||||||
@ -623,6 +625,7 @@ function LeafView({
|
|||||||
// write-portal veil can be mutually excluded with the F3 overlay (exactly one
|
// 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.
|
// full-cell veil at a time, F3 first). Same source the overlay itself reads.
|
||||||
const { active: announcementBusyActive } = useTargetAnnouncements(agentId ?? "");
|
const { active: announcementBusyActive } = useTargetAnnouncements(agentId ?? "");
|
||||||
|
const { restart: agentRestart } = useAgentRestart(agentId ?? "");
|
||||||
// Ticket #54 — local model-server launch state for this cell's pinned agent.
|
// Ticket #54 — local model-server launch state for this cell's pinned agent.
|
||||||
// Correlated `agentId → profileId → opencode.localModelServerId → status` via
|
// Correlated `agentId → profileId → opencode.localModelServerId → status` via
|
||||||
// the shared hook (same reduction as `useAgents`). While the bound server is
|
// 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
|
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 —
|
message sits above the terminal. Only ever shown for an agent cell —
|
||||||
and never together with the F3 overlay (exactly one veil, F3 first). */}
|
and never together with the F3 overlay (exactly one veil, F3 first). */}
|
||||||
{!modelServerOverlay &&
|
{!agentRestart &&
|
||||||
|
!modelServerOverlay &&
|
||||||
cellMode !== "custom" &&
|
cellMode !== "custom" &&
|
||||||
shouldShowWritePortalVeil(agentId != null, Boolean(overlay), busyActive) && (
|
shouldShowWritePortalVeil(agentId != null, Boolean(overlay), busyActive) && (
|
||||||
<div
|
<div
|
||||||
@ -1504,7 +1508,7 @@ function LeafView({
|
|||||||
an availability veil showing its live réflexions. Native TUI cells
|
an availability veil showing its live réflexions. Native TUI cells
|
||||||
need the full-cell guard; custom CLI cells keep the canonical busy
|
need the full-cell guard; custom CLI cells keep the canonical busy
|
||||||
badge and composer lock only, matching their normal chat turn flow. */}
|
badge and composer lock only, matching their normal chat turn flow. */}
|
||||||
{!modelServerOverlay && cellMode !== "custom" && agentId != null && (
|
{!agentRestart && !modelServerOverlay && cellMode !== "custom" && agentId != null && (
|
||||||
<TargetAnnouncementsOverlay
|
<TargetAnnouncementsOverlay
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
@ -1517,7 +1521,7 @@ function LeafView({
|
|||||||
agent's bound local server is downloading its model / starting /
|
agent's bound local server is downloading its model / starting /
|
||||||
probing, the empty terminal is covered rather than left to time out.
|
probing, the empty terminal is covered rather than left to time out.
|
||||||
Retracts at `ready`/`failed` (the predicate returns null then). */}
|
Retracts at `ready`/`failed` (the predicate returns null then). */}
|
||||||
{modelServerOverlay && (
|
{!agentRestart && modelServerOverlay && (
|
||||||
<div
|
<div
|
||||||
data-testid="model-server-overlay"
|
data-testid="model-server-overlay"
|
||||||
role="status"
|
role="status"
|
||||||
@ -1634,6 +1638,9 @@ function LeafView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{cellMode !== "custom" && agentId != null && agentRestart && (
|
||||||
|
<AgentRestartOverlay agentId={agentId} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{busyNotice && (
|
{busyNotice && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user