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:
@ -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,
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user