fix: restrict worker send input to idle admission

This commit is contained in:
2026-10-02 02:39:10 +09:00
parent 4ac448d66c
commit d5f49cbf00
12 changed files with 503 additions and 25 deletions
+10
View File
@@ -240,6 +240,16 @@ pub enum Method {
#[serde(skip_deserializing, default)] #[serde(skip_deserializing, default)]
source: AuthenticatedInputSource, source: AuthenticatedInputSource,
}, },
/// Agent-controlled input: accept only at an Idle controller boundary, never
/// enqueue behind a run. Unlike human Submit, non-Idle admission is rejected.
#[cfg_attr(feature = "typescript", ts(skip))]
#[cfg_attr(feature = "json-schema", schemars(skip))]
SubmitIfIdle {
submission_request_id: String,
input: Vec<Segment>,
#[serde(skip_deserializing, default)]
source: AuthenticatedInputSource,
},
/// Human-readable text injected into the target Worker's LLM context as a /// Human-readable text injected into the target Worker's LLM context as a
/// non-blocking system message. Accepted notifications are delivered at the /// non-blocking system message. Accepted notifications are delivered at the
/// next between-turn boundary, or start a coalesced notification run while /// next between-turn boundary, or start a coalesced notification run while
+13
View File
@@ -1556,6 +1556,19 @@ fn authorize_runtime_protocol_method(
submission_request_id, submission_request_id,
input, input,
}, },
protocol::Method::SubmitIfIdle {
submission_request_id,
input,
..
} => protocol::Method::SubmitIfIdle {
source: transport_source.cloned().unwrap_or_else(|| {
protocol::AuthenticatedInputSource::Backend {
operation_id: submission_request_id.clone(),
}
}),
submission_request_id,
input,
},
protocol::Method::NotifyTracked { protocol::Method::NotifyTracked {
notification_request_id, notification_request_id,
message, message,
+2
View File
@@ -8,6 +8,8 @@ use serde::{Deserialize, Serialize};
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum WorkerInputKind { pub enum WorkerInputKind {
User, User,
/// Agent input that must never enter the human Submit queue.
UserIfIdle,
Notify, Notify,
Compact, Compact,
ListRewindTargets, ListRewindTargets,
+21 -3
View File
@@ -2090,7 +2090,10 @@ impl Runtime {
mut input: WorkerInput, mut input: WorkerInput,
) -> Result<WorkerInteractionAck, RuntimeError> { ) -> Result<WorkerInteractionAck, RuntimeError> {
validate_worker_input(&input)?; validate_worker_input(&input)?;
if matches!(input.kind, WorkerInputKind::User | WorkerInputKind::Notify) { if matches!(
input.kind,
WorkerInputKind::User | WorkerInputKind::UserIfIdle | WorkerInputKind::Notify
) {
let request_id = input let request_id = input
.submission_request_id .submission_request_id
.clone() .clone()
@@ -2098,7 +2101,11 @@ impl Runtime {
.unwrap_or_else(|| Uuid::now_v7().to_string()); .unwrap_or_else(|| Uuid::now_v7().to_string());
input.submission_request_id = Some(request_id); input.submission_request_id = Some(request_id);
} }
let expected_submission_id = (input.kind == WorkerInputKind::User).then(|| { let expected_submission_id = matches!(
input.kind,
WorkerInputKind::User | WorkerInputKind::UserIfIdle
)
.then(|| {
input input
.submission_request_id .submission_request_id
.clone() .clone()
@@ -2110,6 +2117,17 @@ impl Runtime {
.clone() .clone()
.expect("Notify request id must be assigned before dispatch") .expect("Notify request id must be assigned before dispatch")
}); });
if input.kind == WorkerInputKind::UserIfIdle {
let state = self.lock()?;
let worker = state.worker(worker_ref)?;
// Stopped/restoring Workers have no controller to enforce admission.
// Live busy state is checked atomically by the controller, not here.
if worker.status == WorkerStatus::Stopped || worker.has_pending_lifecycle_operation() {
return Err(RuntimeError::InvalidRequest(
"WorkerSendInput requires an Idle Worker and never queues input. Use WorkerNotify for information about work already in progress.".to_string(),
));
}
}
self.ensure_worker_execution(worker_ref)?; self.ensure_worker_execution(worker_ref)?;
let (backend, handle) = { let (backend, handle) = {
let state = self.lock()?; let state = self.lock()?;
@@ -5319,7 +5337,7 @@ fn input_protocol_event(input: &WorkerInput) -> Option<protocol::Event> {
match input.kind { match input.kind {
// Submit is projected only after the Worker commits UserInput. Queued // Submit is projected only after the Worker commits UserInput. Queued
// payloads must never become model- or client-visible history early. // payloads must never become model- or client-visible history early.
WorkerInputKind::User | WorkerInputKind::Notify => None, WorkerInputKind::User | WorkerInputKind::UserIfIdle | WorkerInputKind::Notify => None,
WorkerInputKind::Compact WorkerInputKind::Compact
| WorkerInputKind::ListRewindTargets | WorkerInputKind::ListRewindTargets
| WorkerInputKind::RegisterPeer => Some(protocol::Event::SystemItem { | WorkerInputKind::RegisterPeer => Some(protocol::Event::SystemItem {
+137 -11
View File
@@ -2982,7 +2982,7 @@ where
} }
let (method, submission_request_id) = match input.kind { let (method, submission_request_id) = match input.kind {
WorkerInputKind::User => { WorkerInputKind::User | WorkerInputKind::UserIfIdle => {
let Some(submission_id) = input let Some(submission_id) = input
.submission_request_id .submission_request_id
.filter(|submission_id| !submission_id.trim().is_empty()) .filter(|submission_id| !submission_id.trim().is_empty())
@@ -2992,18 +2992,26 @@ where
"Runtime user input is missing its internal submission id", "Runtime user input is missing its internal submission id",
); );
}; };
( let segments = input
.segments
.unwrap_or_else(|| vec![Segment::text(input.content.trim().to_string())]);
let source = protocol::AuthenticatedInputSource::Backend {
operation_id: submission_id.clone(),
};
let method = if input.kind == WorkerInputKind::UserIfIdle {
Method::SubmitIfIdle {
submission_request_id: submission_id.clone(),
input: segments,
source,
}
} else {
Method::SubmitTracked { Method::SubmitTracked {
submission_request_id: submission_id.clone(), submission_request_id: submission_id.clone(),
input: input.segments.unwrap_or_else(|| { input: segments,
vec![Segment::text(input.content.trim().to_string())] source,
}), }
source: protocol::AuthenticatedInputSource::Backend { };
operation_id: submission_id.clone(), (method, Some(submission_id))
},
},
Some(submission_id),
)
} }
WorkerInputKind::Notify => { WorkerInputKind::Notify => {
unreachable!("Notify input is dispatched before ordinary input mapping") unreachable!("Notify input is dispatched before ordinary input mapping")
@@ -5581,6 +5589,124 @@ mod tests {
assert_eq!(call_count.load(Ordering::SeqCst), 3); assert_eq!(call_count.load(Ordering::SeqCst), 3);
} }
#[test]
#[cfg(feature = "ws-server")]
#[serial_test::serial(worker_allocation)]
fn idle_only_input_acknowledges_idle_and_rejects_running_paused_and_stopped() {
let client = MockClient::sequential(vec![MockResponse::Hang(
simple_text_events().into_iter().take(2).collect(),
)]);
let runtime_base = tempfile::tempdir().unwrap();
let cwd = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
let factory = MockFactory {
client,
runtime_base: runtime_base.path().to_path_buf(),
cwd: cwd.path().to_path_buf(),
store_dir: store.path().join("sessions"),
worker_metadata_dir: store.path().join("workers"),
observed_cwds: Arc::new(Mutex::new(Vec::new())),
observed_workspace_clients: Arc::new(Mutex::new(Vec::new())),
};
let backend = Arc::new(WorkerRuntimeExecutionBackend::new(factory).unwrap());
let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), backend.clone())
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let detail = runtime.create_worker(create_request("idle-only")).unwrap();
let input = |id: &str| {
let mut input = WorkerInput::user(id);
input.kind = WorkerInputKind::UserIfIdle;
input.submission_request_id = Some(id.to_string());
input
};
let ack = runtime
.send_input(&detail.worker_ref, input("idle-input"))
.unwrap();
assert_eq!(ack.submission.unwrap().submission_request_id, "idle-input");
wait_for_adapter_state(&backend, &detail.worker_ref, WorkerStatus::Running);
let error = runtime
.send_input(&detail.worker_ref, input("running-input"))
.unwrap_err();
assert!(error.to_string().contains("WorkerNotify"), "{error}");
runtime
.send_protocol_method(
&detail.worker_ref,
Method::Pause {
command: adapter_command(&backend, &detail.worker_ref),
},
)
.unwrap();
wait_for_adapter_state(&backend, &detail.worker_ref, WorkerStatus::Paused);
let error = runtime
.send_input(&detail.worker_ref, input("paused-input"))
.unwrap_err();
assert!(error.to_string().contains("WorkerNotify"), "{error}");
let handle = backend
.workers
.lock()
.unwrap()
.get(&detail.worker_ref)
.unwrap()
.handle
.clone();
let pending = backend
.run_on_adapter_runtime(async move {
let mut events = handle.subscribe();
handle
.send(Method::ListPendingSubmissions)
.await
.map_err(|e| e.to_string())?;
tokio::time::timeout(Duration::from_secs(2), async {
loop {
if let Ok(Event::PendingSubmissionsChanged { pending }) =
events.recv().await
{
break pending;
}
}
})
.await
.map_err(|e| e.to_string())
})
.unwrap();
assert!(pending.submissions.is_empty());
// Human Submit still queues while paused, and Notify remains advisory.
let ack = runtime
.send_input(&detail.worker_ref, WorkerInput::user("human-input"))
.unwrap();
assert_eq!(
ack.submission.unwrap().disposition,
protocol::SubmissionDisposition::Queued
);
let mut notify = WorkerInput::user("advisory");
notify.kind = WorkerInputKind::Notify;
assert!(
runtime
.send_input(&detail.worker_ref, notify)
.unwrap()
.notification
.is_some()
);
wait_for_adapter_state(&backend, &detail.worker_ref, WorkerStatus::Paused);
let handle = backend
.workers
.lock()
.unwrap()
.get(&detail.worker_ref)
.unwrap()
.handle
.clone();
let entries = serde_json::to_string(&handle.committed_entries()).unwrap();
assert!(!entries.contains("running-input"));
assert!(!entries.contains("paused-input"));
runtime.stop_worker(&detail.worker_ref, None).unwrap();
let error = runtime
.send_input(&detail.worker_ref, input("stopped-input"))
.unwrap_err();
assert!(error.to_string().contains("WorkerNotify"), "{error}");
}
#[test] #[test]
fn stopped_runtime_worker_can_restore_and_accept_input() { fn stopped_runtime_worker_can_restore_and_accept_input() {
let client = MockClient::new(simple_text_events()); let client = MockClient::new(simple_text_events());
+74
View File
@@ -65,6 +65,14 @@ pub struct WorkerHandle {
impl WorkerHandle { impl WorkerHandle {
pub async fn send(&self, method: Method) -> Result<(), mpsc::error::SendError<Method>> { pub async fn send(&self, method: Method) -> Result<(), mpsc::error::SendError<Method>> {
// Reject known-busy sends before channel dispatch, including maintenance
// that cannot receive methods until it finishes. The controller checks
// again at admission to cover an Idle -> busy race after this snapshot.
if self.shared_state.snapshot().state != WorkerState::Idle
&& reject_idle_only_submission(&method, &self.working_event_tx)
{
return Ok(());
}
self.method_tx.send(method).await self.method_tx.send(method).await
} }
@@ -1523,6 +1531,21 @@ where
} }
} }
fn reject_idle_only_submission(method: &Method, events: &broadcast::Sender<Event>) -> bool {
let Method::SubmitIfIdle {
submission_request_id,
..
} = method
else {
return false;
};
let _ = events.send(Event::SubmissionRejected {
submission_request_id: submission_request_id.clone(),
message: "WorkerSendInput requires an Idle Worker and never queues input. Use WorkerNotify for information about work already in progress.".to_string(),
});
true
}
fn durably_accept_method_while_busy<St>( fn durably_accept_method_while_busy<St>(
method: Method, method: Method,
pending_submissions: &PendingSubmissionHandle<St>, pending_submissions: &PendingSubmissionHandle<St>,
@@ -1531,6 +1554,9 @@ fn durably_accept_method_while_busy<St>(
where where
St: session_store::Store + Clone, St: session_store::Store + Clone,
{ {
if reject_idle_only_submission(&method, working_event_tx) {
return None;
}
match method { match method {
Method::Submit { Method::Submit {
submission_request_id, submission_request_id,
@@ -1640,6 +1666,10 @@ fn reject_method_while_attention_locked(
| Method::SubmitTracked { | Method::SubmitTracked {
submission_request_id, submission_request_id,
.. ..
}
| Method::SubmitIfIdle {
submission_request_id,
..
} => { } => {
let _ = working_event_tx.send(Event::SubmissionRejected { let _ = working_event_tx.send(Event::SubmissionRejected {
submission_request_id: submission_request_id.clone(), submission_request_id: submission_request_id.clone(),
@@ -1946,6 +1976,12 @@ async fn controller_loop<C, St>(
} }
}; };
if shared_state.snapshot().state != WorkerState::Idle
&& reject_idle_only_submission(&method, &working_event_tx)
{
continue;
}
if worker.has_pending_compaction_cleanup() { if worker.has_pending_compaction_cleanup() {
match worker.retry_pending_compaction_cleanup().await { match worker.retry_pending_compaction_cleanup().await {
Ok(()) => { Ok(()) => {
@@ -2032,6 +2068,11 @@ async fn controller_loop<C, St>(
submission_request_id, submission_request_id,
input, input,
source, source,
}
| Method::SubmitIfIdle {
submission_request_id,
input,
source,
} => { } => {
let request_id = submission_request_id.clone(); let request_id = submission_request_id.clone();
let (source_namespace, provenance) = let (source_namespace, provenance) =
@@ -3017,6 +3058,9 @@ where
} }
} }
} }
Some(method @ Method::SubmitIfIdle { .. }) => {
reject_idle_only_submission(&method, working_event_tx);
}
Some(Method::SubmitTracked { Some(Method::SubmitTracked {
submission_request_id, submission_request_id,
input, input,
@@ -3558,6 +3602,36 @@ mod tests {
tokio::time::timeout(timeout, accept).await.ok().flatten() tokio::time::timeout(timeout, accept).await.ok().flatten()
} }
#[tokio::test]
async fn idle_only_input_is_rejected_in_busy_maintenance_admission() {
let env = make_env().await;
let mut events = env.working_event_tx.subscribe();
let method = Method::SubmitIfIdle {
submission_request_id: "maintenance-input".to_string(),
input: vec![protocol::Segment::text("must not queue")],
source: protocol::AuthenticatedInputSource::Backend {
operation_id: "maintenance-input".to_string(),
},
};
assert!(
durably_accept_method_while_busy(
method,
&env.pending_submissions,
&env.working_event_tx
)
.is_none()
);
let event = events.try_recv().unwrap();
assert!(
matches!(event, Event::SubmissionRejected { submission_request_id, message }
if submission_request_id == "maintenance-input" && message.contains("WorkerNotify"))
);
assert!(
events.try_recv().is_err(),
"must not emit durable acceptance or queue changes"
);
}
#[tokio::test] #[tokio::test]
async fn parent_originated_finished_fires_turn_ended() { async fn parent_originated_finished_fires_turn_ended() {
let mut env = make_env().await; let mut env = make_env().await;
@@ -245,11 +245,11 @@ impl WorkerControlService for WorkspaceWorkerControlService {
"unknown Worker or permission not granted".to_string(), "unknown Worker or permission not granted".to_string(),
) )
})?; })?;
record record.session.send(content).await.map_err(|error| {
.session WorkspaceClientError::Request(format!(
.send(content) "WorkerSendInput requires an Idle SubWorker and never queues input: {error}"
.await ))
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?; })?;
Ok(WorkspaceResponse { Ok(WorkspaceResponse {
status: 200, status: 200,
body: serde_json::json!({ body: serde_json::json!({
@@ -855,7 +855,7 @@ impl WorkerOperation {
"Spawn a Backend/Runtime Worker session in one existing Workspace Workdir. The model-facing input remains singular and the shared lifecycle wraps it as the canonical attachment collection under the stable `workdir` alias. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. After the Orchestrator has committed a Ticket to `inprogress`, set `ticket_id` with a Flow segment in `initial_submit` to atomically assign the new Coder Worker; the operation id is derived from the durable tool call rather than model input." "Spawn a Backend/Runtime Worker session in one existing Workspace Workdir. The model-facing input remains singular and the shared lifecycle wraps it as the canonical attachment collection under the stable `workdir` alias. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. After the Orchestrator has committed a Ticket to `inprogress`, set `ticket_id` with a Flow segment in `initial_submit` to atomically assign the new Coder Worker; the operation id is derived from the durable tool call rather than model input."
} }
Self::SendInput => { Self::SendInput => {
"Send user input to a known Runtime Worker. Idle Workers can start a fresh turn; Running or Paused Workers durably queue it for a later turn. Use WorkerNotify for advisory information during work already in progress. Direct SubWorkers require Idle." "Start a fresh turn for an Idle Worker. Non-Idle Workers reject WorkerSendInput without queueing. Use WorkerNotify for advisory information during work already in progress. Direct SubWorkers also require Idle."
} }
Self::Notify => { Self::Notify => {
"Send an advisory notification to a known Runtime Worker so information can be incorporated into work already in progress without creating a queued Submit." "Send an advisory notification to a known Runtime Worker so information can be incorporated into work already in progress without creating a queued Submit."
@@ -1536,6 +1536,17 @@ mod tests {
assert_eq!(client.requests.lock().unwrap().len(), 1); assert_eq!(client.requests.lock().unwrap().len(), 1);
} }
#[test]
fn send_input_rejection_is_a_tool_error_with_notify_guidance() {
let error = tool_output(WorkerOperation::SendInput, WorkspaceResponse {
status: 400,
body: "WorkerSendInput requires an Idle Worker. Use WorkerNotify for work already in progress.".to_string(),
}).unwrap_err();
assert!(matches!(error, ToolError::ExecutionFailed(_)));
assert!(error.to_string().contains("WorkerNotify"));
assert!(!error.to_string().contains("completed"));
}
#[tokio::test] #[tokio::test]
async fn control_service_routes_by_subject_without_cross_provider_fallback() { async fn control_service_routes_by_subject_without_cross_provider_fallback() {
let client = Arc::new(RecordingWorkspaceClient::default()); let client = Arc::new(RecordingWorkspaceClient::default());
+2 -2
View File
@@ -937,8 +937,8 @@ mod tests {
"missing canonical tool {name}" "missing canonical tool {name}"
); );
} }
assert!(orchestration.contains("durably queue it for a later turn")); assert!(orchestration.contains("requires an Idle Worker and never queues input"));
assert!(orchestration.contains("Direct SubWorkers still require Idle")); assert!(orchestration.contains("Direct SubWorkers also require Idle"));
assert!(orchestration.contains("use `WorkerNotify` instead")); assert!(orchestration.contains("use `WorkerNotify` instead"));
assert!(orchestration.contains("do not create a queued Submit")); assert!(orchestration.contains("do not create a queued Submit"));
for alias in ["SubWorkerList", "SubWorkerSend", "SubWorkerStop"] { for alias in ["SubWorkerList", "SubWorkerSend", "SubWorkerStop"] {
+91
View File
@@ -2895,6 +2895,97 @@ async fn drain_until<F: FnMut(&Event) -> bool>(
} }
} }
fn idle_only_input(request_id: &str) -> Method {
Method::SubmitIfIdle {
submission_request_id: request_id.to_string(),
input: vec![protocol::Segment::text(request_id)],
source: protocol::AuthenticatedInputSource::Backend {
operation_id: request_id.to_string(),
},
}
}
#[tokio::test]
async fn idle_only_submit_rejects_admission_race_and_paused_without_persisting() {
let client = MockClient::sequential(vec![MockResponse::Hang(vec![
LlmEvent::text_block_start(0),
LlmEvent::text_delta(0, "partial"),
])]);
let handle = spawn_controller(make_worker(client.clone()).await).await;
let mut rx = handle.subscribe();
assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Idle);
// Both callers can observe Idle. Admission of the first request must make
// the second fail rather than silently scheduling a later turn.
handle.send(idle_only_input("first-idle")).await.unwrap();
handle.send(idle_only_input("racing-input")).await.unwrap();
let mut accepted_first = false;
assert!(
drain_until(&mut rx, std::time::Duration::from_secs(2), |event| {
match event {
Event::SubmissionAccepted {
submission_request_id,
..
} => {
assert_eq!(submission_request_id, "first-idle");
accepted_first = true;
false
}
Event::SubmissionRejected {
submission_request_id,
message,
} => {
assert_eq!(submission_request_id, "racing-input");
assert!(message.contains("WorkerNotify"));
true
}
_ => false,
}
})
.await
);
assert!(accepted_first);
wait_for_status(&handle, WorkerStatus::Running).await;
handle
.send(Method::Pause {
command: worker_command(&handle),
})
.await
.unwrap();
wait_for_status(&handle, WorkerStatus::Paused).await;
handle.send(idle_only_input("paused-input")).await.unwrap();
assert!(
drain_until(&mut rx, std::time::Duration::from_secs(2), |event| {
matches!(event, Event::SubmissionRejected { submission_request_id, message }
if submission_request_id == "paused-input" && message.contains("WorkerNotify"))
})
.await
);
assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Paused);
handle.send(Method::ListPendingSubmissions).await.unwrap();
assert!(
drain_until(&mut rx, std::time::Duration::from_secs(2), |event| {
if let Event::PendingSubmissionsChanged { pending } = event {
assert!(pending.submissions.is_empty());
true
} else {
false
}
})
.await
);
assert_eq!(client.captured_requests().len(), 1);
let entries = serde_json::to_string(&handle.committed_entries()).unwrap();
assert!(!entries.contains("racing-input"));
assert!(!entries.contains("paused-input"));
handle
.send(Method::Cancel {
command: worker_command(&handle),
})
.await
.unwrap();
wait_for_status(&handle, WorkerStatus::Idle).await;
}
/// Paused → Running → Idle. A notification accepted while Paused must not /// Paused → Running → Idle. A notification accepted while Paused must not
/// auto-resume the Worker, and explicit Resume must inject it while preserving /// auto-resume the Worker, and explicit Resume must inject it while preserving
/// the interrupted turn's history consistency. /// the interrupted turn's history consistency.
+4
View File
@@ -696,6 +696,8 @@ pub struct WorkerLifecycleResult {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum WorkerInputKind { pub enum WorkerInputKind {
User, User,
/// Agent input that must never enter the human Submit queue.
UserIfIdle,
Notify, Notify,
Compact, Compact,
ListRewindTargets, ListRewindTargets,
@@ -3037,6 +3039,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
let input = EmbeddedWorkerInput { let input = EmbeddedWorkerInput {
kind: match request.kind { kind: match request.kind {
WorkerInputKind::User => EmbeddedWorkerInputKind::User, WorkerInputKind::User => EmbeddedWorkerInputKind::User,
WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle,
WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify, WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify,
WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact, WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact,
WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets, WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets,
@@ -5028,6 +5031,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
let input = EmbeddedWorkerInput { let input = EmbeddedWorkerInput {
kind: match request.kind { kind: match request.kind {
WorkerInputKind::User => EmbeddedWorkerInputKind::User, WorkerInputKind::User => EmbeddedWorkerInputKind::User,
WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle,
WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify, WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify,
WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact, WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact,
WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets, WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets,
+131 -2
View File
@@ -17659,7 +17659,30 @@ async fn send_known_worker_input(
&path.worker, &path.worker,
permission, permission,
)?; )?;
scoped_send_runtime_worker_input(State(api), AxumPath(path), Json(request)).await let mut request = worker_input_request_from_api(request)?;
let idle_only = request.kind == WorkerInputKind::User;
if idle_only {
request.kind = WorkerInputKind::UserIfIdle;
}
let Json(result) =
dispatch_runtime_worker_input(api, path.worker.runtime_id, path.worker.worker_id, request)?;
if result.state != server_api::WorkerOperationState::Accepted {
let detail = result
.diagnostics
.iter()
.map(|diagnostic| diagnostic.message.as_str())
.collect::<Vec<_>>()
.join("; ");
let message = if idle_only {
format!(
"WorkerSendInput requires an Idle Worker and never queues input. Use WorkerNotify for information about work already in progress. {detail}"
)
} else {
detail
};
return Err(Error::InvalidInput(message).into());
}
Ok(Json(result))
} }
async fn cancel_known_worker( async fn cancel_known_worker(
@@ -24426,6 +24449,15 @@ async fn send_runtime_worker_input(
Json(request): Json<server_api::RuntimeWorkerInputRequest>, Json(request): Json<server_api::RuntimeWorkerInputRequest>,
) -> ApiResult<Json<server_api::RuntimeWorkerInputResult>> { ) -> ApiResult<Json<server_api::RuntimeWorkerInputResult>> {
let request = worker_input_request_from_api(request)?; let request = worker_input_request_from_api(request)?;
dispatch_runtime_worker_input(api, runtime_id, worker_id, request)
}
fn dispatch_runtime_worker_input(
api: WorkspaceApi,
runtime_id: String,
worker_id: String,
request: WorkerInputRequest,
) -> ApiResult<Json<server_api::RuntimeWorkerInputResult>> {
let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?;
if api if api
.store .store
@@ -24535,7 +24567,9 @@ pub(crate) fn authorize_browser_worker_method(
message, message,
source: source.clone(), source: source.clone(),
}), }),
protocol::Method::SubmitTracked { .. } | protocol::Method::NotifyTracked { .. } => { protocol::Method::SubmitTracked { .. }
| protocol::Method::SubmitIfIdle { .. }
| protocol::Method::NotifyTracked { .. } => {
Err("authenticated Worker input source is server-owned") Err("authenticated Worker input source is server-owned")
} }
other => Ok(other), other => Ok(other),
@@ -29862,6 +29896,7 @@ mod tests {
#[derive(Clone, Default)] #[derive(Clone, Default)]
struct WorkdirlessFixtureRuntime { struct WorkdirlessFixtureRuntime {
input_requests: Arc<Mutex<Vec<WorkerInputRequest>>>,
workers: Arc<Mutex<Vec<InternalWorkerSummary>>>, workers: Arc<Mutex<Vec<InternalWorkerSummary>>>,
spawn_requests: Arc<Mutex<Vec<WorkerSpawnRequest>>>, spawn_requests: Arc<Mutex<Vec<WorkerSpawnRequest>>>,
reject_next_spawn: Arc<Mutex<bool>>, reject_next_spawn: Arc<Mutex<bool>>,
@@ -29988,6 +30023,30 @@ mod tests {
} }
impl crate::hosts::WorkspaceWorkerRuntime for WorkdirlessFixtureRuntime { impl crate::hosts::WorkspaceWorkerRuntime for WorkdirlessFixtureRuntime {
fn send_input(
&self,
worker_id: &str,
request: WorkerInputRequest,
) -> crate::hosts::WorkerInputResult {
let idle_only = request.kind == WorkerInputKind::UserIfIdle;
self.input_requests.lock().unwrap().push(request);
crate::hosts::WorkerInputResult {
state: if idle_only {
InternalWorkerOperationState::Rejected
} else {
InternalWorkerOperationState::Accepted
},
disposition: if idle_only {
crate::hosts::WorkerInputDisposition::Rejected
} else {
crate::hosts::WorkerInputDisposition::Accepted
},
worker: RuntimeWorkerRef::new(Self::RUNTIME_ID, worker_id),
runtime_run_id: None,
diagnostics: Vec::new(),
}
}
fn runtime_id(&self) -> &str { fn runtime_id(&self) -> &str {
Self::RUNTIME_ID Self::RUNTIME_ID
} }
@@ -33327,6 +33386,76 @@ mod tests {
assert_eq!(current.assignment_id, "existing-coder-assignment"); assert_eq!(current.assignment_id, "existing-coder-assignment");
} }
#[tokio::test]
async fn worker_control_input_is_idle_only_and_returns_rejection_as_http_error() {
let fixture = guarded_spawn_fixture().await;
let response = post_guarded_spawn(
&fixture,
guarded_spawn_payload(&fixture, "idle-only-control"),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let created: BrowserCreateWorkerResponse = serde_json::from_slice(&bytes).unwrap();
let mut headers = HeaderMap::new();
headers.insert(
"x-yoi-runtime-id",
fixture.controller.runtime_id.parse().unwrap(),
);
headers.insert(
"x-yoi-worker-id",
fixture.controller.worker_id.parse().unwrap(),
);
let path = || ScopedRuntimeWorkerPath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
worker: RuntimeWorkerRef::new(&created.runtime_id, &created.worker_id),
};
let request = |kind: &str| server_api::RuntimeWorkerInputRequest {
kind: Some(kind.to_string()),
content: "follow-up".to_string(),
segments: None,
};
let error = send_known_worker_input(
State(fixture.api.clone()),
AxumPath(path()),
headers.clone(),
Json(request("user")),
)
.await
.unwrap_err();
let response = error.into_response();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert!(String::from_utf8_lossy(&bytes).contains("WorkerNotify"));
// Machine Notify is unchanged; the human endpoint must still use User.
let Json(notify) = send_known_worker_input(
State(fixture.api.clone()),
AxumPath(path()),
headers,
Json(request("notify")),
)
.await
.unwrap();
assert_eq!(notify.state, server_api::WorkerOperationState::Accepted);
let Json(human) = scoped_send_runtime_worker_input(
State(fixture.api.clone()),
AxumPath(path()),
Json(request("user")),
)
.await
.unwrap();
assert_eq!(human.state, server_api::WorkerOperationState::Accepted);
let requests = fixture.runtime.input_requests.lock().unwrap();
assert_eq!(
requests.iter().map(|r| r.kind.clone()).collect::<Vec<_>>(),
vec![
WorkerInputKind::UserIfIdle,
WorkerInputKind::Notify,
WorkerInputKind::User
]
);
}
#[tokio::test] #[tokio::test]
async fn worker_control_spawn_retry_converges_on_one_worker_and_one_grant() { async fn worker_control_spawn_retry_converges_on_one_worker_and_one_grant() {
let workspace = tempfile::tempdir().unwrap(); let workspace = tempfile::tempdir().unwrap();
@@ -4,7 +4,7 @@
When SubWorker-management tools are available, create direct children with `SubWorkerSpawn`, discover them with `WorkerList`, continue them with `WorkerSendInput`, and release their delegated authority with `WorkerStop`. Pass the exact `{ kind: "sub_worker", name }` subject returned by `WorkerList`; do not invent direct-only aliases. SubWorker notifications are background signals for the parent Worker to handle at a natural stopping point. Do not ignore routine follow-up, but do not interrupt the current user request unnecessarily. When SubWorker-management tools are available, create direct children with `SubWorkerSpawn`, discover them with `WorkerList`, continue them with `WorkerSendInput`, and release their delegated authority with `WorkerStop`. Pass the exact `{ kind: "sub_worker", name }` subject returned by `WorkerList`; do not invent direct-only aliases. SubWorker notifications are background signals for the parent Worker to handle at a natural stopping point. Do not ignore routine follow-up, but do not interrupt the current user request unnecessarily.
For Runtime Workers, `WorkerSendInput` submits user input: Idle Workers can start a fresh turn, while Running or Paused Workers durably queue it for a later turn. Queueing does not implicitly resume a paused run; Cancel/error retains pending input until explicitly continued. Direct SubWorkers still require Idle for `WorkerSendInput`. When `WorkerNotify` is available and information should be incorporated into work already in progress, use `WorkerNotify` instead; notifications are advisory and do not create a queued Submit. `WorkerSendInput` requires an Idle Worker and never queues input. Non-Idle Workers reject it; when information should be incorporated into work already in progress, use `WorkerNotify` instead. Notifications are advisory and do not create a queued Submit. Direct SubWorkers also require Idle for `WorkerSendInput`; do not send follow-up input until they are Idle. The Submit queue is for human-controlled Web/TUI interaction, not Orchestrator scheduling. Do not retry SendInput in a polling loop or cancel/pause work to bypass this restriction.
The parent Worker does not need to keep a turn open or call tools solely to wait for a notification. Do not use `sleep` or polling loops just to wait for SubWorker output; if there is no useful immediate work, return control and handle the SubWorker when notified or when the user next asks. The parent Worker does not need to keep a turn open or call tools solely to wait for a notification. Do not use `sleep` or polling loops just to wait for SubWorker output; if there is no useful immediate work, return control and handle the SubWorker when notified or when the user next asks.