fix(backend,orchestrator,chat): tour humain agent_send survit au self-restart — #290 (le tour humain direct agent_send/ws chat.send s'enregistre au RestartRendezvousCoordinator via StructuredHumanTurnGuard: Final/Error de l'ancien provider devient transition de contrôle et non le Final logique; transition_after_provider_terminal_async attend Ready puis accept_ready acquitte le handoff et libère le scheduler, Resume => drain de la session de remplacement avec SELF_RESTART_CONTINUATION_PROMPT, seul le terminal du remplacement est transmis au chat; Failed/Cancelled surfacés explicitement avec libération Busy/FIFO; send_with_tap en erreur pendant restart actif traité comme teardown attendu (stream vide puis attente du remplacement, plus de Final prématuré ni chat bloqué); StopLiveAgent câblé with_restart_rendezvous: stop utilisateur annule le rendez-vous restart; RestartRendezvousRegistration rendu public (state/changed/accept_ready); QA verte: backend self_restart_contract_tests 3/3 dont 2 nouveaux (old terminal ignoré + handoff accepté, failure explicite + busy libéré), application orchestrator_service restart 8/8 dont registration directe du rendez-vous, workstate_actions 9/9, app-tauri chat_bridge 31/31, web-server websocket_chat_send 3/3, cargo check --workspace, fmt --check, diff --check — reconfirmés par Git avant commit; réserves hors périmètre préexistantes: 7 échecs fixture PTY web-server + 2 échecs plugin application; carnet #290 + clôture store #289 inclus; scripts/ hors périmètre; aucun push)
This commit is contained in:
@ -168,9 +168,9 @@ pub use orchestrator::{
|
||||
AgentRestartScheduled, AgentSelfRestartScheduler, AgentWakeService, AskLivenessProbe,
|
||||
ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider,
|
||||
OrchestratorOutcome, OrchestratorService, ProposeContext, ReadContext, ReadMemory,
|
||||
RecordTurnProvider, RestartRendezvousCoordinator, RestartRendezvousState, WakeSessionProvider,
|
||||
WatchdogOutcome, WriteMemory, DEFAULT_RENDEZVOUS_CEILING, DEFAULT_RENDEZVOUS_WINDOW,
|
||||
SELF_RESTART_CONTINUATION_PROMPT,
|
||||
RecordTurnProvider, RestartRendezvousCoordinator, RestartRendezvousRegistration,
|
||||
RestartRendezvousState, WakeSessionProvider, WatchdogOutcome, WriteMemory,
|
||||
DEFAULT_RENDEZVOUS_CEILING, DEFAULT_RENDEZVOUS_WINDOW, SELF_RESTART_CONTINUATION_PROMPT,
|
||||
};
|
||||
pub use permission::{
|
||||
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,
|
||||
|
||||
@ -21,7 +21,7 @@ pub use rendezvous::{
|
||||
pub use service::{
|
||||
AgentRestartScheduled, AgentSelfRestartScheduler, AskLivenessProbe, ContextGuardUseCases,
|
||||
LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider, OrchestratorOutcome,
|
||||
OrchestratorService, RecordTurnProvider, RestartRendezvousCoordinator, RestartRendezvousState,
|
||||
SELF_RESTART_CONTINUATION_PROMPT,
|
||||
OrchestratorService, RecordTurnProvider, RestartRendezvousCoordinator,
|
||||
RestartRendezvousRegistration, RestartRendezvousState, SELF_RESTART_CONTINUATION_PROMPT,
|
||||
};
|
||||
pub use wake::{AgentWakeService, WakeSessionProvider};
|
||||
|
||||
@ -138,7 +138,9 @@ impl RestartRendezvousCoordinator {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn register(self: &Arc<Self>, key: RuntimeAgentKey) -> RestartRendezvousRegistration {
|
||||
/// Registers one logical turn that must survive replacement of its concrete
|
||||
/// provider session.
|
||||
pub fn register(self: &Arc<Self>, key: RuntimeAgentKey) -> RestartRendezvousRegistration {
|
||||
let mut entries = self
|
||||
.entries
|
||||
.lock()
|
||||
@ -359,20 +361,29 @@ impl RestartRendezvousCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
struct RestartRendezvousRegistration {
|
||||
/// Per-turn subscription to restart control-plane transitions.
|
||||
pub struct RestartRendezvousRegistration {
|
||||
coordinator: Arc<RestartRendezvousCoordinator>,
|
||||
key: RuntimeAgentKey,
|
||||
rx: watch::Receiver<RestartRendezvousState>,
|
||||
}
|
||||
|
||||
impl RestartRendezvousRegistration {
|
||||
fn state(&mut self) -> RestartRendezvousState {
|
||||
/// Returns the latest transition, marking it observed by this turn.
|
||||
pub fn state(&mut self) -> RestartRendezvousState {
|
||||
self.rx.borrow_and_update().clone()
|
||||
}
|
||||
|
||||
async fn changed(&mut self) -> Result<(), watch::error::RecvError> {
|
||||
/// Waits asynchronously for a restart control transition.
|
||||
pub async fn changed(&mut self) -> Result<(), watch::error::RecvError> {
|
||||
self.rx.changed().await
|
||||
}
|
||||
|
||||
/// Acknowledges ownership of the replacement session and releases the
|
||||
/// detached restart scheduler.
|
||||
pub fn accept_ready(&self, restart_id: &str) {
|
||||
self.coordinator.resume_taken(self.key, restart_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RestartRendezvousRegistration {
|
||||
|
||||
@ -149,6 +149,7 @@ pub struct StopLiveAgent {
|
||||
input: Option<Arc<dyn InputMediator>>,
|
||||
waits: Option<Arc<OrchestratorService>>,
|
||||
cascade: Option<Arc<dyn StopAgentExecutionCascade>>,
|
||||
restart_rendezvous: Option<Arc<crate::RestartRendezvousCoordinator>>,
|
||||
}
|
||||
|
||||
impl StopLiveAgent {
|
||||
@ -161,6 +162,7 @@ impl StopLiveAgent {
|
||||
input: None,
|
||||
waits: None,
|
||||
cascade: None,
|
||||
restart_rendezvous: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -187,6 +189,16 @@ impl StopLiveAgent {
|
||||
self
|
||||
}
|
||||
|
||||
/// Wires cancellation of a logical turn currently crossing a self-restart.
|
||||
#[must_use]
|
||||
pub fn with_restart_rendezvous(
|
||||
mut self,
|
||||
coordinator: Arc<crate::RestartRendezvousCoordinator>,
|
||||
) -> Self {
|
||||
self.restart_rendezvous = Some(coordinator);
|
||||
self
|
||||
}
|
||||
|
||||
/// Stops the agent's current work.
|
||||
///
|
||||
/// # Errors
|
||||
@ -198,6 +210,9 @@ impl StopLiveAgent {
|
||||
) -> Result<StopLiveAgentOutput, AppError> {
|
||||
self.stop_dependencies(&input.project, input.agent_id)
|
||||
.await?;
|
||||
if let Some(coordinator) = &self.restart_rendezvous {
|
||||
coordinator.cancel(RuntimeAgentKey::new(input.project.id, input.agent_id));
|
||||
}
|
||||
if let Some(mediator) = &self.input {
|
||||
mediator.preempt(RuntimeAgentKey::new(input.project.id, input.agent_id));
|
||||
}
|
||||
|
||||
@ -5716,6 +5716,60 @@ async fn same_project_stop_during_restart_releases_ask_and_leaves_no_replacement
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_human_turn_registration_holds_restart_until_replacement_handoff() {
|
||||
let coordinator = Arc::new(application::RestartRendezvousCoordinator::new());
|
||||
let key = rkey(1);
|
||||
let restart_id = "restart-human-turn".to_owned();
|
||||
let mut turn = coordinator.register(key);
|
||||
|
||||
assert!(
|
||||
coordinator.begin(key, restart_id.clone()),
|
||||
"a registered direct human turn owns self-restart continuation"
|
||||
);
|
||||
assert!(matches!(
|
||||
turn.state(),
|
||||
application::RestartRendezvousState::Restarting { .. }
|
||||
));
|
||||
|
||||
coordinator.ready(key, &restart_id);
|
||||
turn.changed().await.expect("ready transition remains live");
|
||||
assert_eq!(
|
||||
turn.state(),
|
||||
application::RestartRendezvousState::Ready {
|
||||
restart_id: restart_id.clone()
|
||||
}
|
||||
);
|
||||
|
||||
turn.accept_ready(&restart_id);
|
||||
coordinator
|
||||
.wait_for_handoff(key, &restart_id)
|
||||
.await
|
||||
.expect("scheduler is released only after the replacement is accepted");
|
||||
assert_eq!(turn.state(), application::RestartRendezvousState::Idle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_human_turn_registration_surfaces_restart_failure() {
|
||||
let coordinator = Arc::new(application::RestartRendezvousCoordinator::new());
|
||||
let key = rkey(1);
|
||||
let restart_id = "restart-human-failure".to_owned();
|
||||
let mut turn = coordinator.register(key);
|
||||
|
||||
assert!(coordinator.begin(key, restart_id.clone()));
|
||||
coordinator.fail(key, &restart_id, "replacement launch failed".to_owned());
|
||||
turn.changed()
|
||||
.await
|
||||
.expect("failure transition remains live");
|
||||
assert_eq!(
|
||||
turn.state(),
|
||||
application::RestartRendezvousState::Failed {
|
||||
restart_id,
|
||||
message: "replacement launch failed".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Régression #287 : la sonde transcript peut être stable (notamment inexistante pour
|
||||
/// un provider non-Claude), tandis que `send_with_tap` publie réellement l'activité du
|
||||
/// tour. Chaque événement non terminal doit réarmer la fenêtre jusqu'au `Final` tardif.
|
||||
|
||||
Reference in New Issue
Block a user