diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 236c8ca351..9968ef3e14 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -4403,7 +4403,7 @@ impl Engine { let snapshot = match self.config.goal_state.lock() { Ok(mut state) => { if state.is_active() - && let Err(err) = state.mark_blocked(message.clone()) + && let Err(err) = state.mark_runtime_blocked(message.clone()) { tracing::warn!("failed to mark goal continuation blocked: {err}"); return; @@ -4433,6 +4433,37 @@ impl Engine { let _ = self.tx_event.send(Event::status(message)).await; } + /// Resume the shared goal when its only blocker was a runtime stop and it + /// is the objective this turn names; publish the change like any other + /// goal transition. + async fn resume_runtime_blocked_goal(&mut self, objective: Option<&str>) -> bool { + let snapshot = match self.config.goal_state.lock() { + Ok(mut state) => { + if normalized_goal_objective(state.objective()) + != normalized_goal_objective(objective) + || !state.resume_after_runtime_block() + { + return false; + } + state.snapshot() + } + Err(err) => { + tracing::warn!("goal state lock poisoned while resuming a goal: {err}"); + return false; + } + }; + self.config.goal_status = GoalStatus::Active; + self.emit_session_updated().await; + let _ = self.tx_event.send(Event::GoalUpdated { snapshot }).await; + let _ = self + .tx_event + .send(Event::status( + "Goal resumed: your message continues the work the earlier turn stopped", + )) + .await; + true + } + /// Pause a still-active goal with an inspectable reason and publish every /// host projection in one ordered path. async fn pause_goal_continuation(&mut self, reason: GoalPauseReason, message: String) { @@ -5089,6 +5120,21 @@ impl Engine { } } + // A person writing to a goal that only the runtime stopped (a failed + // or timed-out continuation) is continuing the work: resume it as a + // new revision instead of running a goalless turn against a stale + // blocker. Blockers the model or user reported stay until an explicit + // resume, and automated inputs never resume anything. + let goal_status = if provenance == UserInputProvenance::ExternalUser + && goal_status == GoalStatus::Blocked + && self + .resume_runtime_blocked_goal(goal_objective.as_deref()) + .await + { + GoalStatus::Active + } else { + goal_status + }; let input_policy = effective_input_policy( provenance, mode, diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 08b8ab2107..992c8f3212 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -2599,6 +2599,92 @@ async fn initial_goal_failure_projects_blocked_state() { run_task.await.expect("engine task"); } +/// A goal the runtime stopped (its turn failed) resumes when the person +/// writes again; the host still reports Blocked because it only learns of +/// the resume from this turn's GoalUpdated. +#[tokio::test] +async fn user_message_resumes_a_goal_only_the_runtime_blocked() { + let objective = "resume after a runtime stop"; + let model = std::sync::Arc::new(FailingGoalModelClient { + calls: std::sync::atomic::AtomicUsize::new(0), + message: "turn deadline elapsed".to_string(), + }); + let config = goal_custom_route_config(); + let client: crate::core::model_client::SharedModelClient = model.clone(); + let (engine, handle) = Engine::new_with_model_client( + EngineConfig { + model: "local-model".to_string(), + snapshots_enabled: false, + terminal_chrome_enabled: false, + ..EngineConfig::default() + }, + &config, + client, + ); + let goal_state = engine.config.goal_state.clone(); + let run_task = tokio::spawn(engine.run()); + let settle = || async { + tokio::time::timeout(model_turn_event_timeout(), handle.get_session_snapshot()) + .await + .expect("turn did not settle") + .expect("session snapshot") + }; + + handle + .send(active_goal_message_op(&config, "start", objective, None)) + .await + .expect("send goal turn"); + settle().await; + let blocked = goal_state.lock().expect("goal lock").snapshot(); + assert_eq!(blocked.status, "blocked"); + + let Op::SendMessage(mut spec) = active_goal_message_op(&config, "continue", objective, None) + else { + unreachable!() + }; + spec.goal_status = crate::tools::goal::GoalStatus::Blocked; + handle + .send(Op::SendMessage(spec)) + .await + .expect("send continue"); + settle().await; + assert_eq!(model.calls.load(std::sync::atomic::Ordering::SeqCst), 2); + let resumed = goal_state.lock().expect("goal lock").snapshot(); + assert_ne!( + resumed.goal_id, blocked.goal_id, + "the continue turn ran as a resumed goal revision" + ); + + // A blocker the model reported is a judgement: the next message is an + // ordinary turn and the goal stays blocked on that report. + goal_state + .lock() + .expect("goal lock") + .mark_blocked("needs the staging credentials".to_string()) + .unwrap(); + let reported = goal_state.lock().expect("goal lock").snapshot(); + let Op::SendMessage(mut spec) = active_goal_message_op(&config, "continue", objective, None) + else { + unreachable!() + }; + spec.goal_status = crate::tools::goal::GoalStatus::Blocked; + handle + .send(Op::SendMessage(spec)) + .await + .expect("send ordinary turn"); + settle().await; + let after = goal_state.lock().expect("goal lock").snapshot(); + assert_eq!(after.status, "blocked"); + assert_eq!(after.goal_id, reported.goal_id); + assert_eq!( + after.blocker.as_deref(), + Some("needs the staging credentials") + ); + + handle.send(Op::Shutdown).await.expect("shutdown engine"); + run_task.await.expect("engine task"); +} + #[tokio::test] async fn initial_goal_interruption_keeps_goal_active() { let objective = "keep goal active after interrupted turn"; diff --git a/crates/tui/src/tools/goal.rs b/crates/tui/src/tools/goal.rs index a8bfb5296b..e7084e3e1d 100644 --- a/crates/tui/src/tools/goal.rs +++ b/crates/tui/src/tools/goal.rs @@ -129,6 +129,13 @@ pub struct GoalState { last_gap_pass: Option, /// Latest reported progress, kept out of the stall accounting entirely. progress: Option, + /// The current blocker was set by the runtime (a continuation turn that + /// failed, timed out or never started), not reported by the model or the + /// user. Such a stop is not a judgement about the work, so the user's next + /// message resumes the goal (see [`Self::resume_after_runtime_block`]). + /// Known limitation: session-local, like the rest of this state's + /// lifecycle detail; a restored Blocked goal needs `/goal resume`. + runtime_blocked: bool, } impl GoalState { @@ -284,6 +291,9 @@ impl GoalState { repeated_gap_count: 0, last_gap_pass: None, progress: None, + // The origin of a persisted blocker is not recorded; treat it as + // reported so only an explicit resume clears it. + runtime_blocked: false, } } @@ -477,10 +487,31 @@ impl GoalState { Ok(()) } + /// Block on a runtime stop rather than a reported blocker; see + /// [`Self::runtime_blocked`]. + pub fn mark_runtime_blocked(&mut self, blocker: String) -> Result<(), &'static str> { + self.mark_blocked(blocker)?; + self.runtime_blocked = true; + Ok(()) + } + + /// Resume a goal whose only blocker was a runtime stop, as a new control + /// revision. Returns false, changing nothing, for any other state: a + /// reported blocker stays until an explicit resume. + pub fn resume_after_runtime_block(&mut self) -> bool { + if !(self.runtime_blocked && self.status == Some(GoalStatus::Blocked)) { + return false; + } + self.resume(None); + self.runtime_blocked = false; + true + } + pub fn mark_blocked(&mut self, blocker: String) -> Result<(), &'static str> { if self.objective.is_none() { return Err("No active goal exists to block."); } + self.runtime_blocked = false; self.status = Some(GoalStatus::Blocked); self.finished_at = Some(Instant::now()); self.blocker = Some(blocker);