Skip to content
2 changes: 1 addition & 1 deletion crates/stella-cli/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ mod coverage;
mod engine;
mod goal;
mod graph;
mod outcome;
pub(crate) mod outcome;
mod output;
mod persistence;
mod presence;
Expand Down
70 changes: 32 additions & 38 deletions crates/stella-cli/src/agent/goal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ pub async fn run_goal_cmd(
goal: &str,
budget_limit: Option<f64>,
use_pipeline: bool,
) -> Result<(), String> {
) -> Result<(), crate::failure::CliFailure> {
crate::enterprise_telemetry::authorize_execution_surface(
crate::enterprise_telemetry::ExecutionSurface::Goal,
)?;
Expand Down Expand Up @@ -370,22 +370,19 @@ pub async fn run_goal_cmd(
// Goal runs are long by construction — always land the inbox
// notification (Enter on it replays this session's journal).
let goal_secs = crate::memory::unix_now_secs().saturating_sub(started_unix);
let notify = if outcome.is_ok() {
format!("{}: goal met ({goal_secs}s)", presence.name())
} else {
format!("{}: goal run FAILED", presence.name())
let notify = match &outcome {
Ok(()) => format!("{}: goal met ({goal_secs}s)", presence.name()),
Err(failure) if failure.is_deliberate_stop() => {
format!("{}: goal run stopped by policy", presence.name())
}
Err(_) => format!("{}: goal run FAILED", presence.name()),
};
// The goal loop still answers with a `String`, which has no room for the
// abort's typed kind (#1637's collapse one level deeper — #1862), so a
// policy-stopped goal round can only record `Error` here. Projected
// through `outcome_status` all the same, so a user interrupt records
// `Cancelled` rather than aging into a crash (#1826).
let terminal = outcome
.as_ref()
.map(|_| ())
.map_err(|e| crate::failure::CliFailure::error(e.clone()));
// The typed abort survives to this terminal write (#1862): the same
// decider as every other registry writer projects a deliberate stop as
// `Stopped`, a user interrupt as `Cancelled`, and a crash as `Error`
// (#1653, #1826).
presence.finish(
crate::daemon::outcome_status(terminal.as_ref().map(|_| ())),
crate::daemon::outcome_status(outcome.as_ref().map(|_| ())),
Some((notify, crate::command_deck::prompt_line(goal, 160))),
);
outcome
Expand Down Expand Up @@ -425,7 +422,7 @@ pub(crate) async fn run_goal_turn(
// before the turn runs — reflection stores the self-review 1:1 with an
// execution, and an unstamped round files an id-less row.
session_memory: Option<&mut crate::memory::SessionMemory>,
) -> Result<(), String> {
) -> Result<(), crate::failure::CliFailure> {
let turn_start = Instant::now();
let execution = begin_execution(store, "goal", goal, cfg, session);
if let (Some((_, id)), Some(m)) = (&execution, session_memory) {
Expand Down Expand Up @@ -545,13 +542,16 @@ pub(crate) async fn run_goal_turn(
rounds,
reason,
cost_usd,
kind,
} => {
tui::cost_summary(
cost_usd,
&format!("{}/{}", cfg.provider.id, cfg.model_id),
turn_start.elapsed(),
);
Err(format!("goal not met after {rounds} round(s): {reason}"))
// The typed kind survives the loop (#1862): a working turn's
// deliberate stop exits `3` and records `Stopped`.
Err(outcome::goal_unmet_failure(rounds, &reason, kind))
}
}
}
Expand Down Expand Up @@ -588,7 +588,7 @@ async fn run_goal_pipeline_turn(
// Same contract as `run_goal_turn`: stamp the execution id into the
// caller's memory before the turn runs, so reflection can name its row.
session_memory: Option<&mut crate::memory::SessionMemory>,
) -> Result<(), String> {
) -> Result<(), crate::failure::CliFailure> {
let turn_start = Instant::now();
let execution = begin_execution(store, "goal", goal, cfg, session);
// Rebound mutable and NOT consumed by the id stamp, so the same memory
Expand Down Expand Up @@ -717,7 +717,7 @@ async fn run_goal_pipeline_turn(
.with_calibration(calibration);

let mut total_cost_usd = 0.0f64;
let mut result: Option<Result<(), String>> = None;
let mut result: Option<Result<(), crate::failure::CliFailure>> = None;
let mut goal_met = false;

for round in 1..=goal_config.max_rounds {
Expand Down Expand Up @@ -773,25 +773,17 @@ async fn run_goal_pipeline_turn(
match pipeline.run(&round_goal, messages, budget).await {
Ok(outcome) => {
total_cost_usd += outcome.total_cost_usd;
match outcome.status {
PipelineStatus::Completed => {}
PipelineStatus::VerificationFailed { verdict } => {
result = Some(Err(format!(
"goal not met: verification failed: {}",
verdict.summary
)));
break;
}
PipelineStatus::Aborted { reason, .. } => {
result = Some(Err(format!(
"goal not met: working round aborted: {reason}"
)));
break;
}
// The fold keeps the abort's typed kind (#1862), so the
// terminal registry write can tell a policy stop from a
// crash. A completed round is no break at all — the loop
// goes on to its verifier assessment below.
if let Some(failure) = outcome::goal_round_break(&outcome.status) {
result = Some(Err(failure));
break;
}
}
Err(e) => {
result = Some(Err(e.to_string()));
result = Some(Err(crate::failure::CliFailure::error(e.to_string())));
break;
}
}
Expand All @@ -807,7 +799,9 @@ async fn run_goal_pipeline_turn(
{
Ok(pair) => pair,
Err(reason) => {
result = Some(Err(format!("goal not met: verifier unavailable: {reason}")));
result = Some(Err(crate::failure::CliFailure::error(format!(
"goal not met: verifier unavailable: {reason}"
))));
break;
}
};
Expand Down Expand Up @@ -853,10 +847,10 @@ async fn run_goal_pipeline_turn(
&format!("{}/{}", cfg.provider.id, cfg.model_id),
turn_start.elapsed(),
);
Err(format!(
Err(crate::failure::CliFailure::error(format!(
"goal not met after {} round(s): round cap reached without a passing verdict",
goal_config.max_rounds
))
)))
}
}
};
Expand Down
104 changes: 102 additions & 2 deletions crates/stella-cli/src/agent/outcome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ pub(super) fn pipeline_episode_outcome(status: &PipelineStatus) -> EpisodeOutcom
}
}

pub(super) fn pipeline_status_result(status: &PipelineStatus) -> Result<(), CliFailure> {
pub(crate) fn pipeline_status_result(status: &PipelineStatus) -> Result<(), CliFailure> {
match status {
PipelineStatus::Completed => Ok(()),
PipelineStatus::VerificationFailed { verdict } => Err(CliFailure::error(format!(
Expand Down Expand Up @@ -114,7 +114,7 @@ pub(super) fn pipeline_session_status(
/// could not tell a resumed policy stop from a resumed crash (#1637).
///
/// [`AbortKind`]: stella_core::AbortKind
pub(super) fn turn_outcome_result(outcome: &TurnOutcome) -> Result<(), CliFailure> {
pub(crate) fn turn_outcome_result(outcome: &TurnOutcome) -> Result<(), CliFailure> {
match outcome {
TurnOutcome::Completed { .. } => Ok(()),
TurnOutcome::Aborted { reason, kind, .. } => {
Expand All @@ -123,6 +123,45 @@ pub(super) fn turn_outcome_result(outcome: &TurnOutcome) -> Result<(), CliFailur
}
}

/// The terminal break a goal round's pipeline status folds to — `None` keeps
/// the loop running to its verifier assessment. The goal-loop analogue of
/// [`pipeline_status_result`], with the loop's own message prefixes: the fold
/// used to stringify the status into `Err(String)`, which dropped the abort's
/// typed [`AbortKind`] right there, so the goal loop's terminal registry
/// write could only record a policy-stopped round as an error (#1862).
///
/// [`AbortKind`]: stella_core::AbortKind
pub(crate) fn goal_round_break(status: &PipelineStatus) -> Option<CliFailure> {
match status {
PipelineStatus::Completed => None,
PipelineStatus::VerificationFailed { verdict } => Some(CliFailure::error(format!(
"goal not met: verification failed: {}",
verdict.summary
))),
PipelineStatus::Aborted { reason, kind } => Some(CliFailure::from_abort(
format!("goal not met: working round aborted: {reason}"),
*kind,
)),
}
}

/// The failure an unmet goal loop reports at the process boundary — the raw
/// (`--no-pipeline`) goal loop's half of #1862. The abort's typed kind
/// survives when a working turn aborted; the backstops that are not turn
/// aborts (round cap, unreachable verifier) carry no kind and stay plain
/// failures, exactly as the untyped chain always read them.
pub(crate) fn goal_unmet_failure(
rounds: usize,
reason: &str,
kind: Option<stella_core::AbortKind>,
) -> CliFailure {
let message = format!("goal not met after {rounds} round(s): {reason}");
match kind {
Some(kind) => CliFailure::from_abort(message, kind),
None => CliFailure::error(message),
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -216,6 +255,67 @@ mod tests {
);
}

/// #1862 witness, the goal loop's half of #1826's: the fold an aborted
/// working round takes on its way to the terminal registry write keeps
/// the abort's typed kind, so `run_goal_cmd`'s `presence.finish`
/// projects a policy-stopped goal round as `Stopped`. Before, the fold
/// stringified the status into `Err(String)` and the terminal write
/// could only reconstruct `CliFailure::error` — every stopped goal run
/// aged into the registry as a crash.
///
/// Like `an_unsupervised_deliberate_stop_projects_stopped_not_error`,
/// this relies on no test in this binary ever calling
/// `signals::note_interrupt`, so `interrupted_exit_code()` is reliably
/// `None` here.
#[test]
fn a_policy_stopped_goal_round_projects_stopped_not_error() {
let stopped = goal_round_break(&PipelineStatus::Aborted {
reason: "stuck-loop detected (persisted after a steering warning)".into(),
kind: AbortKind::DeliberateStop,
})
.expect("an aborted round ends the goal loop");
let crashed = goal_round_break(&PipelineStatus::Aborted {
reason: "the model call would not commit after retries".into(),
kind: AbortKind::Failure,
})
.expect("an aborted round ends the goal loop");

assert_eq!(
crate::daemon::outcome_status(Err(&stopped)),
stella_store::SessionStatus::Stopped,
"a goal round that ended itself by policy must not read as a crash"
);
assert_eq!(
crate::daemon::outcome_status(Err(&crashed)),
stella_store::SessionStatus::Error
);
// A completed round is not a break at all — the loop goes on to its
// verifier assessment.
assert!(goal_round_break(&PipelineStatus::Completed).is_none());
}

/// The raw (`--no-pipeline`) goal loop's half of the same witness: an
/// `Unmet` outcome whose working turn deliberately stopped projects
/// `Stopped`, while the kind-less backstops keep reading as failures.
#[test]
fn a_policy_stopped_raw_goal_loop_projects_stopped_not_error() {
let stopped = goal_unmet_failure(
2,
"working turn aborted: session budget limit reached",
Some(AbortKind::DeliberateStop),
);
let capped = goal_unmet_failure(8, "round cap (8) reached without a passing verdict", None);

assert_eq!(
crate::daemon::outcome_status(Err(&stopped)),
stella_store::SessionStatus::Stopped
);
assert_eq!(
crate::daemon::outcome_status(Err(&capped)),
stella_store::SessionStatus::Error
);
}

#[test]
fn a_deliberately_stopped_turn_carries_its_own_exit_code() {
let failure = turn_outcome_result(&TurnOutcome::Aborted {
Expand Down
39 changes: 16 additions & 23 deletions crates/stella-cli/src/command_deck.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ fn debug_log_path() -> Option<PathBuf> {
/// How one dispatched turn ended, as seen by the driver loop.
enum TurnEnd {
/// The turn future resolved (completed or aborted-with-reason).
Finished(Result<(), String>),
Finished(Result<(), crate::failure::CliFailure>),
/// The user stopped it mid-flight; the future was dropped. `hold` is the
/// double-Esc variant: the interrupted prompt goes back to the FRONT of
/// the backlog and dispatch parks until the user's next submission
Expand Down Expand Up @@ -1593,7 +1593,7 @@ pub async fn run_deck_session(
// The lead lane's pause seam — `p` on the lead row (#1219).
let lead_pause = lead_control::LeadPause::new();
let end = {
// Both arms return `Result<(), String>`, so one pinned future
// Both arms return `Result<(), CliFailure>`, so one pinned future
// drives either path through the same select loop.
let turn = async {
if pipeline_on {
Expand Down Expand Up @@ -2053,7 +2053,7 @@ pub async fn run_deck_session(
match end {
TurnEnd::Finished(outcome) => {
if let Err(reason) = &outcome {
if reason == stella_core::SOFT_STOP_REASON {
if reason.message() == stella_core::SOFT_STOP_REASON {
// A user choice, not a failure: no Error row — the
// work is kept and the next prompt continues from it.
let _ = in_tx.send(Inbound::Event {
Expand All @@ -2069,7 +2069,7 @@ pub async fn run_deck_session(
let _ = in_tx.send(Inbound::Event {
agent: LEAD.to_string(),
event: AgentEvent::Error {
message: reason.clone(),
message: reason.to_string(),
retryable: false,
},
});
Expand Down Expand Up @@ -2122,11 +2122,9 @@ pub async fn run_deck_session(
// ones someone comparing turns wants to see — so this is not
// conditioned on the outcome.
cfg.durability.mark_turn_end();
session_exit = if outcome.is_err() {
stella_store::SessionStatus::Error
} else {
stella_store::SessionStatus::Complete
};
// One decider for every terminal writer (#1653/#1826/#1862):
// a lead turn that ended in a deliberate stop exits `Stopped`.
session_exit = crate::daemon::outcome_status(outcome.as_ref().map(|_| ()));
session_record.status = stella_store::SessionStatus::NeedsInput;
let _ = session_registry.upsert(&session_record);
let turn_secs = crate::memory::unix_now_secs().saturating_sub(started_unix);
Expand Down Expand Up @@ -4230,7 +4228,7 @@ async fn run_lead_turn(
// Phase 2 (#713): this turn's `ContextRecall`, carried from the caller
// because recall runs before this channel exists.
recall_event: Option<AgentEvent>,
) -> Result<(), String> {
) -> Result<(), crate::failure::CliFailure> {
budget.begin_turn();

let (tx, rx) = mpsc::unbounded_channel::<AgentEvent>();
Expand Down Expand Up @@ -4350,10 +4348,9 @@ async fn run_lead_turn(
}
}

match outcome {
TurnOutcome::Completed { .. } => Ok(()),
TurnOutcome::Aborted { reason, .. } => Err(reason),
}
// The abort's typed kind rides through (#1862): the session-exit writer
// reads it off the same projection as every other terminal writer.
agent::outcome::turn_outcome_result(&outcome)
}

/// One staged-pipeline turn for the lead agent (`/pipeline` ON): the deck
Expand Down Expand Up @@ -4398,7 +4395,7 @@ async fn run_lead_pipeline_turn(
steering: &Arc<subsession::SteeringTap>,
pause: &lead_control::LeadPause,
mcp: Option<Arc<stella_mcp::McpToolSet>>,
) -> Result<(), String> {
) -> Result<(), crate::failure::CliFailure> {
budget.begin_turn();

let (tx, rx) = mpsc::unbounded_channel::<AgentEvent>();
Expand Down Expand Up @@ -4557,14 +4554,10 @@ async fn run_lead_pipeline_turn(
}

match result {
Ok(outcome) => match outcome.status {
PipelineStatus::Completed => Ok(()),
PipelineStatus::VerificationFailed { verdict } => {
Err(format!("verification failed: {}", verdict.summary))
}
PipelineStatus::Aborted { reason, .. } => Err(reason),
},
Err(e) => Err(e.to_string()),
// The shared projection keeps the abort's typed kind (#1862) and the
// exact messages the string arms carried before.
Ok(outcome) => agent::outcome::pipeline_status_result(&outcome.status),
Err(e) => Err(crate::failure::CliFailure::error(e.to_string())),
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/stella-cli/src/command_deck/authoring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ pub(super) fn forward_reflection_events(
pub(super) async fn record_and_reflect_turn(
memory: &mut Option<SessionMemory>,
prompt: &str,
outcome: &Result<(), String>,
outcome: &Result<(), crate::failure::CliFailure>,
registry: &ToolRegistry,
files_before: usize,
started_unix: i64,
Expand Down
Loading