mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-01 02:04:24 +00:00
Petri 639ce3e added `ControlService::interrupt_firing` and the `LiveTurns`
capability a host installs beside the pause hooks. Fabro now drives it:
`RunControls::interrupt(stage, text)` resolves its stage the way a steer
does (a label, a node name, or the run's one live agent stage) and stops
that stage's current model turn, keeping the session; the text, when
given, is the stage's next input. `engine::run` installs the live-turn set
as a runtime capability, so without it no interrupt could ever land.
The worker maps `run.interrupt` and `run.interrupt_then_steer`, both of
which now carry an optional `stage`, to that call. The control bus is
one-way, so a refusal is recorded the way a refused steer is: a
`run.notice` on the run's stream whose code says why (`no_live_turn` when
Petri refuses a stage with no turn in flight, `no_such_stage`,
`interrupt_refused`).
The server's `POST /runs/{id}/interrupt` and `POST /runs/{id}/steer` with
`interrupt=true` forward the control and answer 202, replacing the 501
`interrupt_unsupported` stub. The interrupt endpoint takes an optional
body (`stage`, `text`), refuses a finished run with 409
`run_not_interruptible`, and forwards an interrupt of a blocked run, since
an agent stage may be running a turn beside the question and the worker
judges each stage itself. `fabro events --pretty` prints the delivered
interrupt and the stage's `attractor.turn.interrupted` report.
Verified on the twin: `fabro steer --interrupt` during a long tool call
ends the turn, the text is the agent's next request, and the stream
carries the `$interrupt` record and the interrupted-turn report; an
interrupt of a gate stage is refused with `no_live_turn` and the gate's
question is untouched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
256 lines
9.1 KiB
Rust
256 lines
9.1 KiB
Rust
use std::sync::Arc;
|
|
|
|
use axum::Json;
|
|
use axum::extract::State;
|
|
use axum::http::StatusCode;
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::routing::post;
|
|
use fabro_api::types::{InterruptRunRequest, SteerRunRequest};
|
|
use fabro_types::Principal;
|
|
use fabro_workflow::run_status::RunStatus;
|
|
|
|
use super::super::{AnswerTransportError, AppState, durable_run_status, reject_if_archived};
|
|
use crate::error::ApiError;
|
|
use crate::principal_middleware::RequireRunManagementTarget;
|
|
|
|
pub(super) fn routes() -> axum::Router<Arc<AppState>> {
|
|
axum::Router::new()
|
|
.route("/runs/{id}/steer", post(steer_run))
|
|
.route("/runs/{id}/interrupt", post(interrupt_run))
|
|
}
|
|
|
|
/// A control forwarded to the run's worker. The worker resolves the stage
|
|
/// and delivers the control to Petri; what it cannot deliver it refuses on
|
|
/// the run's stream as a `run.notice` whose code says why.
|
|
enum RunControlRequest {
|
|
/// Guidance for a live agent stage's session, run as a follow-up turn.
|
|
Steer {
|
|
text: String,
|
|
stage: Option<String>,
|
|
},
|
|
/// Stop a live agent stage's current model turn and keep its session;
|
|
/// `text`, when given, is the stage's next input.
|
|
Interrupt {
|
|
stage: Option<String>,
|
|
text: Option<String>,
|
|
},
|
|
}
|
|
|
|
impl RunControlRequest {
|
|
fn name(&self) -> &'static str {
|
|
match self {
|
|
Self::Steer { .. } => "steer",
|
|
Self::Interrupt { .. } => "interrupt",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A stage name, when given, must not be blank.
|
|
fn blank_stage(stage: Option<&str>) -> bool {
|
|
stage.is_some_and(|stage| stage.trim().is_empty())
|
|
}
|
|
|
|
async fn steer_run(
|
|
RequireRunManagementTarget(id, actor): RequireRunManagementTarget,
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<SteerRunRequest>,
|
|
) -> Response {
|
|
// OpenAPI enforces minLength=1/maxLength=8192 already; only whitespace-only
|
|
// payloads can slip through.
|
|
let SteerRunRequest {
|
|
text,
|
|
interrupt,
|
|
stage,
|
|
} = req;
|
|
let text: String = text.into();
|
|
if text.trim().is_empty() {
|
|
return ApiError::bad_request("Steer text must not be empty.").into_response();
|
|
}
|
|
let stage = stage.map(String::from);
|
|
if blank_stage(stage.as_deref()) {
|
|
return ApiError::bad_request("Steer stage must not be empty.").into_response();
|
|
}
|
|
let control = if interrupt {
|
|
RunControlRequest::Interrupt {
|
|
stage,
|
|
text: Some(text),
|
|
}
|
|
} else {
|
|
RunControlRequest::Steer { text, stage }
|
|
};
|
|
control_run(actor, state, id, control).await
|
|
}
|
|
|
|
/// Stop a live agent stage's current model turn. The body is optional: no
|
|
/// body interrupts the run's one live agent stage and leaves it waiting
|
|
/// for the next steer.
|
|
async fn interrupt_run(
|
|
RequireRunManagementTarget(id, actor): RequireRunManagementTarget,
|
|
State(state): State<Arc<AppState>>,
|
|
body: Option<Json<InterruptRunRequest>>,
|
|
) -> Response {
|
|
let InterruptRunRequest { stage, text } = body.map(|Json(body)| body).unwrap_or_default();
|
|
let stage = stage.map(String::from);
|
|
if blank_stage(stage.as_deref()) {
|
|
return ApiError::bad_request("Interrupt stage must not be empty.").into_response();
|
|
}
|
|
let text = text.map(String::from);
|
|
if text.as_deref().is_some_and(|text| text.trim().is_empty()) {
|
|
return ApiError::bad_request("Interrupt text must not be empty.").into_response();
|
|
}
|
|
control_run(actor, state, id, RunControlRequest::Interrupt {
|
|
stage,
|
|
text,
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn control_run(
|
|
actor: Principal,
|
|
state: Arc<AppState>,
|
|
id: fabro_types::RunId,
|
|
control: RunControlRequest,
|
|
) -> Response {
|
|
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
|
return response;
|
|
}
|
|
|
|
// Status + steerability gate. Take the answer_transport snapshot under
|
|
// the same lock so we can hand it off without further state races.
|
|
let managed_answer_transport = {
|
|
let runs = state.runs.lock().expect("runs lock poisoned");
|
|
match runs.get(&id) {
|
|
Some(managed_run) => {
|
|
match (&control, &managed_run.status) {
|
|
// A blocked run may still have an agent stage running a
|
|
// turn beside the question; the worker judges the
|
|
// interrupt per stage and refuses the gate itself.
|
|
(RunControlRequest::Interrupt { .. }, RunStatus::Blocked { .. })
|
|
| (_, RunStatus::Running) => {}
|
|
(RunControlRequest::Steer { .. }, RunStatus::Blocked { .. }) => {
|
|
return ApiError::with_code(
|
|
StatusCode::CONFLICT,
|
|
"Run is blocked on a question; use the interview-answer endpoint \
|
|
instead.",
|
|
"use_answer_endpoint",
|
|
)
|
|
.into_response();
|
|
}
|
|
(
|
|
_,
|
|
RunStatus::Submitted
|
|
| RunStatus::Pending { .. }
|
|
| RunStatus::Runnable
|
|
| RunStatus::Starting
|
|
| RunStatus::Paused { .. },
|
|
) => {
|
|
return ApiError::with_code(
|
|
StatusCode::CONFLICT,
|
|
"Run is not currently running.",
|
|
not_controllable_code(&control),
|
|
)
|
|
.into_response();
|
|
}
|
|
(
|
|
_,
|
|
RunStatus::Failed { .. }
|
|
| RunStatus::Succeeded { .. }
|
|
| RunStatus::Removing
|
|
| RunStatus::Dead,
|
|
) => {
|
|
return terminal_control_response(&control);
|
|
}
|
|
}
|
|
// Plain steers buffer in the worker hub when no agent session
|
|
// is active; if active agents exist but none are steerable,
|
|
// there is no live control channel to target.
|
|
if managed_run.active_steerable_stages.is_empty()
|
|
&& !managed_run.active_non_steerable_stages.is_empty()
|
|
{
|
|
return ApiError::with_code(
|
|
StatusCode::CONFLICT,
|
|
"All currently running agent stages use a non-steerable backend.",
|
|
"agent_not_steerable",
|
|
)
|
|
.into_response();
|
|
}
|
|
Some(managed_run.answer_transport.clone())
|
|
}
|
|
None => None,
|
|
}
|
|
};
|
|
|
|
let Some(answer_transport) = managed_answer_transport else {
|
|
return unmanaged_control_response(state.as_ref(), id, &control).await;
|
|
};
|
|
let Some(answer_transport) = answer_transport else {
|
|
return ApiError::with_code(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Run has no live worker control channel.",
|
|
"worker_control_unavailable",
|
|
)
|
|
.into_response();
|
|
};
|
|
|
|
let result = match control {
|
|
RunControlRequest::Steer { text, stage } => {
|
|
answer_transport.steer(text, stage, actor).await
|
|
}
|
|
RunControlRequest::Interrupt { stage, text } => {
|
|
answer_transport.interrupt(stage, text, actor).await
|
|
}
|
|
};
|
|
|
|
match result {
|
|
Ok(()) => StatusCode::ACCEPTED.into_response(),
|
|
Err(AnswerTransportError::Timeout) => ApiError::with_code(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Worker control channel timed out.",
|
|
"worker_control_unavailable",
|
|
)
|
|
.into_response(),
|
|
Err(AnswerTransportError::Closed) => ApiError::with_code(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Worker control channel is closed.",
|
|
"worker_control_unavailable",
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// The 409 code of a control the run's status refuses.
|
|
fn not_controllable_code(control: &RunControlRequest) -> &'static str {
|
|
match control {
|
|
RunControlRequest::Steer { .. } => "run_not_steerable",
|
|
RunControlRequest::Interrupt { .. } => "run_not_interruptible",
|
|
}
|
|
}
|
|
|
|
fn terminal_control_response(control: &RunControlRequest) -> Response {
|
|
ApiError::with_code(
|
|
StatusCode::CONFLICT,
|
|
format!("Run no longer accepts a {}.", control.name()),
|
|
not_controllable_code(control),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn unmanaged_control_response(
|
|
state: &AppState,
|
|
id: fabro_types::RunId,
|
|
control: &RunControlRequest,
|
|
) -> Response {
|
|
match durable_run_status(state, id).await {
|
|
Ok(Some(status)) if status.is_terminal() => terminal_control_response(control),
|
|
Ok(Some(_)) => ApiError::with_code(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Run has no live worker control channel.",
|
|
"worker_control_unavailable",
|
|
)
|
|
.into_response(),
|
|
Ok(None) => ApiError::not_found("Run not found.").into_response(),
|
|
Err(err) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
}
|
|
}
|