fix(backend,orchestrator,chat): l'objectif suspendu guide le premier tour post-restart — #290 regression partielle (register_suspended_turn capture la demande utilisateur exacte au point d'entrée chat agent_send/chat.send avec source diagnostique, RestartRendezvousCoordinator conserve tour/objectif/restart_attempt_id, take_continuation consommable exactement une fois et seulement par la tentative courante: suspended_restart_continuation_prompt place l'objectif utilisateur avant toute consigne generique et StructuredHumanTurnTransition::Resume porte le prompt host-provided injecte au runtime de remplacement; nettoyage du tour suspendu sur succes, echec, annulation et abandon/expiration; diagnostics projet/agent/turn_id/restart_attempt_id/source; rendez-vous inter-agents #289 inchanges; QA verte reconfirmee par Git: cargo check --workspace, backend self_restart_contract_tests 3/3, orchestrator_service restart 10/10, workstate_actions 9/9, app-tauri chat_bridge 31/31, web-server websocket_chat_send 3/3, fmt --check, diff --check; restants: rebuild AppImage + validation live reprise objectif apres restart; carnet #290 iteration regression + passage store qa inclus; scripts/ hors périmètre; aucun push)

This commit is contained in:
2026-09-09 09:17:36 +02:00
parent 93a1c23a29
commit 18c1b01afb
10 changed files with 426 additions and 41 deletions

View File

