From 2f3b285595e54a74a950f6446e3c0f002db66528 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Wed, 23 Sep 2026 08:47:48 -0700 Subject: [PATCH] fix(goals): resume a runtime-stopped goal when the user writes again When a goal continuation turn failed, timed out or never started, the Engine marked the goal Blocked. The next message from the person still arrived with the host's Blocked status, so it ran as an ordinary goalless turn: the goal stayed blocked on an obsolete runtime blocker and the model's progress updates were refused ("requires an active goal") with no way forward except a manual /goal resume. The Engine now records whether a blocker came from the runtime (block_goal_continuation) or was reported by the model/user (update_goal / mark_blocked). A message from the person (ExternalUser provenance) naming the same objective resumes a runtime-blocked goal as a new control revision and publishes GoalUpdated plus a status line. Reported blockers stay until an explicit resume; runtime and automated inputs never resume anything. Known limitation: the origin is session-local, so a restored Blocked goal still needs /goal resume. Evidence: 150 passed, 0 failed (13,108 skipped) on the `goal` selection, including the new engine test. With the resume hook disabled, that test fails (0 passed, 1 failed: "the continue turn ran as a resumed goal revision"). TUI all-target/all-feature Clippy with CI flags and fmt passed. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/tui/src/core/engine.rs | 48 +++++++++++++++- crates/tui/src/core/engine/tests.rs | 86 +++++++++++++++++++++++++++++ crates/tui/src/tools/goal.rs | 31 +++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) 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);