Merge branch 'petri-followup-interrupt' into petri-integration

This commit is contained in:
Bryan Helmkamp 2026-09-18 22:45:31 -04:00
commit a664b0ccce
No known key found for this signature in database
2 changed files with 32 additions and 9 deletions

View file

@ -367,9 +367,11 @@ impl PetriControls {
}
/// Stop the named stage's model turn, `text` as its next input when
/// given. A refusal is a `run.notice` whose code says why: `no_live_turn`
/// given. A refusal is a `run.notice` whose code says why (`no_live_turn`
/// when the stage has no model turn in flight, `no_such_stage` when the
/// name is not running, `interrupt_refused` otherwise.
/// name is not running, `interrupt_refused` otherwise) and whose message
/// names the stage and the reason as Petri spells it, for the web and
/// the CLI to show.
async fn interrupt(&self, stage: Option<&str>, text: Option<&str>, actor: &Principal) {
match self.controls.interrupt(stage, text).await {
Ok(stage) => {
@ -382,9 +384,12 @@ impl PetriControls {
);
}
Err(error) => {
warn!(run_id = %self.run_id, error = %error, "interrupt refused");
self.notice(interrupt_refusal_code(&error), error.to_string())
.await;
warn!(run_id = %self.run_id, stage, error = %error, "interrupt refused");
self.notice(
interrupt_refusal_code(&error),
interrupt_refusal_message(stage, &error),
)
.await;
}
}
}
@ -416,6 +421,16 @@ fn interrupt_refusal_code(error: &SteerError) -> &'static str {
}
}
/// The notice message of a refused interrupt: the stage it named, and the
/// reason as Petri's `ControlError` (or the resolution's own refusal)
/// spells it.
fn interrupt_refusal_message(stage: Option<&str>, error: &SteerError) -> String {
match stage {
Some(stage) => format!("Interrupt of stage `{stage}` refused: {error}"),
None => format!("Interrupt refused: {error}"),
}
}
/// The wire name of a control, for a log line.
fn control_name(message: &WorkerControlMessage) -> &'static str {
match message {

View file

@ -794,8 +794,8 @@ async fn an_interrupt_ends_the_turn_and_its_text_is_the_next_input() {
/// blocked on, named by its node, is refused by Petri with `no_live_turn`;
/// unnamed, with no agent stage live, the worker refuses it with
/// `interrupt_refused`. Both refusals are `run.notice` records on the
/// stream, nothing is delivered, and the gate's question is untouched: its
/// answer routes the run to its end.
/// stream naming the stage and Petri's reason, nothing is delivered, and
/// the gate's question is untouched: its answer routes the run to its end.
#[tokio::test(flavor = "multi_thread")]
async fn an_interrupt_of_a_gate_stage_is_refused_with_no_live_turn() {
if host_plugin().is_none() {
@ -836,10 +836,18 @@ async fn an_interrupt_of_a_gate_stage_is_refused_with_no_live_turn() {
assert_eq!(count_of(&names, "control.requested"), 0, "{names:?}");
let refused = notices(&run_stream(&server, &run_id).await);
assert_eq!(refused.len(), 2, "{refused:?}");
// The notice names the stage and carries Petri's reason as it spells
// it, so the web and the CLI can show both.
assert_eq!(refused[0].0, "no_live_turn", "{refused:?}");
assert_eq!(refused[0].1, "the stage has no model turn to interrupt");
assert_eq!(
refused[0].1,
"Interrupt of stage `gate` refused: the stage has no model turn to interrupt"
);
assert_eq!(refused[1].0, "interrupt_refused", "{refused:?}");
assert_eq!(refused[1].1, "Run has no active steerable agent session.");
assert_eq!(
refused[1].1,
"Interrupt refused: Run has no active steerable agent session."
);
assert_eq!(run_status(&server, &run_id).await, "blocked");
answer(&server, &run_id, &question_id, json!({ "kind": "yes" })).await;