merge(backend,orchestrator,chat): l'objectif suspendu survit au self-restart — #290 regression partielle (QA verte reconfirmee par Git avant commit: 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, cargo check --workspace, fmt --check, diff --check; branche fix/290-self-restart-resume-objective commit 18c1b01; restants: rebuild AppImage + validation live reprise de l'objectif apres restart depuis le chat; réserves hors périmètre préexistantes: 7 échecs fixture PTY web-server + 2 échecs plugin application; scripts/ hors périmètre; aucun push)
This commit is contained in:
@ -1,8 +1,8 @@
|
||||
---
|
||||
issueRef: "#290"
|
||||
version: 9
|
||||
version: 11
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedAt: 1788936952028
|
||||
updatedAt: 1788937781234
|
||||
---
|
||||
# Ticket #290 - Redemarrage agent finalise le tour et bloque le chat
|
||||
|
||||
@ -24,12 +24,12 @@ updatedAt: 1788936952028
|
||||
- Stop ne doit etre cliquable que si une requete stoppable existe; ne pas exposer "Request already stopped" brut.
|
||||
- Le message de succes ne doit etre affiche qu'apres preuve runtime d'un agent relance/reconnecte.
|
||||
|
||||
## Cadrage Architecture
|
||||
## Cadrage Architecture initial
|
||||
- Bug de contrat entre restart autonome et chat humain `agent_send`.
|
||||
- `ask_structured` etait deja restart-aware; `agent_send` devait ignorer le Final de l'ancien runtime pendant restart et attendre la session remplacee.
|
||||
- Correctif backend-first, pas de filtre frontend sur le texte.
|
||||
|
||||
## Implementation DevBackend
|
||||
## Implementation DevBackend initiale
|
||||
- Branche: `fix/290-restart-agent-send-premature-final`.
|
||||
- Le tour humain s'enregistre dans `RestartRendezvousCoordinator`.
|
||||
- `Final`/`Error` de l'ancien provider ignore pendant restart actif.
|
||||
@ -38,7 +38,7 @@ updatedAt: 1788936952028
|
||||
- Stop utilisateur annule aussi le rendez-vous restart.
|
||||
- Pas de changement frontend/protocole signale.
|
||||
|
||||
## Fichiers modifies annonces
|
||||
## Fichiers modifies initiaux annonces
|
||||
- `crates/application/src/orchestrator/service.rs`
|
||||
- `crates/application/src/orchestrator/mod.rs`
|
||||
- `crates/application/src/lib.rs`
|
||||
@ -48,7 +48,7 @@ updatedAt: 1788936952028
|
||||
- `crates/app-tauri/src/commands.rs`
|
||||
- `crates/web-server/src/lib.rs`
|
||||
|
||||
## Validations DevBackend annoncees vertes
|
||||
## Validations DevBackend initiales annoncees vertes
|
||||
- `cargo check --workspace`
|
||||
- `cargo test -p backend self_restart_contract_tests -- --nocapture` — 3/3
|
||||
- `cargo test -p application --test orchestrator_service restart -- --nocapture` — 8/8
|
||||
@ -58,9 +58,9 @@ updatedAt: 1788936952028
|
||||
- `cargo fmt --all -- --check`
|
||||
- `git diff --check`
|
||||
|
||||
## Validation QA - 2026-09-09
|
||||
## Validation QA initiale - 2026-09-09
|
||||
|
||||
Verdict: VERT sur le perimetre #290, avec reserves globales reproduites hors perimetre.
|
||||
Verdict: VERT sur le perimetre initial #290, avec reserves globales reproduites hors perimetre.
|
||||
|
||||
Commandes ciblees executees:
|
||||
- `cargo check --workspace` -> vert.
|
||||
@ -82,13 +82,13 @@ Suites globales rouges reproduites hors perimetre:
|
||||
- `cargo test -p web-server -- --test-threads=1` -> 103 passed, 7 failed. Les 7 echecs sont les tests PTY `websocket_*terminal*`/`websocket_launch_agent_*` qui recoivent `error` au lieu de `terminal.attached`.
|
||||
- `cargo test -p application -- --test-threads=1` -> rouge dans `tests/layout_usecases.rs`, 2 failed: `create_layout_plugin_validates_active_runtime_contribution_and_persists_root` et `mutate_set_plugin_layout_state_persists_opaque_state`, tous deux sur `NotFound("plugin package")`.
|
||||
|
||||
## Git
|
||||
## Git initial
|
||||
- Fix commit: `d03300a` sur `fix/290-restart-agent-send-premature-final`.
|
||||
- Merge local no-ff vers `develop`: `546c7b7`.
|
||||
- Aucun push.
|
||||
- Worktree restant propre sauf `scripts/` non suivi hors perimetre recurrent.
|
||||
|
||||
## Build AppImage
|
||||
## Build AppImage initial
|
||||
- Skill `build-appimage` applique le 2026-09-09.
|
||||
- `npm --prefix frontend run build` -> vert.
|
||||
- `CARGO_HOME=/tmp/idea-cargo-home APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage` depuis `crates/app-tauri` -> vert.
|
||||
@ -100,4 +100,73 @@ Suites globales rouges reproduites hors perimetre:
|
||||
- Observation: IdeA semble bien avoir redemarre l'agent, mais au retour Main ne reprend pas l'objectif suspendu; il repond sur l'etat general du ticket #290 et parle d'autre chose.
|
||||
- Interpretation: le fix precedent traite le terminal premature et la disponibilite du chat, mais pas la conservation/reinjection de l'intention de reprise dans le nouveau runtime.
|
||||
- Attendu additionnel: apres un restart self controle par IdeA, le nouveau tour doit recevoir un handoff explicite contenant la demande utilisateur en cours, par exemple: "reprends apres redemarrage et confirme le succes", et ne doit pas repartir d'un contexte projet/ticket generique.
|
||||
- A cadrer: source de verite de cette intention suspendue, borne de retention, nettoyage apres reprise, et tests qui prouvent que le premier message post-restart est guide par le handoff exact.
|
||||
- A cadrer: source de verite de cette intention suspendue, borne de retention, nettoyage apres reprise, et tests qui prouvent que le premier message post-restart est guide par le handoff exact.
|
||||
|
||||
## Cadrage Architecture regression partielle
|
||||
- Le bug restant est la perte de l'intention metier suspendue, pas la fin prematuree du tour.
|
||||
- Source de verite: etat applicatif explicite de tour suspendable proche/au sein de `RestartRendezvousCoordinator`; le transcript n'est pas autorite.
|
||||
- Pour `agent_send`, `objective_text` doit etre la demande utilisateur originale envoyee a l'agent.
|
||||
- `idea_restart_self` ne fabrique pas l'objectif: il attache un `restart_attempt_id` au tour actif.
|
||||
- Apres relance, le nouveau runtime recoit une consigne host-provided contenant l'objectif suspendu, correlee a la tentative courante.
|
||||
- Retention en memoire suffisante pour ce lot; nettoyage sur succes terminal, cancel utilisateur, erreur terminale ou expiration.
|
||||
- Idempotence: pas de double injection pour une meme tentative, et pas d'ancien handoff injecte dans un runtime plus recent.
|
||||
- Tests minimaux: agent_send+restart+self+relance recoit l'objectif exact, cloture du meme tour, double restart sans double injection, cancel/expiration nettoient, non-regression #289.
|
||||
|
||||
## Branche regression partielle
|
||||
- Branche: `fix/290-self-restart-resume-objective`.
|
||||
- Base: `develop` @ `93a1c23`.
|
||||
- Git a commite le sync store de reouverture sur develop avant branchement: `93a1c23`.
|
||||
- Worktree signale propre sauf `scripts/restart-idea-over-ssh.sh` non suivi hors perimetre recurrent.
|
||||
|
||||
## Implementation DevBackend regression partielle - 2026-09-09
|
||||
- L'objectif utilisateur original est capture par `agent_send` et `chat.send`.
|
||||
- `RestartRendezvousCoordinator` conserve explicitement le tour, la source, l'objectif et le `restart_attempt_id`.
|
||||
- Le nouveau runtime recoit une consigne host-provided commencant par l'objectif suspendu.
|
||||
- Injection consommable une seule fois et uniquement par la tentative courante.
|
||||
- Nettoyage sur succes, echec, annulation et abandon/expiration.
|
||||
- Diagnostics ajoutes avec projet, agent, `turn_id`, `restart_attempt_id` et source.
|
||||
- Les rendez-vous inter-agents #289 conservent leur comportement existant.
|
||||
|
||||
## Fichiers modifies annonces pour regression partielle
|
||||
- `crates/application/src/orchestrator/service.rs`
|
||||
- `crates/application/src/orchestrator/mod.rs`
|
||||
- `crates/application/src/lib.rs`
|
||||
- `crates/backend/src/lib.rs`
|
||||
- `crates/app-tauri/src/commands.rs`
|
||||
- `crates/web-server/src/lib.rs`
|
||||
- `crates/application/tests/orchestrator_service.rs`
|
||||
|
||||
## Validations DevBackend regression partielle annoncees vertes
|
||||
- `cargo check --workspace` -> vert.
|
||||
- `cargo test -p backend self_restart_contract_tests -- --nocapture` -> 3/3 verts.
|
||||
- `cargo test -p application --test orchestrator_service restart -- --nocapture` -> 10/10 verts.
|
||||
- `cargo test -p application --test workstate_actions -- --test-threads=1` -> 9/9 verts.
|
||||
- `cargo test -p app-tauri --test chat_bridge` -> 31/31 verts.
|
||||
- `cargo test -p web-server websocket_chat_send -- --test-threads=1` -> 3/3 verts.
|
||||
- `cargo fmt --all -- --check` -> vert.
|
||||
- `git diff --check` -> vert.
|
||||
|
||||
## Validation QA regression partielle - 2026-09-09
|
||||
|
||||
Verdict: VERT sur `fix/290-self-restart-resume-objective`.
|
||||
|
||||
Commandes executees:
|
||||
- `cargo check --workspace` -> vert.
|
||||
- `cargo test -p backend self_restart_contract_tests -- --nocapture` -> vert, 3 passed.
|
||||
- `cargo test -p application --test orchestrator_service restart -- --nocapture` -> vert, 10 passed.
|
||||
- `cargo test -p application --test workstate_actions -- --test-threads=1` -> vert, 9 passed.
|
||||
- `cargo test -p app-tauri --test chat_bridge` -> vert, 31 passed.
|
||||
- `cargo test -p web-server websocket_chat_send -- --test-threads=1` -> vert, 3 passed.
|
||||
- `cargo fmt --all -- --check` -> vert.
|
||||
- `git diff --check` -> vert.
|
||||
|
||||
Couverture verifiee:
|
||||
- `agent_send` Tauri capture `prompt` utilisateur brut comme objectif suspendu, pas `prompt_for_model`.
|
||||
- `chat.send` websocket capture egalement le prompt utilisateur brut.
|
||||
- `RestartRendezvousCoordinator` conserve `turn_id`, `objective_text`, `source` et `restart_attempt_id`.
|
||||
- L'injection est bornee a l'etat `Ready` de la tentative courante et marquee `injected` apres lecture, donc non rejouable.
|
||||
- Les chemins cancel, failure et drop nettoient `suspended_turn`.
|
||||
- Les tests de regression #289 restent verts dans `orchestrator_service restart`.
|
||||
|
||||
Reserve:
|
||||
- Validation runtime AppImage/live non executee dans cette passe QA; les tests automatises prouvent le contrat backend/transport, pas une relance manuelle de l'AppImage en cours d'utilisation.
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
id: "00547de0-3061-4d45-ad9b-f798152f75ce"
|
||||
number: 290
|
||||
title: "Corriger le redemarrage agent qui finalise le tour et bloque le chat"
|
||||
status: "inProgress"
|
||||
status: "qa"
|
||||
priority: "high"
|
||||
sprint: null
|
||||
links: []
|
||||
@ -11,7 +11,7 @@ attachments: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
createdAt: 1788934173642
|
||||
updatedAt: 1788936952028
|
||||
version: 9
|
||||
updatedAt: 1788937781234
|
||||
version: 11
|
||||
---
|
||||
Bug rapporte par l'utilisateur le 2026-09-09: apres une demande de redemarrage, l'agent lance le redemarrage puis envoie un message; ce message est considere comme Final, le tour se termine, Stop repond "Request already stopped", et le chat devient bloque en ecriture. Attachement de reproduction: /home/anthony/Documents/Projects/IdeA/.ideai/attachments/agent-chat/7d2abba7-ed93-405f-9fe5-857c8455e436/9a881769-d8e8-410d-85f1-7a3ec2afdde0-clipboard-image-1788934010151.png. Objectif: corriger le flux pour que le redemarrage aboutisse vraiment, que l'utilisateur recupere un chat utilisable, et que le message de succes ne soit pas capture comme final prematurant le rendez-vous.
|
||||
@ -3968,7 +3968,7 @@
|
||||
"issueRef": "#290",
|
||||
"path": "290",
|
||||
"title": "Corriger le redemarrage agent qui finalise le tour et bloque le chat",
|
||||
"status": "inProgress",
|
||||
"status": "qa",
|
||||
"priority": "high",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
@ -3978,7 +3978,7 @@
|
||||
"kind": "agent",
|
||||
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||
},
|
||||
"updatedAt": 1788936952028
|
||||
"updatedAt": 1788937781234
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -2620,13 +2620,14 @@ pub async fn agent_send(
|
||||
.collect();
|
||||
let session_meta = state.structured_sessions.meta_for_session(&sid);
|
||||
let turn_guard = session_meta.as_ref().map(|(project_id, agent_id, _, _)| {
|
||||
backend::StructuredHumanTurnGuard::start(
|
||||
backend::StructuredHumanTurnGuard::start_with_source(
|
||||
std::sync::Arc::clone(&state.turn_watch_input),
|
||||
std::sync::Arc::clone(&state.input_mailbox),
|
||||
std::sync::Arc::clone(&state.restart_rendezvous),
|
||||
*project_id,
|
||||
*agent_id,
|
||||
&prompt_for_model,
|
||||
&prompt,
|
||||
"agent_send",
|
||||
)
|
||||
});
|
||||
|
||||
@ -2711,6 +2712,7 @@ pub async fn agent_send(
|
||||
'logical_turn: loop {
|
||||
let mut concrete_terminal = false;
|
||||
let mut resume = false;
|
||||
let mut resume_prompt = None;
|
||||
for event in current_stream {
|
||||
let provider_terminal = matches!(
|
||||
event,
|
||||
@ -2722,8 +2724,9 @@ pub async fn agent_send(
|
||||
if let Some(guard) = turn_guard.as_mut() {
|
||||
match runtime.block_on(guard.transition_after_provider_terminal_async()) {
|
||||
backend::StructuredHumanTurnTransition::Complete => {}
|
||||
backend::StructuredHumanTurnTransition::Resume => {
|
||||
backend::StructuredHumanTurnTransition::Resume { prompt } => {
|
||||
resume = true;
|
||||
resume_prompt = Some(prompt);
|
||||
break;
|
||||
}
|
||||
backend::StructuredHumanTurnTransition::Failed(message) => {
|
||||
@ -2789,7 +2792,10 @@ pub async fn agent_send(
|
||||
if let Some(guard) = turn_guard.as_mut() {
|
||||
match runtime.block_on(guard.transition_after_provider_terminal_async()) {
|
||||
backend::StructuredHumanTurnTransition::Complete => {}
|
||||
backend::StructuredHumanTurnTransition::Resume => resume = true,
|
||||
backend::StructuredHumanTurnTransition::Resume { prompt } => {
|
||||
resume = true;
|
||||
resume_prompt = Some(prompt);
|
||||
}
|
||||
backend::StructuredHumanTurnTransition::Failed(message) => {
|
||||
terminal_visible = true;
|
||||
let _ = bridge.send_output_settled(
|
||||
@ -2824,8 +2830,9 @@ pub async fn agent_send(
|
||||
);
|
||||
break;
|
||||
};
|
||||
match runtime.block_on(replacement.send(application::SELF_RESTART_CONTINUATION_PROMPT))
|
||||
{
|
||||
let resume_prompt = resume_prompt
|
||||
.unwrap_or_else(|| application::SELF_RESTART_CONTINUATION_PROMPT.to_owned());
|
||||
match runtime.block_on(replacement.send(&resume_prompt)) {
|
||||
Ok(stream) => current_stream = stream,
|
||||
Err(error) => {
|
||||
terminal_visible = true;
|
||||
|
||||
@ -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!(
|
||||
|
||||
@ -5770,6 +5770,96 @@ async fn direct_human_turn_registration_surfaces_restart_failure() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn suspended_human_objective_is_bound_to_latest_attempt_and_taken_once() {
|
||||
let coordinator = Arc::new(application::RestartRendezvousCoordinator::new());
|
||||
let key = rkey(1);
|
||||
let turn = coordinator.register_suspended_turn(
|
||||
key,
|
||||
"human-turn-290".to_owned(),
|
||||
"redémarre puis confirme que tu as redémarré".to_owned(),
|
||||
"agent_send".to_owned(),
|
||||
);
|
||||
|
||||
assert!(coordinator.begin(key, "attempt-old".to_owned()));
|
||||
assert!(coordinator.begin(key, "attempt-current".to_owned()));
|
||||
coordinator.ready(key, "attempt-old");
|
||||
assert!(
|
||||
turn.take_continuation("attempt-old").is_none(),
|
||||
"an older restart attempt cannot inject its handoff"
|
||||
);
|
||||
|
||||
coordinator.ready(key, "attempt-current");
|
||||
let continuation = turn
|
||||
.take_continuation("attempt-current")
|
||||
.expect("the current attempt owns the suspended objective");
|
||||
assert_eq!(continuation.turn_id, "human-turn-290");
|
||||
assert_eq!(
|
||||
continuation.objective_text,
|
||||
"redémarre puis confirme que tu as redémarré"
|
||||
);
|
||||
assert_eq!(continuation.source, "agent_send");
|
||||
assert!(
|
||||
turn.take_continuation("attempt-current").is_none(),
|
||||
"the same restart attempt cannot inject twice"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_suspended_turn_during_restart_cleans_handoff_and_unblocks_scheduler() {
|
||||
let coordinator = Arc::new(application::RestartRendezvousCoordinator::new());
|
||||
let key = rkey(1);
|
||||
let turn = coordinator.register_suspended_turn(
|
||||
key,
|
||||
"human-turn-expired".to_owned(),
|
||||
"objectif expiré".to_owned(),
|
||||
"agent_send".to_owned(),
|
||||
);
|
||||
let restart_id = "attempt-expired";
|
||||
|
||||
assert!(coordinator.begin(key, restart_id.to_owned()));
|
||||
drop(turn);
|
||||
|
||||
let error = coordinator
|
||||
.wait_for_handoff(key, restart_id)
|
||||
.await
|
||||
.expect_err("dropping the bounded turn cancels and unblocks restart");
|
||||
assert!(error.contains("cancelled"));
|
||||
coordinator.ready(key, restart_id);
|
||||
assert!(
|
||||
coordinator.take_continuation(key, restart_id).is_none(),
|
||||
"an expired objective is cleaned and cannot be injected"
|
||||
);
|
||||
coordinator.finish(key, restart_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelling_suspended_human_restart_cleans_handoff_and_unblocks_scheduler() {
|
||||
let coordinator = Arc::new(application::RestartRendezvousCoordinator::new());
|
||||
let key = rkey(1);
|
||||
let turn = coordinator.register_suspended_turn(
|
||||
key,
|
||||
"human-turn-cancelled".to_owned(),
|
||||
"objectif annulé".to_owned(),
|
||||
"agent_send".to_owned(),
|
||||
);
|
||||
let restart_id = "attempt-cancelled";
|
||||
|
||||
assert!(coordinator.begin(key, restart_id.to_owned()));
|
||||
assert!(coordinator.cancel(key));
|
||||
let error = coordinator
|
||||
.wait_for_handoff(key, restart_id)
|
||||
.await
|
||||
.expect_err("cancel must unblock the detached restart scheduler");
|
||||
assert!(error.contains("cancelled"));
|
||||
assert!(
|
||||
turn.take_continuation(restart_id).is_none(),
|
||||
"cancelled objective cannot reach a replacement runtime"
|
||||
);
|
||||
drop(turn);
|
||||
coordinator.finish(key, restart_id);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@ -1131,7 +1131,9 @@ mod self_restart_contract_tests {
|
||||
coordinator.ready(key, &restart_id);
|
||||
assert_eq!(
|
||||
turn.transition_after_provider_terminal_async().await,
|
||||
StructuredHumanTurnTransition::Resume,
|
||||
StructuredHumanTurnTransition::Resume {
|
||||
prompt: application::suspended_restart_continuation_prompt("restart me"),
|
||||
},
|
||||
"the old provider terminal is a control transition, not the human Final"
|
||||
);
|
||||
coordinator
|
||||
@ -1453,7 +1455,10 @@ pub enum StructuredHumanTurnTransition {
|
||||
/// No self-restart owns this terminal event; it is authoritative for the UI.
|
||||
Complete,
|
||||
/// The old terminal event is superseded; continue on the replacement runtime.
|
||||
Resume,
|
||||
Resume {
|
||||
/// Host-provided first prompt for the replacement runtime.
|
||||
prompt: String,
|
||||
},
|
||||
/// Replacement failed explicitly.
|
||||
Failed(String),
|
||||
/// The user cancelled the logical turn while replacement was in flight.
|
||||
@ -1470,6 +1475,29 @@ impl StructuredHumanTurnGuard {
|
||||
project_id: ProjectId,
|
||||
agent_id: AgentId,
|
||||
prompt: &str,
|
||||
) -> Self {
|
||||
Self::start_with_source(
|
||||
input,
|
||||
mailbox,
|
||||
restart_rendezvous,
|
||||
project_id,
|
||||
agent_id,
|
||||
prompt,
|
||||
"direct_human_chat",
|
||||
)
|
||||
}
|
||||
|
||||
/// Starts a direct human turn while retaining its exact restart objective and
|
||||
/// diagnostic entry-point source in application state.
|
||||
#[must_use]
|
||||
pub fn start_with_source(
|
||||
input: Arc<dyn domain::input::InputMediator>,
|
||||
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
|
||||
restart_rendezvous: Arc<application::RestartRendezvousCoordinator>,
|
||||
project_id: ProjectId,
|
||||
agent_id: AgentId,
|
||||
prompt: &str,
|
||||
source: &str,
|
||||
) -> Self {
|
||||
let agent = domain::RuntimeAgentKey::new(project_id, agent_id);
|
||||
let ticket_id = domain::mailbox::TicketId::new_random();
|
||||
@ -1486,7 +1514,14 @@ impl StructuredHumanTurnGuard {
|
||||
agent,
|
||||
ticket_id,
|
||||
armed: Arc::new(std::sync::atomic::AtomicBool::new(owns_turn)),
|
||||
restart: owns_turn.then(|| restart_rendezvous.register(agent)),
|
||||
restart: owns_turn.then(|| {
|
||||
restart_rendezvous.register_suspended_turn(
|
||||
agent,
|
||||
ticket_id.to_string(),
|
||||
prompt.to_owned(),
|
||||
source.to_owned(),
|
||||
)
|
||||
}),
|
||||
finish_on_drop: true,
|
||||
}
|
||||
}
|
||||
@ -1527,8 +1562,16 @@ impl StructuredHumanTurnGuard {
|
||||
}
|
||||
}
|
||||
application::RestartRendezvousState::Ready { restart_id } => {
|
||||
let prompt = restart.take_continuation(&restart_id).map_or_else(
|
||||
|| application::SELF_RESTART_CONTINUATION_PROMPT.to_owned(),
|
||||
|continuation| {
|
||||
application::suspended_restart_continuation_prompt(
|
||||
&continuation.objective_text,
|
||||
)
|
||||
},
|
||||
);
|
||||
restart.accept_ready(&restart_id);
|
||||
return StructuredHumanTurnTransition::Resume;
|
||||
return StructuredHumanTurnTransition::Resume { prompt };
|
||||
}
|
||||
application::RestartRendezvousState::Failed { message, .. } => {
|
||||
return StructuredHumanTurnTransition::Failed(message);
|
||||
|
||||
@ -1879,13 +1879,14 @@ async fn ws_send_chat(
|
||||
})?;
|
||||
let session_meta = state.app.structured_sessions.meta_for_session(&sid);
|
||||
let turn_guard = session_meta.as_ref().map(|(project_id, agent_id, _, _)| {
|
||||
backend::StructuredHumanTurnGuard::start(
|
||||
backend::StructuredHumanTurnGuard::start_with_source(
|
||||
Arc::clone(&state.app.turn_watch_input),
|
||||
Arc::clone(&state.app.input_mailbox),
|
||||
Arc::clone(&state.app.restart_rendezvous),
|
||||
*project_id,
|
||||
*agent_id,
|
||||
&prompt,
|
||||
"websocket_chat_send",
|
||||
)
|
||||
});
|
||||
|
||||
@ -1935,6 +1936,7 @@ async fn ws_send_chat(
|
||||
'logical_turn: loop {
|
||||
let mut concrete_terminal = false;
|
||||
let mut resume = false;
|
||||
let mut resume_prompt = None;
|
||||
for event in stream {
|
||||
let provider_terminal =
|
||||
matches!(event, ReplyEvent::Final { .. } | ReplyEvent::Error { .. });
|
||||
@ -1943,8 +1945,9 @@ async fn ws_send_chat(
|
||||
if let Some(guard) = turn_guard.as_mut() {
|
||||
match guard.transition_after_provider_terminal_async().await {
|
||||
backend::StructuredHumanTurnTransition::Complete => {}
|
||||
backend::StructuredHumanTurnTransition::Resume => {
|
||||
backend::StructuredHumanTurnTransition::Resume { prompt } => {
|
||||
resume = true;
|
||||
resume_prompt = Some(prompt);
|
||||
break;
|
||||
}
|
||||
backend::StructuredHumanTurnTransition::Failed(message) => {
|
||||
@ -1995,7 +1998,10 @@ async fn ws_send_chat(
|
||||
if let Some(guard) = turn_guard.as_mut() {
|
||||
match guard.transition_after_provider_terminal_async().await {
|
||||
backend::StructuredHumanTurnTransition::Complete => {}
|
||||
backend::StructuredHumanTurnTransition::Resume => resume = true,
|
||||
backend::StructuredHumanTurnTransition::Resume { prompt } => {
|
||||
resume = true;
|
||||
resume_prompt = Some(prompt);
|
||||
}
|
||||
backend::StructuredHumanTurnTransition::Failed(message) => {
|
||||
let chunk = reply_event_chunk(ReplyEvent::Error { message });
|
||||
tx.send(ServerFrame::chat_output(
|
||||
@ -2028,8 +2034,10 @@ async fn ws_send_chat(
|
||||
code: "CHAT_RESTART_FAILED".to_owned(),
|
||||
message: "restart completed without a replacement structured session".to_owned(),
|
||||
})?;
|
||||
let resume_prompt = resume_prompt
|
||||
.unwrap_or_else(|| application::SELF_RESTART_CONTINUATION_PROMPT.to_owned());
|
||||
stream = replacement
|
||||
.send(application::SELF_RESTART_CONTINUATION_PROMPT)
|
||||
.send(&resume_prompt)
|
||||
.await
|
||||
.map_err(|err| ErrorDto {
|
||||
code: "CHAT_RESTART_FAILED".to_owned(),
|
||||
|
||||
Reference in New Issue
Block a user