Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
86 changes: 86 additions & 0 deletions crates/tui/src/core/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
31 changes: 31 additions & 0 deletions crates/tui/src/tools/goal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ pub struct GoalState {
last_gap_pass: Option<u32>,
/// Latest reported progress, kept out of the stall accounting entirely.
progress: Option<GoalProgressReport>,
/// 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 {
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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);
Expand Down
Loading