Replace stdin JSONL control pipe with WebSocket worker control bus (#440)

## Summary

Workers no longer receive control messages over stdin JSONL. A new
`WorkerControlBus` abstraction (backed by `LocalWorkerControlBus` for
local/single-node deployments) publishes `WorkerControlEnvelope`
messages server-side; a worker-initiated WebSocket at `GET
/runs/{id}/worker/control-stream` delivers them with ordered, replayable
delivery frames. The bus API is designed so a Redis Streams backend can
slot in later without touching API handlers or worker message handling.

### Plan Summary

- **Task 1 – Bus contract:** `WorkerControlBus` trait,
`WorkerControlDelivery`, `WorkerControlCursor` (`Start` / `After(id)`),
bus errors.
- **Task 2 – Local backend:** `LocalWorkerControlBus` — in-memory
per-run stream, replay from `Start`, reconnect via `After(id)`, 1
024-message trim bound, cleanup on terminal runs.
- **Task 3 – Server state:** `Arc<dyn WorkerControlBus>` added to
`AppState`; `LocalWorkerControlBus` constructed at startup.
- **Task 4 – Protocol extension:** `WorkerControlMessage::RunPause` /
`RunUnpause`, `WorkerControlDeliveryFrame`, WebSocket liveness constants
(`WORKER_CONTROL_WS_PING_INTERVAL = 15s`,
`WORKER_CONTROL_WS_LIVENESS_TIMEOUT = 45s`), close-reason strings.
- **Task 5 – Worker message handler:** `apply_worker_control_message`
split out; pause/unpause routing; delivery-id dedupe
(`AppliedWorkerControlDeliveryIds`, capacity 2 048).
- **Task 6 – Worker WebSocket client:** `spawn_worker_control_manager` —
HTTP→ws/wss and Unix-socket connection, backoff 100ms→5s,
first-connection gate before `operations::start/resume`, ping/pong
watchdog, fatal loss wired back to `execute`.
- **Task 7 – Server route:** `GET /runs/{id}/worker/control-stream`,
worker-only auth via new `RequireWorkerRunScoped` extractor,
`Start`/`After` cursor dispatch, 410 on invalid cursor, server-side
ping/pong.
- **Task 8 – Stdin removal:** `RunAnswerTransport::Subprocess` renamed
to `Worker { run_id, bus }`; `pump_worker_control_jsonl` deleted; worker
launched with `stdin(Stdio::null())`; pause/unpause transport methods
added.
- **Tasks 9–10 – E2E & verification:** reconnect, invalid-cursor,
cancel-over-WebSocket, and human-interview regression tests; no Redis
dependency added.

### Key design decisions

**`RunAnswerTransport::Subprocess` → `Worker { run_id, bus }`** — all
existing transport methods (`submit`, `cancel_run`, `steer`,
`interrupt`, `pair_*`) now call `bus.publish(run_id, envelope)` instead
of writing to a channel that fed stdin. The match arms are symmetric, so
the diff is mechanical but large.

**First-connection gate** — `execute()` calls
`control_manager.wait_for_first_connection().await?` before
`operations::start` or `operations::resume`. Temporary failures spin
with backoff; a fatal invalid-cursor or request-build failure propagates
as an error before the workflow starts.

**Fatal vs. reconnectable** — HTTP 410 or a WebSocket close with reason
`"invalid_cursor"` is fatal (infrastructure failure, not user
cancellation). Any other close/error triggers the reconnect loop while
the run is non-terminal.

**`AutomationStore::load` made synchronous** — startup load now uses
`std::fs` under a `clippy::disallowed_methods` exception; async
`tokio::fs` is no longer needed for the one-shot directory scan. Invalid
automation files now fail loudly instead of being silently skipped.

**`canRetry` extended to succeeded runs** — `status.kind ===
"succeeded"` is now retryable (non-archived). Tests and API docs updated
to match.

**Default model bumps** — OpenAI default: `gpt-5.4` → `gpt-5.5`; Gemini
default: `gemini-3.1-pro-preview` → `gemini-3.5-flash`.


### Fabro Details

<details>
<summary>Ran 9 stages in 129m 19s for $58.27</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 10s | – | 0 |
| preflight_lint | 2m 23s | – | 0 |
| implement | 73m 48s | $41.53 | 0 |
| simplify_opus | 22m 55s | $11.75 | 0 |
| simplify_gpt | 7m 19s | $2.74 | 0 |
| verify | 8m 51s | – | 0 |
| fixup | 10m 59s | $2.24 | 0 |
| **Total** | **129m 19s** | **$58.27** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> exit  [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
This commit is contained in:
fabro-sh-0530[bot] 2026-05-27 20:24:25 -04:00 committed by GitHub
parent ee1502f793
commit 475b4ab650
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2331 additions and 345 deletions

2
Cargo.lock generated
View file

@ -1889,6 +1889,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite 0.26.2",
"tokio-util",
"toml 0.8.23",
"toml_edit",
@ -2408,6 +2409,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tokio-tungstenite 0.26.2",
"tokio-util",
"toml 0.8.23",
"toml_edit",

View file

@ -61,6 +61,7 @@ anyhow.workspace = true
miette.workspace = true
dotenvy.workspace = true
tokio.workspace = true
tokio-tungstenite.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
tracing-appender.workspace = true
@ -128,3 +129,4 @@ fabro-test = { workspace = true }
fabro-macros = { path = "../fabro-macros" }
hkdf.workspace = true
reqwest = { workspace = true, features = ["cookies"] }
tokio = { workspace = true, features = ["test-util", "macros"] }

File diff suppressed because it is too large Load diff

View file

@ -794,6 +794,66 @@ fn detached_run_answers_pending_question_without_interview_scratch_files() {
)));
}
#[test]
fn detached_run_cancel_reaches_worker_over_control_websocket() {
let context = auth_context();
let run_id = unique_run_id();
let workflow_path = context.temp_dir.join("cancel-over-control-websocket.fabro");
let _gate = write_gated_workflow(
&workflow_path,
"cancel_over_control_websocket",
"Wait for cancellation",
);
let output = context
.command()
.args([
"run",
"--detach",
"--run-id",
run_id.as_str(),
"--environment",
"local",
workflow_path.to_str().unwrap(),
])
.timeout(SHARED_DAEMON_TIMEOUT)
.output()
.expect("detached run should execute");
assert!(
output.status.success(),
"detached run failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let run_dir = context.find_run_dir(&run_id);
wait_for_event_names(&run_dir, &["run.running"]);
tokio::runtime::Runtime::new()
.expect("test runtime should build")
.block_on(async {
let (client, base_url) =
server_endpoint(&context.storage_dir).expect("server endpoint should exist");
let response = client
.post(format!("{base_url}/api/v1/runs/{run_id}/cancel"))
.send()
.await
.expect("cancel request should succeed");
assert_reqwest_status(
response,
fabro_http::StatusCode::OK,
format!("POST /api/v1/runs/{run_id}/cancel"),
)
.await;
});
wait_for_status(&run_dir, &["failed"]);
let events = stored_worker_events(&run_dir);
assert!(events.iter().any(|event| matches!(
&event.body,
EventBody::RunFailed(props) if props.failure.reason == FailureReason::Cancelled
)));
}
#[cfg(unix)]
#[test]
fn worker_exits_after_sigterm_cancel_even_when_stdin_stays_open() {

View file

@ -1,3 +1,5 @@
use std::time::Duration;
use fabro_types::{PairId, PairMessageId, PairTarget, Principal, RunId};
use serde::{Deserialize, Serialize};
@ -5,6 +7,25 @@ use crate::{Answer, AnswerSubmission, AnswerValue};
pub const WORKER_CONTROL_PROTOCOL_VERSION: u8 = 1;
/// Interval between worker-control WebSocket ping frames.
///
/// Server and worker both initiate pings at this cadence; either side that
/// fails to observe inbound traffic for [`WORKER_CONTROL_WS_LIVENESS_TIMEOUT`]
/// closes the WebSocket.
pub const WORKER_CONTROL_WS_PING_INTERVAL: Duration = Duration::from_secs(15);
/// Maximum quiet time allowed on a worker-control WebSocket before either side
/// declares the connection dead.
pub const WORKER_CONTROL_WS_LIVENESS_TIMEOUT: Duration = Duration::from_secs(45);
/// WebSocket close-frame reason used when the server can no longer prove
/// replay correctness for the requested cursor. Workers must treat this as
/// fatal control-channel loss.
pub const WORKER_CONTROL_INVALID_CURSOR_REASON: &str = "invalid_cursor";
/// WebSocket close-frame reason used when the ping/pong watchdog fires.
pub const WORKER_CONTROL_PONG_TIMEOUT_REASON: &str = "pong_timeout";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerControlEnvelope {
pub v: u8,
@ -33,6 +54,22 @@ impl WorkerControlEnvelope {
}
}
#[must_use]
pub fn pause_run() -> Self {
Self {
v: WORKER_CONTROL_PROTOCOL_VERSION,
message: WorkerControlMessage::RunPause,
}
}
#[must_use]
pub fn unpause_run() -> Self {
Self {
v: WORKER_CONTROL_PROTOCOL_VERSION,
message: WorkerControlMessage::RunUnpause,
}
}
#[must_use]
pub fn steer(text: impl Into<String>, actor: Principal) -> Self {
Self {
@ -121,6 +158,10 @@ pub enum WorkerControlMessage {
},
#[serde(rename = "run.cancel")]
RunCancel,
#[serde(rename = "run.pause")]
RunPause,
#[serde(rename = "run.unpause")]
RunUnpause,
#[serde(rename = "run.steer")]
Steer { text: String, actor: Principal },
#[serde(rename = "run.interrupt")]
@ -147,6 +188,12 @@ pub enum WorkerControlMessage {
PairEnd { pair_id: PairId, actor: Principal },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerControlDeliveryFrame {
pub id: String,
pub envelope: WorkerControlEnvelope,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum WorkerControlAnswer {
@ -229,6 +276,26 @@ mod tests {
assert_eq!(parsed, envelope);
}
#[test]
fn pause_run_round_trips_through_json() {
let envelope = WorkerControlEnvelope::pause_run();
let json = serde_json::to_string(&envelope).unwrap();
assert_eq!(json, r#"{"v":1,"type":"run.pause"}"#);
let parsed: WorkerControlEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, envelope);
}
#[test]
fn unpause_run_round_trips_through_json() {
let envelope = WorkerControlEnvelope::unpause_run();
let json = serde_json::to_string(&envelope).unwrap();
assert_eq!(json, r#"{"v":1,"type":"run.unpause"}"#);
let parsed: WorkerControlEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, envelope);
}
#[test]
fn steer_append_round_trips_through_json() {
let envelope = WorkerControlEnvelope::steer("try again", Principal::System {
@ -326,4 +393,20 @@ mod tests {
serde_json::from_str(&serde_json::to_string(&end).unwrap()).unwrap();
assert_eq!(parsed, end);
}
#[test]
fn delivery_frame_round_trips_through_json() {
let frame = WorkerControlDeliveryFrame {
id: "local:42".to_string(),
envelope: WorkerControlEnvelope::cancel_run(),
};
let json = serde_json::to_string(&frame).unwrap();
assert_eq!(
json,
r#"{"id":"local:42","envelope":{"v":1,"type":"run.cancel"}}"#
);
let parsed: WorkerControlDeliveryFrame = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, frame);
}
}

View file

@ -222,8 +222,10 @@ pub use callback::CallbackInterviewer;
pub use console::ConsoleInterviewer;
pub use control::{ControlInterviewer, SubmitError};
pub use control_protocol::{
WORKER_CONTROL_PROTOCOL_VERSION, WorkerControlAnswer, WorkerControlEnvelope,
WorkerControlMessage,
WORKER_CONTROL_INVALID_CURSOR_REASON, WORKER_CONTROL_PONG_TIMEOUT_REASON,
WORKER_CONTROL_PROTOCOL_VERSION, WORKER_CONTROL_WS_LIVENESS_TIMEOUT,
WORKER_CONTROL_WS_PING_INTERVAL, WorkerControlAnswer, WorkerControlDeliveryFrame,
WorkerControlEnvelope, WorkerControlMessage,
};
pub use queue::QueueInterviewer;
pub use recording::RecordingInterviewer;

View file

@ -112,6 +112,7 @@ httpmock = "0.8"
serde_yaml = "0.9"
tracing-subscriber.workspace = true
tokio-util.workspace = true
tokio-tungstenite.workspace = true
fabro-macros = { path = "../fabro-macros" }
fabro-sandbox = { path = "../fabro-sandbox", features = ["test-support"] }
fabro-test = { workspace = true }

View file

@ -49,6 +49,7 @@ pub mod static_files;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
pub mod web_auth;
mod worker_control;
mod worker_token;
pub use error::{ApiError, Error, Result};

View file

@ -58,6 +58,7 @@ pub(crate) struct RequestAuth(pub(crate) AuthContextSlot);
pub(crate) struct RequiredUser(pub(crate) UserPrincipal);
pub(crate) struct RequiredRunManagementActor(pub(crate) Principal);
pub(crate) struct RequireRunScoped(pub(crate) RunId);
pub(crate) struct RequireWorkerRunScoped(pub(crate) RunId);
pub(crate) struct RequireRunManagementTarget(pub(crate) RunId, pub(crate) Principal);
pub(crate) struct RequireRunBlob(pub(crate) RunId, pub(crate) RunBlobId);
pub(crate) struct RequireRunStageScoped(pub(crate) RunId, pub(crate) String);
@ -245,6 +246,23 @@ impl FromRequestParts<Arc<AppState>> for RequireRunScoped {
}
}
impl FromRequestParts<Arc<AppState>> for RequireWorkerRunScoped {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
let Path(id): Path<String> = Path::from_request_parts(parts, state)
.await
.map_err(IntoResponse::into_response)?;
let run_id = parse_run_id_path(&id)?;
require_worker_for_run(&auth_slot_from_parts(parts), &run_id)
.map_err(IntoResponse::into_response)?;
Ok(Self(run_id))
}
}
impl FromRequestParts<Arc<AppState>> for RequireRunManagementTarget {
type Rejection = Response;
@ -418,6 +436,15 @@ fn require_worker_or_user_for_run(
}
}
fn require_worker_for_run(slot: &AuthContextSlot, route_run_id: &RunId) -> Result<(), ApiError> {
let context = slot.0.lock().expect("auth context lock poisoned");
match &context.principal {
Principal::Worker { run_id } if run_id == route_run_id => Ok(()),
Principal::Worker { .. } | Principal::User(_) => Err(ApiError::forbidden()),
_ => Err(auth_rejection(context.auth_status, context.auth_error_code)),
}
}
fn require_run_management_target(
slot: &AuthContextSlot,
route_run_id: &RunId,

View file

@ -814,6 +814,8 @@ where
http_client: None,
sandbox_provider_registry: None,
shutdown: shutdown.clone(),
#[cfg(test)]
worker_control_bus: None,
#[cfg(any(test, feature = "test-support"))]
automation_materializer_override: None,
})?;

View file

@ -124,7 +124,7 @@ use sha2::{Digest, Sha256};
use tempfile::NamedTempFile;
use tokio::fs;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{ChildStderr, ChildStdin, Command};
use tokio::process::{ChildStderr, Command};
use tokio::sync::broadcast::error::RecvError;
use tokio::sync::{
Mutex as AsyncMutex, Notify, OwnedMutexGuard, RwLock as AsyncRwLock, Semaphore, broadcast,
@ -153,14 +153,15 @@ use crate::ip_allowlist::{IpAllowlistConfig, ip_allowlist_middleware};
use crate::jwt_auth::{self, AuthMode};
use crate::principal_middleware::{
AuthContextSlot, RequestAuth, RequestAuthContext, RequireRunBlob, RequireRunManagementTarget,
RequireRunScoped, RequireRunStageScoped, RequireStageArtifact, RequiredUser,
principal_middleware,
RequireRunScoped, RequireRunStageScoped, RequireStageArtifact, RequireWorkerRunScoped,
RequiredUser, principal_middleware,
};
use crate::request_id::{self, RequestId};
use crate::run_files::{FilesInFlight, new_files_in_flight};
use crate::server_secrets::{LlmClientResult, ServerSecrets};
use crate::spawn_env::{apply_render_graph_env, apply_worker_env};
use crate::startup::load_startup_vault;
use crate::worker_control::{LocalWorkerControlBus, WorkerControlBus, WorkerControlBusError};
use crate::worker_token::{WorkerScopeSet, WorkerTokenKeys, issue_worker_token_with_scopes};
use crate::{
canonical_host, demo, diagnostics, run_manifest, security_headers, static_files, web_auth,
@ -272,7 +273,6 @@ enum ExecutionResult {
const WORKER_CANCEL_GRACE: Duration = Duration::from_secs(5);
const TERMINAL_DELETE_WORKER_GRACE: Duration = Duration::from_millis(50);
const WORKER_CONTROL_QUEUE_CAPACITY: usize = 8;
const WORKER_CONTROL_ENQUEUE_TIMEOUT: Duration = Duration::from_secs(1);
/// Per-model billing totals.
#[derive(Default)]
@ -294,8 +294,9 @@ pub(crate) type RegistryFactoryOverride =
#[derive(Clone)]
enum RunAnswerTransport {
Subprocess {
control_tx: mpsc::Sender<WorkerControlEnvelope>,
Worker {
run_id: RunId,
bus: Arc<dyn WorkerControlBus>,
},
InProcess {
interviewer: Arc<ControlInterviewer>,
@ -317,18 +318,46 @@ enum PairTransportError {
}
impl RunAnswerTransport {
async fn publish_worker_control(
run_id: RunId,
bus: &Arc<dyn WorkerControlBus>,
message: WorkerControlEnvelope,
) -> Result<(), WorkerControlBusError> {
timeout(WORKER_CONTROL_ENQUEUE_TIMEOUT, bus.publish(run_id, message))
.await
.map_err(|_| WorkerControlBusError::PublishTimeout)?
.map(|_| ())
}
fn answer_error_from_bus(error: &WorkerControlBusError) -> AnswerTransportError {
match error {
WorkerControlBusError::PublishTimeout => AnswerTransportError::Timeout,
WorkerControlBusError::Closed
| WorkerControlBusError::Unavailable
| WorkerControlBusError::InvalidCursor { .. } => AnswerTransportError::Closed,
}
}
fn pair_error_from_bus(error: &WorkerControlBusError) -> PairTransportError {
match error {
WorkerControlBusError::PublishTimeout => PairTransportError::Timeout,
WorkerControlBusError::Closed
| WorkerControlBusError::Unavailable
| WorkerControlBusError::InvalidCursor { .. } => PairTransportError::Closed,
}
}
async fn submit(
&self,
qid: &str,
submission: AnswerSubmission,
) -> Result<(), AnswerTransportError> {
match self {
Self::Subprocess { control_tx } => {
Self::Worker { run_id, bus } => {
let message = WorkerControlEnvelope::interview_answer(qid.to_string(), submission);
timeout(WORKER_CONTROL_ENQUEUE_TIMEOUT, control_tx.send(message))
Self::publish_worker_control(*run_id, bus, message)
.await
.map_err(|_| AnswerTransportError::Timeout)?
.map_err(|_| AnswerTransportError::Closed)
.map_err(|err| Self::answer_error_from_bus(&err))
}
Self::InProcess { interviewer, .. } => interviewer
.submit(qid, submission)
@ -339,12 +368,11 @@ impl RunAnswerTransport {
async fn cancel_run(&self) -> Result<(), AnswerTransportError> {
match self {
Self::Subprocess { control_tx } => {
Self::Worker { run_id, bus } => {
let message = WorkerControlEnvelope::cancel_run();
timeout(WORKER_CONTROL_ENQUEUE_TIMEOUT, control_tx.send(message))
Self::publish_worker_control(*run_id, bus, message)
.await
.map_err(|_| AnswerTransportError::Timeout)?
.map_err(|_| AnswerTransportError::Closed)
.map_err(|err| Self::answer_error_from_bus(&err))
}
Self::InProcess { interviewer, .. } => {
interviewer.cancel_all().await;
@ -357,12 +385,11 @@ impl RunAnswerTransport {
/// in-process steering hub.
async fn steer(&self, text: String, actor: Principal) -> Result<(), AnswerTransportError> {
match self {
Self::Subprocess { control_tx } => {
Self::Worker { run_id, bus } => {
let message = WorkerControlEnvelope::steer(text, actor);
timeout(WORKER_CONTROL_ENQUEUE_TIMEOUT, control_tx.send(message))
Self::publish_worker_control(*run_id, bus, message)
.await
.map_err(|_| AnswerTransportError::Timeout)?
.map_err(|_| AnswerTransportError::Closed)
.map_err(|err| Self::answer_error_from_bus(&err))
}
Self::InProcess { steering_hub, .. } => {
steering_hub.deliver_steer(text, Some(actor));
@ -373,12 +400,11 @@ impl RunAnswerTransport {
async fn interrupt(&self, actor: Principal) -> Result<(), AnswerTransportError> {
match self {
Self::Subprocess { control_tx } => {
Self::Worker { run_id, bus } => {
let message = WorkerControlEnvelope::interrupt(actor);
timeout(WORKER_CONTROL_ENQUEUE_TIMEOUT, control_tx.send(message))
Self::publish_worker_control(*run_id, bus, message)
.await
.map_err(|_| AnswerTransportError::Timeout)?
.map_err(|_| AnswerTransportError::Closed)
.map_err(|err| Self::answer_error_from_bus(&err))
}
Self::InProcess { steering_hub, .. } => {
steering_hub.interrupt(Some(&actor));
@ -393,12 +419,11 @@ impl RunAnswerTransport {
actor: Principal,
) -> Result<(), AnswerTransportError> {
match self {
Self::Subprocess { control_tx } => {
Self::Worker { run_id, bus } => {
let message = WorkerControlEnvelope::interrupt_then_steer(text, actor);
timeout(WORKER_CONTROL_ENQUEUE_TIMEOUT, control_tx.send(message))
Self::publish_worker_control(*run_id, bus, message)
.await
.map_err(|_| AnswerTransportError::Timeout)?
.map_err(|_| AnswerTransportError::Closed)
.map_err(|err| Self::answer_error_from_bus(&err))
}
Self::InProcess { steering_hub, .. } => {
steering_hub.interrupt_then_steer(&text, Some(&actor));
@ -415,12 +440,14 @@ impl RunAnswerTransport {
actor: Principal,
) -> Result<(), PairTransportError> {
match self {
Self::Subprocess { control_tx } => {
Self::Worker {
run_id: worker_run_id,
bus,
} => {
let message = WorkerControlEnvelope::start_pair(run_id, pair_id, target, actor);
timeout(WORKER_CONTROL_ENQUEUE_TIMEOUT, control_tx.send(message))
Self::publish_worker_control(*worker_run_id, bus, message)
.await
.map_err(|_| PairTransportError::Timeout)?
.map_err(|_| PairTransportError::Closed)
.map_err(|err| Self::pair_error_from_bus(&err))
}
Self::InProcess { steering_hub, .. } => steering_hub
.start_pair(run_id, pair_id, target, Some(actor))
@ -438,7 +465,7 @@ impl RunAnswerTransport {
actor: Principal,
) -> Result<(), PairTransportError> {
match self {
Self::Subprocess { control_tx } => {
Self::Worker { run_id, bus } => {
let message = WorkerControlEnvelope::pair_message(
pair_id,
message_id,
@ -446,10 +473,9 @@ impl RunAnswerTransport {
client_message_id.clone(),
actor,
);
timeout(WORKER_CONTROL_ENQUEUE_TIMEOUT, control_tx.send(message))
Self::publish_worker_control(*run_id, bus, message)
.await
.map_err(|_| PairTransportError::Timeout)?
.map_err(|_| PairTransportError::Closed)
.map_err(|err| Self::pair_error_from_bus(&err))
}
Self::InProcess { steering_hub, .. } => steering_hub
.send_pair_message(pair_id, message_id, text, client_message_id, Some(actor))
@ -460,12 +486,11 @@ impl RunAnswerTransport {
async fn end_pair(&self, pair_id: PairId, actor: Principal) -> Result<(), PairTransportError> {
match self {
Self::Subprocess { control_tx } => {
Self::Worker { run_id, bus } => {
let message = WorkerControlEnvelope::end_pair(pair_id, actor);
timeout(WORKER_CONTROL_ENQUEUE_TIMEOUT, control_tx.send(message))
Self::publish_worker_control(*run_id, bus, message)
.await
.map_err(|_| PairTransportError::Timeout)?
.map_err(|_| PairTransportError::Closed)
.map_err(|err| Self::pair_error_from_bus(&err))
}
Self::InProcess { steering_hub, .. } => steering_hub
.end_pair(pair_id, Some(actor))
@ -473,6 +498,30 @@ impl RunAnswerTransport {
.map_err(PairTransportError::Control),
}
}
async fn pause_run(&self) -> Result<(), AnswerTransportError> {
match self {
Self::Worker { run_id, bus } => {
let message = WorkerControlEnvelope::pause_run();
Self::publish_worker_control(*run_id, bus, message)
.await
.map_err(|err| Self::answer_error_from_bus(&err))
}
Self::InProcess { .. } => Err(AnswerTransportError::Closed),
}
}
async fn unpause_run(&self) -> Result<(), AnswerTransportError> {
match self {
Self::Worker { run_id, bus } => {
let message = WorkerControlEnvelope::unpause_run();
Self::publish_worker_control(*run_id, bus, message)
.await
.map_err(|err| Self::answer_error_from_bus(&err))
}
Self::InProcess { .. } => Err(AnswerTransportError::Closed),
}
}
}
#[derive(Debug, Clone)]
@ -1019,6 +1068,7 @@ pub struct AppState {
started_at: Instant,
resource_sampler: resource_sampler::ResourceSampler,
max_concurrent_runs: usize,
pub(crate) worker_control_bus: Arc<dyn WorkerControlBus>,
scheduler_notify: Notify,
global_event_tx: broadcast::Sender<EventEnvelope>,
/// Per-run coalescing registry for `GET /runs/{id}/files`. Concurrent
@ -1178,6 +1228,8 @@ pub(crate) struct AppStateConfig {
pub(crate) http_client: Option<fabro_http::HttpClient>,
pub(crate) sandbox_provider_registry: Option<SandboxProviderRegistry>,
pub(crate) shutdown: CancellationToken,
#[cfg(test)]
pub(crate) worker_control_bus: Option<Arc<dyn WorkerControlBus>>,
#[cfg(any(test, feature = "test-support"))]
pub(crate) automation_materializer_override: Option<Arc<dyn AutomationRunMaterializer>>,
}
@ -2230,6 +2282,8 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
http_client,
sandbox_provider_registry,
shutdown,
#[cfg(test)]
worker_control_bus,
#[cfg(any(test, feature = "test-support"))]
automation_materializer_override,
} = config;
@ -2315,6 +2369,16 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
};
let worker_tokens = worker_token_keys_from_server_secrets(&server_secrets)?;
let github_api_base_url = github_api_base_url.unwrap_or_else(fabro_github::github_api_base_url);
let worker_control_bus: Arc<dyn WorkerControlBus> = {
#[cfg(test)]
{
worker_control_bus.unwrap_or_else(|| Arc::new(LocalWorkerControlBus::new()))
}
#[cfg(not(test))]
{
Arc::new(LocalWorkerControlBus::new())
}
};
Ok(Arc::new(AppState {
runs: Mutex::new(HashMap::new()),
aggregate_billing: Mutex::new(BillingAccumulator::default()),
@ -2328,6 +2392,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
started_at: Instant::now(),
resource_sampler: resource_sampler::ResourceSampler::new(),
max_concurrent_runs,
worker_control_bus,
scheduler_notify: Notify::new(),
global_event_tx,
files_in_flight: new_files_in_flight(),
@ -2776,6 +2841,13 @@ fn clear_live_run_state(run: &mut ManagedRun) {
run.worker_pgid = None;
}
fn cleanup_worker_control_bus_for_run(state: &AppState, run_id: RunId) {
let bus = Arc::clone(&state.worker_control_bus);
tokio::spawn(async move {
bus.cleanup_run(run_id).await;
});
}
fn reconcile_live_interview_state_for_event(run: &mut ManagedRun, event: &RunEvent) {
match &event.body {
EventBody::InterviewCompleted(props) => {
@ -3023,6 +3095,7 @@ async fn finish_cancelled_run_before_execution(state: &Arc<AppState>, run_id: Ru
clear_live_run_state(managed_run);
}
drop(runs);
cleanup_worker_control_bus_for_run(state.as_ref(), run_id);
state.scheduler_notify.notify_one();
}
@ -3158,6 +3231,7 @@ fn fail_managed_run(state: &Arc<AppState>, run_id: RunId, reason: FailureReason,
managed_run.error = Some(message);
clear_live_run_state(managed_run);
}
cleanup_worker_control_bus_for_run(state.as_ref(), run_id);
}
fn update_live_run_from_event(state: &AppState, run_id: RunId, event: &RunEvent) {
@ -3233,6 +3307,7 @@ fn update_live_run_from_event(state: &AppState, run_id: RunId, event: &RunEvent)
managed_run.active_api_targets.clear();
managed_run.active_steerable_stages.clear();
managed_run.active_non_steerable_stages.clear();
cleanup_worker_control_bus_for_run(state, run_id);
}
EventBody::RunFailed(props) => {
managed_run.status = RunStatus::Failed {
@ -3245,6 +3320,7 @@ fn update_live_run_from_event(state: &AppState, run_id: RunId, event: &RunEvent)
managed_run.active_api_targets.clear();
managed_run.active_steerable_stages.clear();
managed_run.active_non_steerable_stages.clear();
cleanup_worker_control_bus_for_run(state, run_id);
}
// Track active agent sessions by steerability. Activated/deactivated
// are leased by session id so stale deactivations cannot clear a newer
@ -3329,20 +3405,6 @@ async fn drain_worker_stderr(run_id: RunId, stderr: ChildStderr) -> anyhow::Resu
Ok(())
}
async fn pump_worker_control_jsonl(
mut stdin: ChildStdin,
mut control_rx: mpsc::Receiver<WorkerControlEnvelope>,
) -> anyhow::Result<()> {
while let Some(message) = control_rx.recv().await {
let mut line = serde_json::to_vec(&message)?;
line.push(b'\n');
stdin.write_all(&line).await?;
stdin.flush().await?;
}
Ok(())
}
async fn append_worker_exit_failure(
run_store: &fabro_store::RunDatabase,
run_id: RunId,
@ -3426,7 +3488,7 @@ fn worker_command(
.arg(run_id.to_string())
.arg("--mode")
.arg(worker_mode_arg(mode))
.stdin(Stdio::piped())
.stdin(Stdio::null())
.stdout(worker_stdout)
.stderr(Stdio::piped());
@ -4105,25 +4167,6 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
}
}
let Some(stdin) = child.stdin.take() else {
let message = "Worker stdin pipe was unavailable".to_string();
tracing::error!(run_id = %run_id, "{message}");
let _ = child.start_kill();
let failure_event = workflow_event::Event::workflow_run_failed_from_error(
&WorkflowError::engine(message.clone()),
fabro_types::RunTiming::default(),
FailureReason::LaunchFailed,
None,
None,
None,
None,
);
let _ = workflow_event::append_event(&run_store, &run_id, &failure_event).await;
fail_managed_run(&state, run_id, FailureReason::LaunchFailed, message);
state.scheduler_notify.notify_one();
return;
};
let Some(stderr) = child.stderr.take() else {
let message = "Worker stderr pipe was unavailable".to_string();
tracing::error!(run_id = %run_id, "{message}");
@ -4143,15 +4186,16 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
return;
};
let (control_tx, control_rx) = mpsc::channel(WORKER_CONTROL_QUEUE_CAPACITY);
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
if let Some(managed_run) = runs.get_mut(&run_id) {
managed_run.answer_transport = Some(RunAnswerTransport::Subprocess { control_tx });
managed_run.answer_transport = Some(RunAnswerTransport::Worker {
run_id,
bus: Arc::clone(&state.worker_control_bus),
});
}
}
let control_task = tokio::spawn(pump_worker_control_jsonl(stdin, control_rx));
let stderr_task = tokio::spawn(drain_worker_stderr(run_id, stderr));
let wait_status = match child.wait().await {
@ -4176,9 +4220,6 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
}
};
control_task.abort();
let _ = control_task.await;
match stderr_task.await {
Ok(Ok(())) => {}
Ok(Err(err)) => {

View file

@ -387,10 +387,6 @@ async fn cancel_run(
| RunStatus::Running
| RunStatus::Blocked { .. }
| RunStatus::Paused { .. } => {
let use_cancel_signal = !matches!(
managed_run.answer_transport,
Some(RunAnswerTransport::InProcess { .. })
);
let persist_cancelled_status = matches!(
managed_run.status,
RunStatus::Submitted | RunStatus::Pending { .. } | RunStatus::Runnable
@ -404,9 +400,7 @@ async fn cancel_run(
persist_cancelled_status,
managed_run.answer_transport.clone(),
managed_run.cancel_token.clone(),
use_cancel_signal
.then(|| managed_run.cancel_tx.take())
.flatten(),
managed_run.cancel_tx.take(),
managed_run.worker_pid,
))
}
@ -436,22 +430,29 @@ async fn cancel_run(
if let Some(token) = &cancel_token {
token.cancel();
}
let sent_cancel_signal = if let Some(cancel_tx) = cancel_tx {
let sent_in_process_cancel = if let Some(cancel_tx) = cancel_tx {
let _ = cancel_tx.send(());
true
} else {
false
};
if let Some(answer_transport) = answer_transport {
if !(sent_cancel_signal && matches!(answer_transport, RunAnswerTransport::InProcess { .. }))
let delivered_control = if let Some(answer_transport) = answer_transport {
if sent_in_process_cancel
&& matches!(answer_transport, RunAnswerTransport::InProcess { .. })
{
let _ = answer_transport.cancel_run().await;
true
} else {
answer_transport.cancel_run().await.is_ok()
}
} else {
false
};
if !delivered_control {
if let Some(worker_pid) = worker_pid {
#[cfg(unix)]
fabro_proc::sigterm(worker_pid);
schedule_worker_kill(Arc::clone(&state), id, worker_pid);
}
}
if let Some(worker_pid) = worker_pid {
#[cfg(unix)]
fabro_proc::sigterm(worker_pid);
schedule_worker_kill(Arc::clone(&state), id, worker_pid);
}
if persist_cancelled_status {
@ -504,9 +505,9 @@ async fn unmanaged_cancel_response(
/// How `pause_run` should enact the transition, chosen from the current run
/// status.
enum PauseMode {
/// Worker is running; ask it to pause via SIGUSR1. Status flips to
/// `Paused` once the worker acknowledges.
Signal { worker_pid: u32 },
/// Worker is running; ask it to pause via the worker control bus. Status
/// flips to `Paused` once the worker acknowledges.
Transport { transport: RunAnswerTransport },
/// Worker is blocked on a human gate; flip to `Paused` directly by
/// appending `RunPaused` ourselves.
AppendEvent,
@ -514,8 +515,9 @@ enum PauseMode {
/// How `unpause_run` should enact the transition.
enum UnpauseMode {
/// No outstanding block; ask the worker to resume via SIGUSR2.
Signal { worker_pid: u32 },
/// No outstanding block; ask the worker to resume via the worker control
/// bus.
Transport { transport: RunAnswerTransport },
/// Was paused while blocked; append `RunUnpaused` and let the reducer
/// restore the underlying blocked state from `Paused { prior_block }`.
AppendEvent,
@ -544,11 +546,11 @@ async fn pause_run(
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) if managed_run.status == RunStatus::Running => {
let Some(worker_pid) = managed_run.worker_pid else {
let Some(transport) = managed_run.answer_transport.clone() else {
return ApiError::new(StatusCode::CONFLICT, "Run worker is not available.")
.into_response();
};
PauseMode::Signal { worker_pid }
PauseMode::Transport { transport }
}
Some(managed_run) if matches!(managed_run.status, RunStatus::Blocked { .. }) => {
PauseMode::AppendEvent
@ -578,11 +580,14 @@ async fn pause_run(
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
}
match mode {
PauseMode::Signal { worker_pid } => {
#[cfg(unix)]
fabro_proc::sigusr1(worker_pid);
#[cfg(not(unix))]
let _ = worker_pid;
PauseMode::Transport { transport } => {
if transport.pause_run().await.is_err() {
return ApiError::new(
StatusCode::SERVICE_UNAVAILABLE,
"Failed to deliver pause request to the active run.",
)
.into_response();
}
}
PauseMode::AppendEvent => {
if let Some(response) = synchronous_transition(state.as_ref(), id, |events| {
@ -625,11 +630,11 @@ async fn unpause_run(
prior_block: Some(_),
} => UnpauseMode::AppendEvent,
RunStatus::Paused { prior_block: None } => {
let Some(worker_pid) = managed_run.worker_pid else {
let Some(transport) = managed_run.answer_transport.clone() else {
return ApiError::new(StatusCode::CONFLICT, "Run worker is not available.")
.into_response();
};
UnpauseMode::Signal { worker_pid }
UnpauseMode::Transport { transport }
}
_ => {
return ApiError::new(StatusCode::CONFLICT, "Run is not paused.")
@ -658,11 +663,14 @@ async fn unpause_run(
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
}
match mode {
UnpauseMode::Signal { worker_pid } => {
#[cfg(unix)]
fabro_proc::sigusr2(worker_pid);
#[cfg(not(unix))]
let _ = worker_pid;
UnpauseMode::Transport { transport } => {
if transport.unpause_run().await.is_err() {
return ApiError::new(
StatusCode::SERVICE_UNAVAILABLE,
"Failed to deliver unpause request to the active run.",
)
.into_response();
}
}
UnpauseMode::AppendEvent => {
if let Some(response) = synchronous_transition(state.as_ref(), id, |events| {

View file

@ -23,6 +23,7 @@ mod sessions;
mod steer;
pub(in crate::server) mod system;
mod variables;
mod worker_control;
pub(super) use system::{health, openapi_spec};
@ -167,6 +168,7 @@ pub(super) fn real_routes() -> Router<Arc<AppState>> {
.merge(models::routes())
.merge(secrets::routes())
.merge(variables::routes())
.merge(worker_control::routes())
.merge(sessions::routes())
.merge(system::routes())
.merge(completions::routes())

View file

@ -0,0 +1,166 @@
use std::sync::Arc;
use axum::extract::ws::{
CloseFrame, Message as WsMessage, WebSocket, WebSocketUpgrade, close_code,
};
use fabro_interview::{
WORKER_CONTROL_INVALID_CURSOR_REASON, WORKER_CONTROL_PONG_TIMEOUT_REASON,
WORKER_CONTROL_WS_LIVENESS_TIMEOUT, WORKER_CONTROL_WS_PING_INTERVAL,
WorkerControlDeliveryFrame,
};
use futures_util::{SinkExt, StreamExt};
use tokio::time::{self, Instant, MissedTickBehavior};
use super::super::{
ApiError, AppState, IntoResponse, Query, RequireWorkerRunScoped, Response, Router, State,
StatusCode, get,
};
use crate::worker_control::{WorkerControlBusError, WorkerControlCursor, WorkerControlReceiver};
#[derive(Debug, serde::Deserialize)]
struct WorkerControlStreamQuery {
after: Option<String>,
}
pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new().route(
"/runs/{id}/worker/control-stream",
get(worker_control_stream),
)
}
async fn worker_control_stream(
RequireWorkerRunScoped(id): RequireWorkerRunScoped,
State(state): State<Arc<AppState>>,
Query(query): Query<WorkerControlStreamQuery>,
ws: WebSocketUpgrade,
) -> Response {
let cached = match state.store.get_cached_run(&id).await {
Ok(Some(cached)) => cached,
Ok(None) => return ApiError::not_found("Run not found.").into_response(),
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
if cached.projection.archived_at.is_some() {
return ApiError::new(StatusCode::CONFLICT, "Run is archived.").into_response();
}
if cached.projection.status.is_terminal() {
return ApiError::new(
StatusCode::CONFLICT,
"Run is terminal and cannot accept worker control streams.",
)
.into_response();
}
let cursor = match WorkerControlCursor::from_after_query(query.after.as_deref()) {
Ok(cursor) => cursor,
Err(err @ WorkerControlBusError::InvalidCursor { .. }) => {
return ApiError::new(StatusCode::GONE, err.to_string()).into_response();
}
Err(err) => return worker_control_bus_error_response(&err),
};
let receiver = match state.worker_control_bus.subscribe(id, cursor).await {
Ok(receiver) => receiver,
Err(err @ WorkerControlBusError::InvalidCursor { .. }) => {
return ApiError::new(StatusCode::GONE, err.to_string()).into_response();
}
Err(err) => return worker_control_bus_error_response(&err),
};
ws.on_upgrade(move |socket| worker_control_websocket(socket, receiver))
}
fn worker_control_bus_error_response(err: &WorkerControlBusError) -> Response {
let status = match err {
WorkerControlBusError::Closed | WorkerControlBusError::Unavailable => {
StatusCode::SERVICE_UNAVAILABLE
}
WorkerControlBusError::PublishTimeout => StatusCode::SERVICE_UNAVAILABLE,
WorkerControlBusError::InvalidCursor { .. } => StatusCode::GONE,
};
ApiError::new(status, err.to_string()).into_response()
}
async fn worker_control_websocket(socket: WebSocket, mut receiver: WorkerControlReceiver) {
let (mut sender, mut receiver_ws) = socket.split();
let mut ping_interval = time::interval(WORKER_CONTROL_WS_PING_INTERVAL);
ping_interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
let mut last_liveness = Instant::now();
let liveness_timeout = time::sleep_until(last_liveness + WORKER_CONTROL_WS_LIVENESS_TIMEOUT);
tokio::pin!(liveness_timeout);
loop {
liveness_timeout
.as_mut()
.reset(last_liveness + WORKER_CONTROL_WS_LIVENESS_TIMEOUT);
tokio::select! {
delivery = receiver.recv() => {
let Some(delivery) = delivery else {
let _ = sender.send(WsMessage::Close(None)).await;
return;
};
let delivery = match delivery {
Ok(delivery) => delivery,
Err(WorkerControlBusError::InvalidCursor { .. }) => {
let _ = sender.send(invalid_cursor_close_message()).await;
return;
}
Err(_) => {
let _ = sender.send(WsMessage::Close(None)).await;
return;
}
};
let frame = WorkerControlDeliveryFrame {
id: delivery.id.to_string(),
envelope: delivery.envelope,
};
let Ok(text) = serde_json::to_string(&frame) else {
let _ = sender.send(WsMessage::Close(None)).await;
return;
};
if sender.send(WsMessage::Text(text.into())).await.is_err() {
return;
}
}
message = receiver_ws.next() => {
let Some(message) = message else {
return;
};
match message {
Ok(WsMessage::Ping(payload)) => {
last_liveness = Instant::now();
if sender.send(WsMessage::Pong(payload)).await.is_err() {
return;
}
}
Ok(WsMessage::Pong(_) | WsMessage::Text(_) | WsMessage::Binary(_)) => {
last_liveness = Instant::now();
}
Ok(WsMessage::Close(_)) | Err(_) => return,
}
}
_ = ping_interval.tick() => {
if sender.send(WsMessage::Ping(Vec::new().into())).await.is_err() {
return;
}
}
() = &mut liveness_timeout => {
let _ = sender.send(WsMessage::Close(Some(CloseFrame {
code: close_code::AWAY,
reason: WORKER_CONTROL_PONG_TIMEOUT_REASON.into(),
}))).await;
return;
}
}
}
}
fn invalid_cursor_close_message() -> WsMessage {
WsMessage::Close(Some(CloseFrame {
code: close_code::POLICY,
reason: WORKER_CONTROL_INVALID_CURSOR_REASON.into(),
}))
}

View file

@ -13,7 +13,8 @@ use fabro_automation::{AutomationId, AutomationTarget};
use fabro_config::ServerSettingsBuilder;
use fabro_config::bind::Bind;
use fabro_interview::{
AnswerValue, ControlInterviewer, Interviewer, Question, WorkerControlMessage,
AnswerValue, ControlInterviewer, Interviewer, Question, WorkerControlDeliveryFrame,
WorkerControlEnvelope, WorkerControlMessage,
};
use fabro_llm::types::{Message as LlmMessage, Request as LlmRequest, TokenCounts};
use fabro_model::catalog::LlmCatalogSettings;
@ -33,6 +34,8 @@ use httpmock::Method::{GET, POST};
use httpmock::MockServer;
use serde_json::json;
use tokio_stream::StreamExt as _;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::protocol::Message as WebSocketMessage;
use tower::ServiceExt;
use tracing::field::{Field, Visit};
use tracing::{Event as TracingEvent, Subscriber, subscriber};
@ -45,6 +48,9 @@ use crate::automation_materializer::AutomationRunMaterializeInput;
use crate::github_webhooks::compute_signature;
use crate::jwt_auth::{AuthMode, ConfiguredAuth};
use crate::test_support::*;
use crate::worker_control::{
LocalWorkerControlBus, WorkerControlBus, WorkerControlCursor, WorkerControlReceiver,
};
const MINIMAL_DOT: &str = r#"digraph Test {
graph [goal="Test"]
@ -669,6 +675,292 @@ fn bearer_request(method: Method, path: &str, bearer: &str, body: Body) -> Reque
.unwrap()
}
struct WorkerControlWsTestServer {
base_url: String,
task: tokio::task::JoinHandle<()>,
}
impl WorkerControlWsTestServer {
async fn spawn(app: Router) -> Self {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("test WebSocket listener should bind");
let addr = listener
.local_addr()
.expect("test WebSocket listener should have a local address");
let task = tokio::spawn(async move {
let result = axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await;
if let Err(err) = result {
tracing::debug!(error = %err, "test WebSocket server stopped");
}
});
Self {
base_url: format!("ws://{addr}"),
task,
}
}
fn worker_control_url(&self, run_id: RunId, after: Option<&str>) -> String {
let mut url = format!(
"{}/api/v1/runs/{run_id}/worker/control-stream",
self.base_url
);
if let Some(after) = after {
url.push_str("?after=");
url.push_str(after);
}
url
}
}
impl Drop for WorkerControlWsTestServer {
fn drop(&mut self) {
self.task.abort();
}
}
fn worker_control_ws_request(
server: &WorkerControlWsTestServer,
run_id: RunId,
bearer: Option<&str>,
after: Option<&str>,
) -> Request<()> {
let mut request = server
.worker_control_url(run_id, after)
.into_client_request()
.expect("test worker-control WebSocket request should build");
if let Some(bearer) = bearer {
request.headers_mut().insert(
header::AUTHORIZATION,
format!("Bearer {bearer}")
.parse()
.expect("test bearer header should parse"),
);
}
request
}
async fn connect_worker_control_ws(
server: &WorkerControlWsTestServer,
run_id: RunId,
bearer: &str,
after: Option<&str>,
) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
let request = worker_control_ws_request(server, run_id, Some(bearer), after);
let (socket, _) = tokio_tungstenite::connect_async(request)
.await
.expect("worker-control WebSocket should connect");
socket
}
async fn assert_worker_control_ws_rejected(
server: &WorkerControlWsTestServer,
run_id: RunId,
bearer: Option<&str>,
after: Option<&str>,
expected: StatusCode,
) {
let request = worker_control_ws_request(server, run_id, bearer, after);
let error = tokio_tungstenite::connect_async(request)
.await
.expect_err("worker-control WebSocket should be rejected");
match error {
tokio_tungstenite::tungstenite::Error::Http(response) => {
assert_eq!(response.status(), expected);
}
other => panic!("expected HTTP rejection {expected}, got {other:#}"),
}
}
async fn next_worker_control_frame(
socket: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> WorkerControlDeliveryFrame {
let message = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
let message = futures_util::StreamExt::next(socket)
.await
.expect("worker-control WebSocket should remain open")
.expect("worker-control WebSocket frame should be ok");
match message {
WebSocketMessage::Text(text) => return text,
WebSocketMessage::Ping(payload) => {
futures_util::SinkExt::send(socket, WebSocketMessage::Pong(payload))
.await
.expect("test worker-control pong should send");
}
WebSocketMessage::Pong(_)
| WebSocketMessage::Binary(_)
| WebSocketMessage::Frame(_) => {}
WebSocketMessage::Close(frame) => {
panic!("worker-control WebSocket closed before text frame: {frame:?}");
}
}
}
})
.await
.expect("worker-control frame should arrive");
serde_json::from_str(message.as_str()).expect("worker-control delivery frame should parse")
}
#[tokio::test(flavor = "current_thread")]
async fn worker_control_stream_rejects_missing_user_and_cross_run_auth() {
let (_state, app) = jwt_auth_app();
let user_bearer = issue_test_user_jwt();
let run_id = create_run_with_bearer(&app, &user_bearer).await;
let worker_bearer = issue_test_worker_token(&run_id);
let other_run_id = create_run_with_bearer(&app, &user_bearer).await;
let other_worker_bearer = issue_test_worker_token(&other_run_id);
let server = WorkerControlWsTestServer::spawn(app).await;
assert_worker_control_ws_rejected(&server, run_id, None, None, StatusCode::UNAUTHORIZED).await;
assert_worker_control_ws_rejected(
&server,
run_id,
Some(&user_bearer),
None,
StatusCode::FORBIDDEN,
)
.await;
assert_worker_control_ws_rejected(
&server,
run_id,
Some(&other_worker_bearer),
None,
StatusCode::FORBIDDEN,
)
.await;
let mut socket = connect_worker_control_ws(&server, run_id, &worker_bearer, None).await;
futures_util::SinkExt::send(&mut socket, WebSocketMessage::Close(None))
.await
.unwrap();
}
#[tokio::test(flavor = "current_thread")]
async fn worker_control_stream_start_subscription_delivers_frames() {
let (state, app) = jwt_auth_app();
let user_bearer = issue_test_user_jwt();
let run_id = create_run_with_bearer(&app, &user_bearer).await;
let worker_bearer = issue_test_worker_token(&run_id);
let server = WorkerControlWsTestServer::spawn(app).await;
let mut socket = connect_worker_control_ws(&server, run_id, &worker_bearer, None).await;
let expected = WorkerControlEnvelope::cancel_run();
let id = state
.worker_control_bus
.publish(run_id, expected.clone())
.await
.unwrap();
let frame = next_worker_control_frame(&mut socket).await;
assert_eq!(frame.id, id.to_string());
assert_eq!(frame.envelope, expected);
}
#[tokio::test(flavor = "current_thread")]
async fn worker_control_stream_after_subscription_delivers_only_later_frames() {
let (state, app) = jwt_auth_app();
let user_bearer = issue_test_user_jwt();
let run_id = create_run_with_bearer(&app, &user_bearer).await;
let worker_bearer = issue_test_worker_token(&run_id);
let first = state
.worker_control_bus
.publish(run_id, WorkerControlEnvelope::cancel_run())
.await
.unwrap();
let expected = WorkerControlEnvelope::pause_run();
let second = state
.worker_control_bus
.publish(run_id, expected.clone())
.await
.unwrap();
let server = WorkerControlWsTestServer::spawn(app).await;
let mut socket =
connect_worker_control_ws(&server, run_id, &worker_bearer, Some(first.as_str())).await;
let frame = next_worker_control_frame(&mut socket).await;
assert_eq!(frame.id, second.to_string());
assert_eq!(frame.envelope, expected);
}
#[tokio::test(flavor = "current_thread")]
async fn worker_control_stream_invalid_cursor_is_http_gone_before_upgrade() {
let (_state, app) = jwt_auth_app();
let user_bearer = issue_test_user_jwt();
let run_id = create_run_with_bearer(&app, &user_bearer).await;
let worker_bearer = issue_test_worker_token(&run_id);
let server = WorkerControlWsTestServer::spawn(app).await;
assert_worker_control_ws_rejected(
&server,
run_id,
Some(&worker_bearer),
Some("local:999"),
StatusCode::GONE,
)
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn worker_control_stream_rejects_missing_terminal_and_archived_runs() {
let (state, app) = jwt_auth_app();
let user_bearer = issue_test_user_jwt();
let missing_run_id = RunId::new();
let missing_worker_bearer = issue_test_worker_token(&missing_run_id);
let terminal_run_id = RunId::new();
create_succeeded_run(&state, terminal_run_id).await;
let terminal_worker_bearer = issue_test_worker_token(&terminal_run_id);
let archived_run_id = RunId::new();
create_succeeded_run(&state, archived_run_id).await;
let archived_worker_bearer = issue_test_worker_token(&archived_run_id);
let archive_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(api(&format!("/runs/{archived_run_id}/archive")))
.header(header::AUTHORIZATION, format!("Bearer {user_bearer}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_json!(archive_response, StatusCode::OK).await;
let server = WorkerControlWsTestServer::spawn(app).await;
assert_worker_control_ws_rejected(
&server,
missing_run_id,
Some(&missing_worker_bearer),
None,
StatusCode::NOT_FOUND,
)
.await;
assert_worker_control_ws_rejected(
&server,
terminal_run_id,
Some(&terminal_worker_bearer),
None,
StatusCode::CONFLICT,
)
.await;
assert_worker_control_ws_rejected(
&server,
archived_run_id,
Some(&archived_worker_bearer),
None,
StatusCode::CONFLICT,
)
.await;
}
fn json_bearer_request(
method: Method,
path: &str,
@ -1664,6 +1956,7 @@ fn slack_app_state_with_secret_sources(
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_provider_registry: None,
shutdown: tokio_util::sync::CancellationToken::new(),
worker_control_bus: None,
automation_materializer_override: None,
})
.expect("slack test app state should build")
@ -1764,6 +2057,7 @@ fn slack_service_respects_disabled_server_config_even_with_vault_tokens() {
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_provider_registry: None,
shutdown: tokio_util::sync::CancellationToken::new(),
worker_control_bus: None,
automation_materializer_override: None,
})
.expect("slack disabled test app state should build");
@ -1771,6 +2065,23 @@ fn slack_service_respects_disabled_server_config_even_with_vault_tokens() {
assert!(state.slack_service.is_none());
}
#[cfg(unix)]
#[test]
fn worker_command_uses_null_stdin_and_token_env() {
let storage_dir = tempfile::tempdir().unwrap();
let state = worker_command_test_state(storage_dir.path(), &["dev-token"], Some(TEST_DEV_TOKEN));
let cmd = worker_command(
state.as_ref(),
RunId::new(),
RunExecutionMode::Start,
storage_dir.path(),
false,
)
.unwrap();
assert_worker_command_passes_token_only_by_env(&cmd);
}
#[cfg(unix)]
#[test]
fn worker_command_default_token_omits_agent_run_tools_scope() {
@ -2069,6 +2380,7 @@ methods = ["dev-token"]
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_provider_registry: None,
shutdown: tokio_util::sync::CancellationToken::new(),
worker_control_bus: None,
automation_materializer_override: None,
}) else {
panic!("build_app_state should require SESSION_SECRET")
@ -2197,6 +2509,7 @@ fn build_test_app_state_with_vault_path(vault_path: &Path) -> anyhow::Result<Arc
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_provider_registry: None,
shutdown: tokio_util::sync::CancellationToken::new(),
worker_control_bus: None,
automation_materializer_override: None,
})
}
@ -2377,23 +2690,47 @@ fn worker_token_claims(cmd: &Command, state: &AppState) -> crate::worker_token::
.claims
}
async fn worker_transport_with_receiver(
run_id: RunId,
) -> (RunAnswerTransport, WorkerControlReceiver) {
let bus = StdArc::new(LocalWorkerControlBus::new());
let receiver = bus
.subscribe(run_id, WorkerControlCursor::Start)
.await
.expect("test worker bus should subscribe");
// Ensure the subscription task is waiting before the test publishes.
tokio::task::yield_now().await;
let bus: StdArc<dyn WorkerControlBus> = bus;
let transport = RunAnswerTransport::Worker { run_id, bus };
(transport, receiver)
}
async fn recv_worker_control_envelope(
receiver: &mut WorkerControlReceiver,
) -> WorkerControlEnvelope {
receiver
.recv()
.await
.expect("test worker control receiver should stay open")
.expect("test worker control delivery should succeed")
.envelope
}
#[tokio::test]
async fn subprocess_answer_transport_cancel_run_enqueues_cancel_message() {
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(1);
let transport = RunAnswerTransport::Subprocess { control_tx };
async fn worker_answer_transport_cancel_run_publishes_cancel_message() {
let (transport, mut control_rx) = worker_transport_with_receiver(fixtures::RUN_1).await;
transport.cancel_run().await.unwrap();
assert_eq!(
control_rx.recv().await,
Some(WorkerControlEnvelope::cancel_run())
recv_worker_control_envelope(&mut control_rx).await,
WorkerControlEnvelope::cancel_run()
);
}
#[tokio::test]
async fn subprocess_answer_transport_steer_enqueues_plain_steer_message() {
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(1);
let transport = RunAnswerTransport::Subprocess { control_tx };
async fn worker_answer_transport_steer_publishes_plain_steer_message() {
let (transport, mut control_rx) = worker_transport_with_receiver(fixtures::RUN_1).await;
let actor = Principal::System {
system_kind: SystemActorKind::Engine,
};
@ -2404,15 +2741,14 @@ async fn subprocess_answer_transport_steer_enqueues_plain_steer_message() {
.unwrap();
assert_eq!(
control_rx.recv().await,
Some(WorkerControlEnvelope::steer("try again", actor))
recv_worker_control_envelope(&mut control_rx).await,
WorkerControlEnvelope::steer("try again", actor)
);
}
#[tokio::test]
async fn subprocess_answer_transport_interrupt_enqueues_interrupt_message() {
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(1);
let transport = RunAnswerTransport::Subprocess { control_tx };
async fn worker_answer_transport_interrupt_publishes_interrupt_message() {
let (transport, mut control_rx) = worker_transport_with_receiver(fixtures::RUN_1).await;
let actor = Principal::System {
system_kind: SystemActorKind::Engine,
};
@ -2420,15 +2756,14 @@ async fn subprocess_answer_transport_interrupt_enqueues_interrupt_message() {
transport.interrupt(actor.clone()).await.unwrap();
assert_eq!(
control_rx.recv().await,
Some(WorkerControlEnvelope::interrupt(actor))
recv_worker_control_envelope(&mut control_rx).await,
WorkerControlEnvelope::interrupt(actor)
);
}
#[tokio::test]
async fn subprocess_answer_transport_interrupt_then_steer_enqueues_single_combined_message() {
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(1);
let transport = RunAnswerTransport::Subprocess { control_tx };
async fn worker_answer_transport_interrupt_then_steer_publishes_single_combined_message() {
let (transport, mut control_rx) = worker_transport_with_receiver(fixtures::RUN_1).await;
let actor = Principal::System {
system_kind: SystemActorKind::Engine,
};
@ -2439,19 +2774,15 @@ async fn subprocess_answer_transport_interrupt_then_steer_enqueues_single_combin
.unwrap();
assert_eq!(
control_rx.recv().await,
Some(WorkerControlEnvelope::interrupt_then_steer(
"try again",
actor
))
recv_worker_control_envelope(&mut control_rx).await,
WorkerControlEnvelope::interrupt_then_steer("try again", actor)
);
}
#[tokio::test]
async fn subprocess_answer_transport_pair_commands_enqueue_control_messages() {
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(3);
let transport = RunAnswerTransport::Subprocess { control_tx };
async fn worker_answer_transport_pair_commands_publish_control_messages() {
let run_id = fixtures::RUN_1;
let (transport, mut control_rx) = worker_transport_with_receiver(run_id).await;
let pair_id = "01HZX6M29F1CD5YYMHT1F5D7WQ".parse().unwrap();
let message_id = "01HZX6M4D7Y1QW0Q0P6V8Z4DR5".parse().unwrap();
let actor = Principal::System {
@ -2476,27 +2807,39 @@ async fn subprocess_answer_transport_pair_commands_enqueue_control_messages() {
transport.end_pair(pair_id, actor.clone()).await.unwrap();
assert_eq!(
control_rx.recv().await,
Some(WorkerControlEnvelope::start_pair(
run_id,
pair_id,
target,
actor.clone()
))
recv_worker_control_envelope(&mut control_rx).await,
WorkerControlEnvelope::start_pair(run_id, pair_id, target, actor.clone())
);
assert_eq!(
control_rx.recv().await,
Some(WorkerControlEnvelope::pair_message(
recv_worker_control_envelope(&mut control_rx).await,
WorkerControlEnvelope::pair_message(
pair_id,
message_id,
"inspect this",
Some("client-1".to_string()),
actor.clone()
))
)
);
assert_eq!(
control_rx.recv().await,
Some(WorkerControlEnvelope::end_pair(pair_id, actor))
recv_worker_control_envelope(&mut control_rx).await,
WorkerControlEnvelope::end_pair(pair_id, actor)
);
}
#[tokio::test]
async fn worker_answer_transport_pause_and_unpause_publish_control_messages() {
let (transport, mut control_rx) = worker_transport_with_receiver(fixtures::RUN_1).await;
transport.pause_run().await.unwrap();
transport.unpause_run().await.unwrap();
assert_eq!(
recv_worker_control_envelope(&mut control_rx).await,
WorkerControlEnvelope::pause_run()
);
assert_eq!(
recv_worker_control_envelope(&mut control_rx).await,
WorkerControlEnvelope::unpause_run()
);
}
@ -5444,6 +5787,7 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings(
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_provider_registry: None,
shutdown: tokio_util::sync::CancellationToken::new(),
worker_control_bus: None,
automation_materializer_override: None,
};
let state = build_app_state(config).expect("test app state should build");
@ -10847,12 +11191,8 @@ async fn steer_without_active_steerable_session_forwards_plain_steer_for_bufferi
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(1);
let _temp_dir = insert_running_control_run(
&state,
run_id,
Some(RunAnswerTransport::Subprocess { control_tx }),
);
let (transport, mut control_rx) = worker_transport_with_receiver(run_id).await;
let _temp_dir = insert_running_control_run(&state, run_id, Some(transport));
let req = Request::builder()
.method("POST")
@ -10863,7 +11203,7 @@ async fn steer_without_active_steerable_session_forwards_plain_steer_for_bufferi
let response = app.oneshot(req).await.unwrap();
assert_status!(response, StatusCode::ACCEPTED).await;
let envelope = control_rx.recv().await.unwrap();
let envelope = recv_worker_control_envelope(&mut control_rx).await;
assert!(matches!(
envelope.message,
WorkerControlMessage::Steer { ref text, .. } if text == "try again"
@ -10876,12 +11216,8 @@ async fn steer_with_active_non_steerable_session_returns_conflict() {
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;
let stage_id = StageId::new("agent", 1);
let (control_tx, _control_rx) = tokio::sync::mpsc::channel(1);
let _temp_dir = insert_running_control_run(
&state,
run_id,
Some(RunAnswerTransport::Subprocess { control_tx }),
);
let (transport, _control_rx) = worker_transport_with_receiver(run_id).await;
let _temp_dir = insert_running_control_run(&state, run_id, Some(transport));
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
runs.get_mut(&run_id)
@ -10908,12 +11244,8 @@ async fn steer_interrupt_without_active_steerable_session_returns_conflict() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;
let (control_tx, _control_rx) = tokio::sync::mpsc::channel(1);
let _temp_dir = insert_running_control_run(
&state,
run_id,
Some(RunAnswerTransport::Subprocess { control_tx }),
);
let (transport, _control_rx) = worker_transport_with_receiver(run_id).await;
let _temp_dir = insert_running_control_run(&state, run_id, Some(transport));
let req = Request::builder()
.method("POST")
@ -10934,12 +11266,8 @@ async fn interrupt_with_active_steerable_session_forwards_interrupt() {
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;
let stage_id = StageId::new("agent", 1);
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(1);
let _temp_dir = insert_running_control_run(
&state,
run_id,
Some(RunAnswerTransport::Subprocess { control_tx }),
);
let (transport, mut control_rx) = worker_transport_with_receiver(run_id).await;
let _temp_dir = insert_running_control_run(&state, run_id, Some(transport));
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
runs.get_mut(&run_id)
@ -10956,7 +11284,7 @@ async fn interrupt_with_active_steerable_session_forwards_interrupt() {
let response = app.oneshot(req).await.unwrap();
assert_status!(response, StatusCode::ACCEPTED).await;
let envelope = control_rx.recv().await.unwrap();
let envelope = recv_worker_control_envelope(&mut control_rx).await;
assert!(matches!(
envelope.message,
WorkerControlMessage::Interrupt {
@ -10971,12 +11299,8 @@ async fn steer_interrupt_with_active_steerable_session_forwards_combined_control
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;
let stage_id = StageId::new("agent", 1);
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(1);
let _temp_dir = insert_running_control_run(
&state,
run_id,
Some(RunAnswerTransport::Subprocess { control_tx }),
);
let (transport, mut control_rx) = worker_transport_with_receiver(run_id).await;
let _temp_dir = insert_running_control_run(&state, run_id, Some(transport));
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
runs.get_mut(&run_id)
@ -10994,7 +11318,7 @@ async fn steer_interrupt_with_active_steerable_session_forwards_combined_control
let response = app.oneshot(req).await.unwrap();
assert_status!(response, StatusCode::ACCEPTED).await;
let envelope = control_rx.recv().await.unwrap();
let envelope = recv_worker_control_envelope(&mut control_rx).await;
assert!(matches!(
envelope.message,
WorkerControlMessage::InterruptThenSteer { ref text, .. } if text == "try again"
@ -11160,12 +11484,8 @@ async fn steer_with_active_acp_session_forwards_to_worker() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(1);
let _temp_dir = insert_running_control_run(
&state,
run_id,
Some(RunAnswerTransport::Subprocess { control_tx }),
);
let (transport, mut control_rx) = worker_transport_with_receiver(run_id).await;
let _temp_dir = insert_running_control_run(&state, run_id, Some(transport));
let started = acp_event_for_stage(&run_id, &workflow_event::Event::AgentAcpStarted {
node_id: "agent".to_string(),
@ -11198,7 +11518,7 @@ async fn steer_with_active_acp_session_forwards_to_worker() {
let response = app.oneshot(req).await.unwrap();
assert_status!(response, StatusCode::ACCEPTED).await;
let envelope = control_rx.recv().await.unwrap();
let envelope = recv_worker_control_envelope(&mut control_rx).await;
assert!(matches!(
envelope.message,
WorkerControlMessage::Steer { ref text, .. } if text == "try again"
@ -11265,12 +11585,8 @@ async fn active_acp_steerable_marker_clears_on_terminal_paths() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;
let (control_tx, _control_rx) = tokio::sync::mpsc::channel(1);
let _temp_dir = insert_running_control_run(
&state,
run_id,
Some(RunAnswerTransport::Subprocess { control_tx }),
);
let (transport, _control_rx) = worker_transport_with_receiver(run_id).await;
let _temp_dir = insert_running_control_run(&state, run_id, Some(transport));
let started = acp_event_for_stage(&run_id, &workflow_event::Event::AgentAcpStarted {
node_id: "agent".to_string(),
visit: 1,
@ -13200,11 +13516,13 @@ async fn pause_run_sets_pending_control_on_board_response() {
let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await;
let run_id = run_id_str.parse::<RunId>().unwrap();
let (transport, _control_rx) = worker_transport_with_receiver(run_id).await;
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
let managed_run = runs.get_mut(&run_id).expect("run should exist");
managed_run.status = RunStatus::Running;
managed_run.worker_pid = Some(u32::MAX);
managed_run.answer_transport = Some(transport);
}
let req = Request::builder()
@ -13320,11 +13638,13 @@ async fn unpause_run_sets_pending_control() {
let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await;
let run_id = run_id_str.parse::<RunId>().unwrap();
let (transport, _control_rx) = worker_transport_with_receiver(run_id).await;
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
let managed_run = runs.get_mut(&run_id).expect("run should exist");
managed_run.status = RunStatus::Paused { prior_block: None };
managed_run.worker_pid = Some(u32::MAX);
managed_run.answer_transport = Some(transport);
}
let req = Request::builder()

View file

@ -249,6 +249,8 @@ impl TestAppStateBuilder {
),
sandbox_provider_registry: self.sandbox_provider_registry,
shutdown: CancellationToken::new(),
#[cfg(test)]
worker_control_bus: None,
automation_materializer_override: self.automation_materializer,
})
}

View file

@ -0,0 +1,155 @@
use std::fmt;
use fabro_interview::WorkerControlEnvelope;
use fabro_types::RunId;
use futures_util::future::BoxFuture;
use tokio::sync::mpsc;
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct WorkerControlMessageId(String);
impl WorkerControlMessageId {
#[must_use]
pub(crate) fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Debug for WorkerControlMessageId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("WorkerControlMessageId")
.field(&self.0)
.finish()
}
}
impl fmt::Display for WorkerControlMessageId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for WorkerControlMessageId {
fn from(value: String) -> Self {
Self::new(value)
}
}
impl From<&str> for WorkerControlMessageId {
fn from(value: &str) -> Self {
Self::new(value)
}
}
/// Cursor for replaying a run's worker-control stream.
///
/// Future Redis Streams mapping:
/// - `Start` maps to `XREAD ... STREAMS fabro:run:{run_id}:control 0-0`.
/// - `After(id)` maps to `XREAD ... STREAMS fabro:run:{run_id}:control {id}`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum WorkerControlCursor {
Start,
After(WorkerControlMessageId),
}
impl WorkerControlCursor {
pub(crate) fn from_after_query(after: Option<&str>) -> Result<Self, WorkerControlBusError> {
match after {
None => Ok(Self::Start),
Some(value) if value.trim().is_empty() => Err(WorkerControlBusError::InvalidCursor {
cursor: value.to_string(),
reason: "cursor id must not be empty".to_string(),
}),
Some(value) => Ok(Self::After(WorkerControlMessageId::from(value))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WorkerControlDelivery {
pub(crate) id: WorkerControlMessageId,
pub(crate) envelope: WorkerControlEnvelope,
}
#[allow(
dead_code,
reason = "The local backend does not construct every cross-backend error variant."
)]
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub(crate) enum WorkerControlBusError {
#[error("worker control backend is closed")]
Closed,
#[error("worker control backend is unavailable")]
Unavailable,
#[error("worker control cursor `{cursor}` is invalid: {reason}")]
InvalidCursor { cursor: String, reason: String },
#[error("timed out publishing worker control message")]
PublishTimeout,
}
impl WorkerControlBusError {
#[must_use]
pub(crate) fn invalid_cursor(cursor: impl Into<String>, reason: impl Into<String>) -> Self {
Self::InvalidCursor {
cursor: cursor.into(),
reason: reason.into(),
}
}
}
pub(crate) type WorkerControlReceiver =
mpsc::Receiver<Result<WorkerControlDelivery, WorkerControlBusError>>;
pub(crate) trait WorkerControlBus: Send + Sync {
fn publish(
&self,
run_id: RunId,
envelope: WorkerControlEnvelope,
) -> BoxFuture<'_, Result<WorkerControlMessageId, WorkerControlBusError>>;
fn subscribe(
&self,
run_id: RunId,
cursor: WorkerControlCursor,
) -> BoxFuture<'_, Result<WorkerControlReceiver, WorkerControlBusError>>;
fn cleanup_run(&self, run_id: RunId) -> BoxFuture<'_, ()>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn message_id_equality_and_debug_are_opaque() {
let first = WorkerControlMessageId::from("local:42");
let same = WorkerControlMessageId::from("local:42");
let different = WorkerControlMessageId::from("local:43");
assert_eq!(first, same);
assert_ne!(first, different);
assert_eq!(format!("{first}"), "local:42");
assert_eq!(format!("{first:?}"), "WorkerControlMessageId(\"local:42\")");
}
#[test]
fn absent_after_query_parses_as_start_cursor() {
assert_eq!(
WorkerControlCursor::from_after_query(None).unwrap(),
WorkerControlCursor::Start
);
}
#[test]
fn present_after_query_parses_as_after_cursor() {
assert_eq!(
WorkerControlCursor::from_after_query(Some("local:42")).unwrap(),
WorkerControlCursor::After(WorkerControlMessageId::from("local:42"))
);
}
}

View file

@ -0,0 +1,458 @@
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use fabro_interview::WorkerControlEnvelope;
use fabro_types::RunId;
use futures_util::FutureExt;
use futures_util::future::BoxFuture;
use tokio::sync::{Notify, mpsc};
use super::{
WorkerControlBus, WorkerControlBusError, WorkerControlCursor, WorkerControlDelivery,
WorkerControlMessageId, WorkerControlReceiver,
};
pub(crate) const LOCAL_WORKER_CONTROL_RETAINED_MESSAGES_PER_RUN: usize = 1024;
const LOCAL_WORKER_CONTROL_SUBSCRIBER_BUFFER: usize = 64;
#[derive(Default)]
pub(crate) struct LocalWorkerControlBus {
streams: Arc<Mutex<HashMap<RunId, LocalRunControlStream>>>,
next_sequence: Arc<AtomicU64>,
}
struct LocalRunControlStream {
messages: VecDeque<LocalMessage>,
notify: Arc<Notify>,
has_trimmed: bool,
}
#[derive(Clone)]
struct LocalMessage {
sequence: u64,
delivery: WorkerControlDelivery,
}
impl LocalWorkerControlBus {
#[must_use]
pub(crate) fn new() -> Self {
Self {
streams: Arc::new(Mutex::new(HashMap::new())),
next_sequence: Arc::new(AtomicU64::new(1)),
}
}
fn next_message_id(&self) -> (u64, WorkerControlMessageId) {
let sequence = self.next_sequence.fetch_add(1, Ordering::Relaxed);
(
sequence,
WorkerControlMessageId::new(format!("local:{sequence}")),
)
}
fn subscribe_inner(
&self,
run_id: RunId,
cursor: &WorkerControlCursor,
) -> Result<WorkerControlReceiver, WorkerControlBusError> {
let (next_sequence, notify) = {
let mut streams = self
.streams
.lock()
.expect("worker control streams poisoned");
let stream = match cursor {
WorkerControlCursor::Start => streams
.entry(run_id)
.or_insert_with(LocalRunControlStream::new),
WorkerControlCursor::After(id) => streams.get_mut(&run_id).ok_or_else(|| {
WorkerControlBusError::invalid_cursor(
id.as_str(),
"message id is not retained for this run",
)
})?,
};
let next_sequence = stream.next_sequence_for_cursor(cursor)?;
(next_sequence, Arc::clone(&stream.notify))
};
let (tx, rx) = mpsc::channel(LOCAL_WORKER_CONTROL_SUBSCRIBER_BUFFER);
let streams = Arc::clone(&self.streams);
tokio::spawn(async move {
local_subscription_task(streams, run_id, notify, next_sequence, tx).await;
});
Ok(rx)
}
#[cfg(test)]
pub(crate) fn retained_len(&self, run_id: &RunId) -> usize {
let streams = self
.streams
.lock()
.expect("worker control streams poisoned");
streams
.get(run_id)
.map_or(0, |stream| stream.messages.len())
}
}
impl LocalRunControlStream {
fn new() -> Self {
Self {
messages: VecDeque::new(),
notify: Arc::new(Notify::new()),
has_trimmed: false,
}
}
fn next_sequence_for_cursor(
&self,
cursor: &WorkerControlCursor,
) -> Result<Option<u64>, WorkerControlBusError> {
match cursor {
WorkerControlCursor::Start if self.has_trimmed => Err(
WorkerControlBusError::invalid_cursor("start", "retained local stream is trimmed"),
),
WorkerControlCursor::Start => Ok(self.messages.front().map(|message| message.sequence)),
WorkerControlCursor::After(id) => {
let requested_sequence = parse_local_sequence(id)?;
let Some(position) = self
.messages
.iter()
.position(|message| message.delivery.id == *id)
else {
return Err(WorkerControlBusError::invalid_cursor(
id.as_str(),
"message id is not retained for this run",
));
};
Ok(self
.messages
.get(position + 1)
.map(|message| message.sequence)
.or(Some(requested_sequence.saturating_add(1))))
}
}
}
fn trim_retained(&mut self) {
while self.messages.len() > LOCAL_WORKER_CONTROL_RETAINED_MESSAGES_PER_RUN {
self.messages.pop_front();
self.has_trimmed = true;
}
}
}
fn parse_local_sequence(id: &WorkerControlMessageId) -> Result<u64, WorkerControlBusError> {
let Some(raw) = id.as_str().strip_prefix("local:") else {
return Err(WorkerControlBusError::invalid_cursor(
id.as_str(),
"local backend only understands local message ids",
));
};
raw.parse::<u64>().map_err(|_| {
WorkerControlBusError::invalid_cursor(id.as_str(), "local message id sequence is invalid")
})
}
async fn local_subscription_task(
streams: Arc<Mutex<HashMap<RunId, LocalRunControlStream>>>,
run_id: RunId,
notify: Arc<Notify>,
mut next_sequence: Option<u64>,
tx: mpsc::Sender<Result<WorkerControlDelivery, WorkerControlBusError>>,
) {
loop {
// Register interest *before* inspecting the stream so that a publish
// racing with this read does not cause a lost wakeup. `notify_waiters`
// does not leave a permit for future `notified()` calls.
let notified = notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
let collected = {
let streams_guard = streams.lock().expect("worker control streams poisoned");
match streams_guard.get(&run_id) {
None => None,
Some(stream) => {
// A `Start` subscriber that joined before any publish lazily
// adopts the first retained message as its cursor.
if next_sequence.is_none() && stream.has_trimmed {
Some(Err(WorkerControlBusError::invalid_cursor(
"start",
"retained local stream is trimmed",
)))
} else {
if next_sequence.is_none() {
next_sequence = stream.messages.front().map(|message| message.sequence);
}
match next_sequence {
None => Some(Ok(Vec::new())),
Some(next) => Some(collect_messages_from(&stream.messages, next)),
}
}
}
}
};
let messages = match collected {
None => return,
Some(Err(err)) => {
let _ = tx.send(Err(err)).await;
return;
}
Some(Ok(messages)) => messages,
};
if messages.is_empty() {
notified.await;
continue;
}
for message in messages {
next_sequence = Some(message.sequence.saturating_add(1));
if tx.send(Ok(message.delivery)).await.is_err() {
return;
}
}
}
}
/// Returns the retained messages with `sequence >= next`. Cheaper than scanning
/// the whole deque: `partition_point` is O(log N) and we only clone the tail.
fn collect_messages_from(
messages: &VecDeque<LocalMessage>,
next: u64,
) -> Result<Vec<LocalMessage>, WorkerControlBusError> {
let Some(first_sequence) = messages.front().map(|message| message.sequence) else {
return Ok(Vec::new());
};
if next < first_sequence {
return Err(WorkerControlBusError::invalid_cursor(
format!("local:{next}"),
"subscriber fell behind retained local messages",
));
}
let start = messages.partition_point(|message| message.sequence < next);
Ok(messages.iter().skip(start).cloned().collect())
}
impl WorkerControlBus for LocalWorkerControlBus {
fn publish(
&self,
run_id: RunId,
envelope: WorkerControlEnvelope,
) -> BoxFuture<'_, Result<WorkerControlMessageId, WorkerControlBusError>> {
async move {
let (sequence, id) = self.next_message_id();
let delivery = WorkerControlDelivery {
id: id.clone(),
envelope,
};
let notify = {
let mut streams = self
.streams
.lock()
.expect("worker control streams poisoned");
let stream = streams
.entry(run_id)
.or_insert_with(LocalRunControlStream::new);
stream
.messages
.push_back(LocalMessage { sequence, delivery });
stream.trim_retained();
Arc::clone(&stream.notify)
};
notify.notify_waiters();
Ok(id)
}
.boxed()
}
fn subscribe(
&self,
run_id: RunId,
cursor: WorkerControlCursor,
) -> BoxFuture<'_, Result<WorkerControlReceiver, WorkerControlBusError>> {
async move { self.subscribe_inner(run_id, &cursor) }.boxed()
}
fn cleanup_run(&self, run_id: RunId) -> BoxFuture<'_, ()> {
async move {
let notify = {
let mut streams = self
.streams
.lock()
.expect("worker control streams poisoned");
streams.remove(&run_id).map(|stream| stream.notify)
};
if let Some(notify) = notify {
notify.notify_waiters();
}
}
.boxed()
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use fabro_interview::WorkerControlMessage;
use fabro_types::fixtures;
use super::*;
async fn recv_delivery(receiver: &mut WorkerControlReceiver) -> WorkerControlDelivery {
tokio::time::timeout(Duration::from_secs(1), receiver.recv())
.await
.expect("delivery should arrive")
.expect("subscription should remain open")
.expect("delivery should be ok")
}
#[tokio::test]
async fn messages_publish_in_order() {
let bus = LocalWorkerControlBus::new();
bus.publish(fixtures::RUN_1, WorkerControlEnvelope::cancel_run())
.await
.unwrap();
bus.publish(fixtures::RUN_1, WorkerControlEnvelope::pause_run())
.await
.unwrap();
let mut receiver = bus
.subscribe(fixtures::RUN_1, WorkerControlCursor::Start)
.await
.unwrap();
assert!(matches!(
recv_delivery(&mut receiver).await.envelope.message,
WorkerControlMessage::RunCancel
));
assert!(matches!(
recv_delivery(&mut receiver).await.envelope.message,
WorkerControlMessage::RunPause
));
}
#[tokio::test]
async fn active_subscriber_receives_message_published_after_subscription() {
let bus = LocalWorkerControlBus::new();
let mut receiver = bus
.subscribe(fixtures::RUN_1, WorkerControlCursor::Start)
.await
.unwrap();
bus.publish(fixtures::RUN_1, WorkerControlEnvelope::cancel_run())
.await
.unwrap();
assert!(matches!(
recv_delivery(&mut receiver).await.envelope.message,
WorkerControlMessage::RunCancel
));
}
#[tokio::test]
async fn messages_published_before_subscription_replay_from_start() {
let bus = LocalWorkerControlBus::new();
bus.publish(fixtures::RUN_1, WorkerControlEnvelope::cancel_run())
.await
.unwrap();
let mut receiver = bus
.subscribe(fixtures::RUN_1, WorkerControlCursor::Start)
.await
.unwrap();
assert!(matches!(
recv_delivery(&mut receiver).await.envelope.message,
WorkerControlMessage::RunCancel
));
}
#[tokio::test]
async fn after_cursor_receives_only_later_messages() {
let bus = LocalWorkerControlBus::new();
let first_id = bus
.publish(fixtures::RUN_1, WorkerControlEnvelope::cancel_run())
.await
.unwrap();
bus.publish(fixtures::RUN_1, WorkerControlEnvelope::pause_run())
.await
.unwrap();
let mut receiver = bus
.subscribe(
fixtures::RUN_1,
WorkerControlCursor::After(first_id.clone()),
)
.await
.unwrap();
assert!(matches!(
recv_delivery(&mut receiver).await.envelope.message,
WorkerControlMessage::RunPause
));
assert!(receiver.try_recv().is_err());
}
#[tokio::test]
async fn trimming_bounds_retained_messages_and_invalidates_old_cursor() {
let bus = LocalWorkerControlBus::new();
let old_id = bus
.publish(fixtures::RUN_1, WorkerControlEnvelope::cancel_run())
.await
.unwrap();
for _ in 0..LOCAL_WORKER_CONTROL_RETAINED_MESSAGES_PER_RUN {
bus.publish(fixtures::RUN_1, WorkerControlEnvelope::pause_run())
.await
.unwrap();
}
assert_eq!(
bus.retained_len(&fixtures::RUN_1),
LOCAL_WORKER_CONTROL_RETAINED_MESSAGES_PER_RUN
);
let err = bus
.subscribe(fixtures::RUN_1, WorkerControlCursor::After(old_id))
.await
.unwrap_err();
assert!(matches!(err, WorkerControlBusError::InvalidCursor { .. }));
let err = bus
.subscribe(fixtures::RUN_1, WorkerControlCursor::Start)
.await
.unwrap_err();
assert!(matches!(err, WorkerControlBusError::InvalidCursor { .. }));
}
#[tokio::test]
async fn after_cursor_for_unknown_run_does_not_create_stream() {
let bus = LocalWorkerControlBus::new();
let err = bus
.subscribe(
fixtures::RUN_1,
WorkerControlCursor::After(WorkerControlMessageId::new("local:1")),
)
.await
.unwrap_err();
assert!(matches!(err, WorkerControlBusError::InvalidCursor { .. }));
assert_eq!(bus.retained_len(&fixtures::RUN_1), 0);
}
#[tokio::test]
async fn cleanup_removes_retained_messages() {
let bus = LocalWorkerControlBus::new();
bus.publish(fixtures::RUN_1, WorkerControlEnvelope::cancel_run())
.await
.unwrap();
assert_eq!(bus.retained_len(&fixtures::RUN_1), 1);
bus.cleanup_run(fixtures::RUN_1).await;
assert_eq!(bus.retained_len(&fixtures::RUN_1), 0);
}
}

View file

@ -0,0 +1,8 @@
mod bus;
mod local;
pub(crate) use bus::{
WorkerControlBus, WorkerControlBusError, WorkerControlCursor, WorkerControlDelivery,
WorkerControlMessageId, WorkerControlReceiver,
};
pub(crate) use local::LocalWorkerControlBus;

View file

@ -26,6 +26,7 @@ use fabro_types::{ManifestPath, RunId, RunRunnableSource, SandboxProviderKind};
use fabro_vault::Vault;
use tokio::runtime::Handle;
use tokio::sync::RwLock as AsyncRwLock;
use tokio::time;
use tokio_util::sync::CancellationToken;
use crate::artifact_upload::ArtifactSink;
@ -987,6 +988,24 @@ impl Drop for DetachedRunBootstrapGuard {
const POSTRUN_INTERRUPTED_MESSAGE: &str = "Run interrupted before post-run finalization completed.";
const POSTRUN_CANCELLED_MESSAGE: &str = "Run cancelled before post-run finalization completed.";
const DETACHED_COMPLETION_GUARD_TERMINAL_GRACE: Duration = Duration::from_millis(25);
async fn run_store_reaches_terminal(run_store: &RunStoreHandle, timeout: Duration) -> bool {
let start = Instant::now();
loop {
if run_store
.state()
.await
.is_ok_and(|state| state.status.is_terminal())
{
return true;
}
if start.elapsed() >= timeout {
return false;
}
time::sleep(Duration::from_millis(10)).await;
}
}
struct DetachedRunCompletionGuard {
event_sink: RunEventSink,
@ -1044,6 +1063,11 @@ impl Drop for DetachedRunCompletionGuard {
let run_store = self.run_store.clone();
if let Ok(handle) = Handle::try_current() {
handle.spawn(async move {
if run_store_reaches_terminal(&run_store, DETACHED_COMPLETION_GUARD_TERMINAL_GRACE)
.await
{
return;
}
emit_workflow_run_failed(
run_id,
&run_store,