@ -165,12 +165,13 @@ pub use model_server::{
};
pub use orchestrator::{
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
AgentRestartScheduled, AgentSelfRestartScheduler, AgentWakeService, AskLivenessProbe,
ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider,
OrchestratorOutcome, OrchestratorService, ProposeContext, ReadContext, ReadMemory,
RecordTurnProvider, RestartRendezvousCoordinator, RestartRendezvousRegistration,
RestartRendezvousState, WakeSessionProvider, WatchdogOutcome, WriteMemory,
DEFAULT_RENDEZVOUS_CEILING, DEFAULT_RENDEZVOUS_WINDOW, SELF_RESTART_CONTINUATION_PROMPT,
suspended_restart_continuation_prompt, AgentRestartScheduled, AgentSelfRestartScheduler,
AgentWakeService, AskLivenessProbe, ContextGuardUseCases, LiveStateProvider,
LiveStateReadProvider, McpRuntimeProvider, OrchestratorOutcome, OrchestratorService,
ProposeContext, ReadContext, ReadMemory, RecordTurnProvider, RestartContinuation,
RestartRendezvousCoordinator, RestartRendezvousRegistration, RestartRendezvousState,
WakeSessionProvider, WatchdogOutcome, WriteMemory, DEFAULT_RENDEZVOUS_CEILING,
DEFAULT_RENDEZVOUS_WINDOW, SELF_RESTART_CONTINUATION_PROMPT,
};
pub use permission::{
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,

View File

@ -19,9 +19,10 @@ pub use rendezvous::{
WatchdogOutcome, DEFAULT_RENDEZVOUS_CEILING, DEFAULT_RENDEZVOUS_WINDOW,
};
pub use service::{
AgentRestartScheduled, AgentSelfRestartScheduler, AskLivenessProbe, ContextGuardUseCases,
LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider, OrchestratorOutcome,
OrchestratorService, RecordTurnProvider, RestartRendezvousCoordinator,
RestartRendezvousRegistration, RestartRendezvousState, SELF_RESTART_CONTINUATION_PROMPT,
suspended_restart_continuation_prompt, AgentRestartScheduled, AgentSelfRestartScheduler,
AskLivenessProbe, ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider,
McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
RestartContinuation, RestartRendezvousCoordinator, RestartRendezvousRegistration,
RestartRendezvousState, SELF_RESTART_CONTINUATION_PROMPT,
};
pub use wake::{AgentWakeService, WakeSessionProvider};

View File

@ -87,6 +87,18 @@ pub trait AgentSelfRestartScheduler: Send + Sync {
pub const SELF_RESTART_CONTINUATION_PROMPT: &str =
"IdeA vient de te redémarrer. Reprends immédiatement le travail en cours là où tu l'avais laissé.";
/// Builds the host-provided first instruction for a replacement runtime. The
/// suspended objective is deliberately placed before any generic continuation
/// guidance so the resumed agent cannot substitute unrelated project status.
#[must_use]
pub fn suspended_restart_continuation_prompt(objective_text: &str) -> String {
format!(
"Objectif utilisateur suspendu avant ton redémarrage :\n\n{objective_text}\n\n\
IdeA vient de te redémarrer. Reprends cet objectif immédiatement et réponds à \
l'utilisateur dans ce même tour."
)
}
/// Control-plane state of a self-restart affecting an active logical rendezvous.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RestartRendezvousState {
@ -120,9 +132,30 @@ struct RestartRendezvousEntry {
active_waiters: usize,
activity: u64,
state: RestartRendezvousState,
suspended_turn: Option<SuspendedRestartTurn>,
tx: watch::Sender<RestartRendezvousState>,
}
#[derive(Debug, Clone)]
struct SuspendedRestartTurn {
turn_id: String,
objective_text: String,
source: String,
restart_attempt_id: Option<String>,
injected: bool,
}
/// Host-provided continuation payload for a logical turn suspended by self-restart.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RestartContinuation {
/// Stable id of the logical turn that originally received the user request.
pub turn_id: String,
/// Exact user objective captured at the normal chat entry point.
pub objective_text: String,
/// Entry-point label used for restart diagnostics.
pub source: String,
}
/// Stable, runtime-keyed seam shared by the orchestrator and the detached restart
/// adapter. It keeps a logical ask alive while its concrete provider session is
/// replaced.
@ -152,6 +185,7 @@ impl RestartRendezvousCoordinator {
active_waiters: 0,
activity: 0,
state,
suspended_turn: None,
tx,
}
});
@ -163,6 +197,39 @@ impl RestartRendezvousCoordinator {
}
}
/// Registers a logical human turn and retains its original objective as the
/// application-owned restart handoff. The payload is never reconstructed from
/// transcript or generic project context.
pub fn register_suspended_turn(
self: &Arc<Self>,
key: RuntimeAgentKey,
turn_id: String,
objective_text: String,
source: String,
) -> RestartRendezvousRegistration {
let registration = self.register(key);
let mut entries = self
.entries
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(entry) = entries.get_mut(&key) {
crate::diag!(
"[restart-rendezvous] suspended objective registered: project={} agent={} \
turn_id={turn_id} source={source}",
key.project_id,
key.agent_id,
);
entry.suspended_turn = Some(SuspendedRestartTurn {
turn_id,
objective_text,
source,
restart_attempt_id: None,
injected: false,
});
}
registration
}
/// Announces a restart synchronously. Returns whether an active ask owns the
/// continuation and therefore must receive the post-restart `Final`.
pub fn begin(&self, key: RuntimeAgentKey, restart_id: String) -> bool {
@ -176,6 +243,21 @@ impl RestartRendezvousCoordinator {
if entry.active_waiters == 0 {
return false;
}
if let Some(turn) = entry.suspended_turn.as_mut() {
if turn.restart_attempt_id.as_deref() != Some(restart_id.as_str()) {
turn.restart_attempt_id = Some(restart_id.clone());
turn.injected = false;
}
crate::diag!(
"[restart-rendezvous] restart attached to suspended objective: project={} \
agent={} turn_id={} restart_attempt_id={} source={}",
key.project_id,
key.agent_id,
turn.turn_id,
restart_id,
turn.source,
);
}
entry.activity = entry.activity.saturating_add(1);
entry.state = RestartRendezvousState::Restarting { restart_id };
entry.tx.send_replace(entry.state.clone());
@ -204,10 +286,50 @@ impl RestartRendezvousCoordinator {
return;
}
entry.state = RestartRendezvousState::Idle;
entry.suspended_turn = None;
entry.activity = entry.activity.saturating_add(1);
entry.tx.send_replace(entry.state.clone());
}
/// Takes the suspended objective exactly once for the matching restart attempt.
/// Stale attempts and duplicate consumers cannot obtain a payload.
pub fn take_continuation(
&self,
key: RuntimeAgentKey,
restart_id: &str,
) -> Option<RestartContinuation> {
let mut entries = self
.entries
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let entry = entries.get_mut(&key)?;
if !matches!(
&entry.state,
RestartRendezvousState::Ready { restart_id: current } if current == restart_id
) {
return None;
}
let turn = entry.suspended_turn.as_mut()?;
if turn.restart_attempt_id.as_deref() != Some(restart_id) || turn.injected {
return None;
}
turn.injected = true;
crate::diag!(
"[restart-rendezvous] suspended objective injected: project={} agent={} \
turn_id={} restart_attempt_id={} source={}",
key.project_id,
key.agent_id,
turn.turn_id,
restart_id,
turn.source,
);
Some(RestartContinuation {
turn_id: turn.turn_id.clone(),
objective_text: turn.objective_text.clone(),
source: turn.source.clone(),
})
}
/// Publishes an explicit restart failure to the awaiting logical rendezvous.
pub fn fail(&self, key: RuntimeAgentKey, restart_id: &str, message: String) {
self.transition(key, restart_id, |restart_id| {
@ -233,7 +355,19 @@ impl RestartRendezvousCoordinator {
| RestartRendezvousState::Ready { restart_id } => restart_id.clone(),
_ => return false,
};
if let Some(turn) = entry.suspended_turn.as_ref() {
crate::diag!(
"[restart-rendezvous] suspended objective cancelled: project={} agent={} \
turn_id={} restart_attempt_id={} source={}",
key.project_id,
key.agent_id,
turn.turn_id,
restart_id,
turn.source,
);
}
entry.state = RestartRendezvousState::Cancelled { restart_id };
entry.suspended_turn = None;
entry.activity = entry.activity.saturating_add(1);
entry.tx.send_replace(entry.state.clone());
true
@ -264,8 +398,23 @@ impl RestartRendezvousCoordinator {
if !matches {
return;
}
let next_state = next(restart_id.to_owned());
if matches!(next_state, RestartRendezvousState::Failed { .. }) {
if let Some(turn) = entry.suspended_turn.as_ref() {
crate::diag!(
"[restart-rendezvous] suspended objective failed: project={} agent={} \
turn_id={} restart_attempt_id={} source={}",
key.project_id,
key.agent_id,
turn.turn_id,
restart_id,
turn.source,
);
}
entry.suspended_turn = None;
}
entry.activity = entry.activity.saturating_add(1);
entry.state = next(restart_id.to_owned());
entry.state = next_state;
entry.tx.send_replace(entry.state.clone());
}
@ -379,6 +528,11 @@ impl RestartRendezvousRegistration {
self.rx.changed().await
}
/// Takes the application-owned suspended objective for this exact attempt.
pub fn take_continuation(&self, restart_id: &str) -> Option<RestartContinuation> {
self.coordinator.take_continuation(self.key, restart_id)
}
/// Acknowledges ownership of the replacement session and releases the
/// detached restart scheduler.
pub fn accept_ready(&self, restart_id: &str) {
@ -404,7 +558,19 @@ impl Drop for RestartRendezvousRegistration {
_ => None,
};
if let Some(restart_id) = restart_id {
if let Some(turn) = entry.suspended_turn.as_ref() {
crate::diag!(
"[restart-rendezvous] suspended objective abandoned: project={} agent={} \
turn_id={} restart_attempt_id={} source={}",
self.key.project_id,
self.key.agent_id,
turn.turn_id,
restart_id,
turn.source,
);
}
entry.state = RestartRendezvousState::Cancelled { restart_id };
entry.suspended_turn = None;
entry.activity = entry.activity.saturating_add(1);
entry.tx.send_replace(entry.state.clone());
} else if matches!(