worker: support typed initial submit on spawn

This commit is contained in:
2026-08-09 23:19:29 +09:00
parent 068ba9afa5
commit b9dadb6a08
13 changed files with 367 additions and 94 deletions
Generated
+1
View File
@@ -3056,6 +3056,7 @@ version = "0.1.0"
name = "protocol" name = "protocol"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"schemars",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
+2
View File
@@ -8,8 +8,10 @@ license.workspace = true
default = ["stream"] default = ["stream"]
stream = ["dep:tokio"] stream = ["dep:tokio"]
typescript = ["dep:ts-rs"] typescript = ["dep:ts-rs"]
json-schema = ["dep:schemars"]
[dependencies] [dependencies]
schemars = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
tokio = { workspace = true, features = ["io-util"], optional = true } tokio = { workspace = true, features = ["io-util"], optional = true }
+1
View File
@@ -183,6 +183,7 @@ impl WorkerEvent {
/// the dropped intent. /// the dropped intent.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum Segment { pub enum Segment {
/// Free-form text. The fallback every client can produce. /// Free-form text. The fallback every client can produce.
+37
View File
@@ -3427,6 +3427,43 @@ mod tests {
); );
} }
#[test]
fn restore_does_not_redispatch_spawn_initial_submit() {
let backend = Arc::new(TestExecutionBackend::default());
let runtime = Runtime::with_execution_backend(
RuntimeOptions {
..RuntimeOptions::default()
},
backend.clone(),
)
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let mut request = task_request("flow restore");
request.initial_input = Some(WorkerInput {
kind: WorkerInputKind::User,
content: String::new(),
segments: Some(vec![
protocol::Segment::Flow {
selector: "builtin:coder-review".to_string(),
},
protocol::Segment::text("Implement Ticket 00001"),
]),
});
let detail = runtime.create_worker(request).unwrap();
assert_eq!(backend.dispatched_inputs.lock().unwrap().len(), 1);
runtime
.stop_worker(&detail.worker_ref, Some("restore test".to_string()))
.unwrap();
runtime.restore_worker(&detail.worker_ref).unwrap();
assert_eq!(
backend.dispatched_inputs.lock().unwrap().len(),
1,
"restore must continue durable Worker state without replaying spawn initial input"
);
}
#[test] #[test]
fn send_input_dispatches_segment_only_flow_submission() { fn send_input_dispatches_segment_only_flow_submission() {
let backend = Arc::new(TestExecutionBackend::default()); let backend = Arc::new(TestExecutionBackend::default());
+1 -1
View File
@@ -16,7 +16,7 @@ session-store = { workspace = true }
secrets = { workspace = true } secrets = { workspace = true }
manifest = { workspace = true } manifest = { workspace = true }
mcp = { workspace = true } mcp = { workspace = true }
protocol = { workspace = true } protocol = { workspace = true, features = ["json-schema"] }
client = { workspace = true } client = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
@@ -9,6 +9,8 @@ use llm_engine::tool::{
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use protocol::Segment;
use crate::feature::{ use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution, FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration, ToolDeclaration,
@@ -96,8 +98,10 @@ struct WorkerSpawnInput {
profile: String, profile: String,
#[serde(default)] #[serde(default)]
display_name: Option<String>, display_name: Option<String>,
/// Normal typed initial user submission delivered after spawn. An empty
/// vector starts the Worker without initial input.
#[serde(default)] #[serde(default)]
initial_text: Option<String>, initial_submit: Vec<Segment>,
#[serde(default)] #[serde(default)]
relative_cwd: Option<String>, relative_cwd: Option<String>,
} }
@@ -107,7 +111,7 @@ struct WorkerSpawnRequest {
runtime_id: String, runtime_id: String,
display_name: String, display_name: String,
profile: String, profile: String,
initial_text: String, initial_submit: Vec<Segment>,
working_directory: WorkerWorkingDirectorySelection, working_directory: WorkerWorkingDirectorySelection,
} }
@@ -197,7 +201,7 @@ impl Tool for WorkspaceWorkerTool {
.filter(|value| !value.trim().is_empty()) .filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "Workspace Worker".to_string()), .unwrap_or_else(|| "Workspace Worker".to_string()),
profile: non_empty(input.profile, "profile")?, profile: non_empty(input.profile, "profile")?,
initial_text: input.initial_text.unwrap_or_default(), initial_submit: input.initial_submit,
working_directory: WorkerWorkingDirectorySelection { working_directory: WorkerWorkingDirectorySelection {
working_directory_id: authority_id( working_directory_id: authority_id(
&input.working_directory_id, &input.working_directory_id,
@@ -320,7 +324,78 @@ fn validate_relative_cwd(value: &str) -> Result<String, ToolError> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::Mutex;
use super::*; use super::*;
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
#[derive(Debug, Default)]
struct RecordingWorkspaceClient {
requests: Mutex<Vec<WorkspaceRequest>>,
}
impl WorkspaceClient for RecordingWorkspaceClient {
fn workspace_id(&self) -> Option<&str> {
Some("workspace/test")
}
fn kind(&self) -> &str {
"recording"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.requests.lock().unwrap().push(request);
Ok(WorkspaceResponse {
status: 200,
body: "{}".to_string(),
})
}
}
#[tokio::test]
async fn worker_spawn_forwards_typed_initial_submit_to_workspace_api() {
let client = Arc::new(RecordingWorkspaceClient::default());
let tool = WorkspaceWorkerTool {
operation: WorkerOperation::Spawn,
client: client.clone(),
workspace_id: "workspace%2Ftest".to_string(),
};
tool.execute(
&serde_json::json!({
"runtime_id": "runtime-1",
"working_directory_id": "workdir-1",
"profile": "builtin:coder",
"initial_submit": [
{ "kind": "flow", "selector": "builtin:coder-review" },
{ "kind": "text", "content": "Implement Ticket 00001" }
]
})
.to_string(),
ToolExecutionContext::direct(),
)
.await
.unwrap();
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].path, "/api/w/workspace%2Ftest/workers");
let body: serde_json::Value =
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["initial_submit"][0]["kind"], "flow");
assert_eq!(
body["initial_submit"][0]["selector"],
"builtin:coder-review"
);
assert_eq!(body["initial_submit"][1]["kind"], "text");
assert!(body.get("initial_text").is_none());
}
#[test] #[test]
fn worker_tool_family_is_distinct_from_sub_worker_tools() { fn worker_tool_family_is_distinct_from_sub_worker_tools() {
@@ -330,13 +405,28 @@ mod tests {
); );
} }
#[test]
fn worker_spawn_schema_exposes_normal_typed_segment_variants() {
let schema = serde_json::to_value(schemars::schema_for!(WorkerSpawnInput)).unwrap();
let text = serde_json::to_string(&schema).unwrap();
assert!(text.contains("initial_submit"));
assert!(text.contains("selector"));
assert!(text.contains("flow"));
assert!(!text.contains("initial_text"));
}
#[test] #[test]
fn worker_spawn_request_uses_authority_ids_without_runtime_paths() { fn worker_spawn_request_uses_authority_ids_without_runtime_paths() {
let request = WorkerSpawnRequest { let request = WorkerSpawnRequest {
runtime_id: "runtime-1".to_string(), runtime_id: "runtime-1".to_string(),
display_name: "Coder".to_string(), display_name: "Coder".to_string(),
profile: "builtin:coder".to_string(), profile: "builtin:coder".to_string(),
initial_text: "Implement the Ticket".to_string(), initial_submit: vec![
Segment::Flow {
selector: "builtin:coder-review".to_string(),
},
Segment::text("Implement the Ticket"),
],
working_directory: WorkerWorkingDirectorySelection { working_directory: WorkerWorkingDirectorySelection {
working_directory_id: "wd-1".to_string(), working_directory_id: "wd-1".to_string(),
relative_cwd: Some("repo".to_string()), relative_cwd: Some("repo".to_string()),
@@ -348,6 +438,13 @@ mod tests {
assert!(value.get("cwd").is_none()); assert!(value.get("cwd").is_none());
assert!(value.get("runtime_url").is_none()); assert!(value.get("runtime_url").is_none());
assert!(value["working_directory"].get("mode").is_none()); assert!(value["working_directory"].get("mode").is_none());
assert_eq!(value["initial_submit"][0]["kind"], "flow");
assert_eq!(
value["initial_submit"][0]["selector"],
"builtin:coder-review"
);
assert_eq!(value["initial_submit"][1]["kind"], "text");
assert!(value.get("initial_text").is_none());
} }
#[test] #[test]
+83 -28
View File
@@ -1,6 +1,7 @@
use crate::Error; use crate::Error;
use crate::resource_broker::{BackendResourceBroker, BackendResourceTarget}; use crate::resource_broker::{BackendResourceBroker, BackendResourceTarget};
use chrono::Utc; use chrono::Utc;
use protocol::Segment;
use reqwest::blocking::{Client as BlockingHttpClient, RequestBuilder}; use reqwest::blocking::{Client as BlockingHttpClient, RequestBuilder};
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
use reqwest::{Client as AsyncHttpClient, StatusCode, Url}; use reqwest::{Client as AsyncHttpClient, StatusCode, Url};
@@ -359,8 +360,8 @@ pub struct WorkerSpawnRequest {
pub profile: ProfileSelector, pub profile: ProfileSelector,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ticket_assignment: Option<WorkerTicketAssignmentRequest>, pub ticket_assignment: Option<WorkerTicketAssignmentRequest>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub initial_input: Option<EmbeddedWorkerInput>, pub initial_submit: Vec<Segment>,
/// Optional safe working-directory creation request. The Workspace server resolves /// Optional safe working-directory creation request. The Workspace server resolves
/// this into a runtime-internal `WorkingDirectoryRequest` from configured /// this into a runtime-internal `WorkingDirectoryRequest` from configured
/// repositories before calling a host. /// repositories before calling a host.
@@ -410,6 +411,17 @@ pub enum WorkerSpawnAcceptanceRequirement {
RunAccepted { expected_segments: usize }, RunAccepted { expected_segments: usize },
} }
fn initial_worker_input(segments: &[Segment]) -> Option<EmbeddedWorkerInput> {
if segments.is_empty() {
return None;
}
Some(EmbeddedWorkerInput {
kind: EmbeddedWorkerInputKind::User,
content: Segment::flatten_to_text(segments),
segments: Some(segments.to_vec()),
})
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerSpawnResult { pub struct WorkerSpawnResult {
pub state: WorkerOperationState, pub state: WorkerOperationState,
@@ -1141,6 +1153,30 @@ impl RuntimeRegistry {
request: WorkerSpawnRequest, request: WorkerSpawnRequest,
) -> Result<WorkerSpawnResult, RuntimeRegistryError> { ) -> Result<WorkerSpawnResult, RuntimeRegistryError> {
validate_backend_identifier("runtime_id", runtime_id)?; validate_backend_identifier("runtime_id", runtime_id)?;
match request.acceptance {
WorkerSpawnAcceptanceRequirement::RunAccepted { expected_segments }
if expected_segments != request.initial_submit.len() =>
{
return Err(RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: runtime_id.to_string(),
code: "worker_initial_segment_count_mismatch".to_string(),
message: format!(
"spawn acceptance expects {expected_segments} initial segment(s), request carries {}",
request.initial_submit.len()
),
});
}
WorkerSpawnAcceptanceRequirement::SocketReady if !request.initial_submit.is_empty() => {
return Err(RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: runtime_id.to_string(),
code: "worker_initial_submit_require_run_acceptance".to_string(),
message:
"spawn requests with initial segments must require RunAccepted acceptance"
.to_string(),
});
}
_ => {}
}
let runtime = self.runtime(runtime_id)?; let runtime = self.runtime(runtime_id)?;
Ok(runtime.spawn_worker(request)) Ok(runtime.spawn_worker(request))
} }
@@ -1865,7 +1901,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
display_name: request.requested_worker_name.clone(), display_name: request.requested_worker_name.clone(),
config_bundle: None, config_bundle: None,
profile_source, profile_source,
initial_input: request.initial_input.clone(), initial_input: initial_worker_input(&request.initial_submit),
working_directory_request: request.resolved_working_directory_request.clone(), working_directory_request: request.resolved_working_directory_request.clone(),
working_directory: request.resolved_working_directory.clone(), working_directory: request.resolved_working_directory.clone(),
worker_observation_enabled: request.resolved_worker_observation_enabled, worker_observation_enabled: request.resolved_worker_observation_enabled,
@@ -2970,7 +3006,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
display_name: request.requested_worker_name.clone(), display_name: request.requested_worker_name.clone(),
config_bundle: None, config_bundle: None,
profile_source, profile_source,
initial_input: request.initial_input.clone(), initial_input: initial_worker_input(&request.initial_submit),
working_directory_request: request.resolved_working_directory_request.clone(), working_directory_request: request.resolved_working_directory_request.clone(),
working_directory: request.resolved_working_directory.clone(), working_directory: request.resolved_working_directory.clone(),
worker_observation_enabled: request.resolved_worker_observation_enabled, worker_observation_enabled: request.resolved_worker_observation_enabled,
@@ -4551,7 +4587,7 @@ mod tests {
}, },
profile: ProfileSelector::Builtin("builtin:coder".to_string()), profile: ProfileSelector::Builtin("builtin:coder".to_string()),
ticket_assignment: None, ticket_assignment: None,
initial_input: None, initial_submit: Vec::new(),
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
resolved_working_directory: None, resolved_working_directory: None,
@@ -4601,29 +4637,48 @@ mod tests {
} }
#[test] #[test]
fn embedded_runtime_rejects_system_initial_input_without_worker_projection() { fn worker_spawn_idempotency_fingerprint_covers_canonical_initial_submit() {
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
"local:test",
Arc::new(AcceptingExecutionBackend::default()),
)
.expect("test backend should connect");
let mut request = embedded_spawn_request(); let mut request = embedded_spawn_request();
request.initial_input = Some(EmbeddedWorkerInput { request.ticket_assignment = Some(WorkerTicketAssignmentRequest {
kind: EmbeddedWorkerInputKind::Notify, ticket_id: "00001KVZSGT0Q".to_string(),
content: "system/role instruction belongs in profile".to_string(), operation_id: "operation-1".to_string(),
segments: None,
}); });
request.initial_submit = vec![
Segment::Flow {
selector: "builtin:coder-review".to_string(),
},
Segment::text("Implement Ticket 00001KVZSGT0Q"),
];
request.acceptance = WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: request.initial_submit.len(),
};
let spawned = runtime.spawn_worker(request); let first = worker_spawn_idempotency(&request).unwrap().unwrap();
assert_eq!(spawned.state, WorkerOperationState::Rejected); let repeated = worker_spawn_idempotency(&request).unwrap().unwrap();
assert!(spawned.worker.is_none()); assert_eq!(first, repeated);
assert!(spawned.diagnostics.iter().any(|diagnostic| { assert_eq!(first.0, "operation-1");
diagnostic.code == "embedded_worker_initial_input_kind_invalid"
&& diagnostic let mut changed = request.clone();
.message changed.initial_submit[1] = Segment::text("Different instruction");
.contains("initial worker input must be user input") let changed = worker_spawn_idempotency(&changed).unwrap().unwrap();
})); assert_ne!(first.1, changed.1);
assert!(runtime.list_workers(10).items.is_empty()); }
#[test]
fn shared_spawn_projects_typed_initial_submit_to_runtime_user_input() {
let segments = vec![
Segment::Flow {
selector: "builtin:coder-review".to_string(),
},
Segment::text("Implement Ticket 00001"),
];
let input = initial_worker_input(&segments).expect("typed initial input");
assert_eq!(input.kind, EmbeddedWorkerInputKind::User);
assert_eq!(input.content, Segment::flatten_to_text(&segments));
assert_eq!(input.segments, Some(segments));
assert!(initial_worker_input(&[]).is_none());
} }
#[test] #[test]
@@ -4702,7 +4757,7 @@ mod tests {
}, },
profile: ProfileSelector::Builtin("builtin:coder".to_string()), profile: ProfileSelector::Builtin("builtin:coder".to_string()),
ticket_assignment: None, ticket_assignment: None,
initial_input: None, initial_submit: Vec::new(),
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
resolved_working_directory: None, resolved_working_directory: None,
@@ -4799,7 +4854,7 @@ mod tests {
}, },
profile: ProfileSelector::Builtin("builtin:coder".to_string()), profile: ProfileSelector::Builtin("builtin:coder".to_string()),
ticket_assignment: None, ticket_assignment: None,
initial_input: None, initial_submit: Vec::new(),
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
resolved_working_directory: None, resolved_working_directory: None,
@@ -4835,7 +4890,7 @@ mod tests {
acceptance: WorkerSpawnAcceptanceRequirement::SocketReady, acceptance: WorkerSpawnAcceptanceRequirement::SocketReady,
profile: ProfileSelector::Builtin("builtin:companion".to_string()), profile: ProfileSelector::Builtin("builtin:companion".to_string()),
ticket_assignment: None, ticket_assignment: None,
initial_input: None, initial_submit: Vec::new(),
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
resolved_working_directory: None, resolved_working_directory: None,
+104 -45
View File
@@ -18,6 +18,7 @@ use memory::backend::{
MemoryBackendHttpResponse, MemoryBackendOperation, MemoryConsolidateStagingOperation, MemoryBackendHttpResponse, MemoryBackendOperation, MemoryConsolidateStagingOperation,
MemoryConsolidationOutput, MemoryConsolidationOutput,
}; };
use protocol::Segment;
use protocol::stream::{decode_method, encode_event}; use protocol::stream::{decode_method, encode_event};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -114,9 +115,6 @@ use worker_runtime::http_server::{
RuntimeHttpSummaryResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkersResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkersResponse,
}; };
use worker_runtime::identity::RuntimeWorkerRef; use worker_runtime::identity::RuntimeWorkerRef;
use worker_runtime::interaction::{
WorkerInput as EmbeddedWorkerInput, WorkerInputKind as EmbeddedWorkerInputKind,
};
const EMBEDDED_WORKER_RUNTIME_ID: &str = "embedded-worker-runtime"; const EMBEDDED_WORKER_RUNTIME_ID: &str = "embedded-worker-runtime";
@@ -1558,12 +1556,13 @@ pub struct BrowserWorkspaceOrchestratorResponse {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct BrowserCreateWorkerRequest { pub struct CreateWorkspaceWorkerRequest {
pub runtime_id: String, pub runtime_id: String,
pub display_name: String, pub display_name: String,
#[serde(default)] #[serde(default)]
pub profile: Option<String>, pub profile: Option<String>,
pub initial_text: String, #[serde(default)]
pub initial_submit: Vec<Segment>,
#[serde(default)] #[serde(default)]
pub working_directory: Option<BrowserWorkerWorkingDirectorySelection>, pub working_directory: Option<BrowserWorkerWorkingDirectorySelection>,
} }
@@ -3949,11 +3948,7 @@ fn start_memory_staging_consolidation(
&api.config.workspace_created_at, &api.config.workspace_created_at,
MEMORY_CONSOLIDATION_PROFILE, MEMORY_CONSOLIDATION_PROFILE,
)?; )?;
let input = EmbeddedWorkerInput { let initial_submit = vec![Segment::text(input_content)];
kind: EmbeddedWorkerInputKind::User,
content: input_content,
segments: None,
};
let result = api.spawn_workspace_worker( let result = api.spawn_workspace_worker(
&runtime_id, &runtime_id,
WorkerSpawnRequest { WorkerSpawnRequest {
@@ -3964,7 +3959,7 @@ fn start_memory_staging_consolidation(
}, },
profile: profile_selector, profile: profile_selector,
ticket_assignment: None, ticket_assignment: None,
initial_input: Some(input), initial_submit,
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
resolved_working_directory: None, resolved_working_directory: None,
@@ -4510,7 +4505,7 @@ async fn scoped_start_workspace_orchestrator(
}, },
profile: ProfileSelector::Builtin("builtin:orchestrator".to_string()), profile: ProfileSelector::Builtin("builtin:orchestrator".to_string()),
ticket_assignment: None, ticket_assignment: None,
initial_input: None, initial_submit: Vec::new(),
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
resolved_working_directory: None, resolved_working_directory: None,
@@ -4574,7 +4569,7 @@ fn workspace_orchestrator_is_online(worker: &WorkerSummary) -> bool {
async fn scoped_create_workspace_worker( async fn scoped_create_workspace_worker(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>, AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(request): Json<BrowserCreateWorkerRequest>, Json(request): Json<CreateWorkspaceWorkerRequest>,
) -> ApiResult<Json<BrowserCreateWorkerResponse>> { ) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?; validate_workspace_scope(&api, &path.workspace_id)?;
create_workspace_worker(State(api), Json(request)).await create_workspace_worker(State(api), Json(request)).await
@@ -6832,9 +6827,38 @@ fn configured_working_directory_request(
)) ))
} }
fn validate_worker_initial_submit(segments: &[Segment]) -> Result<()> {
let flow_selectors = segments
.iter()
.filter_map(|segment| match segment {
Segment::Flow { selector } => Some(selector),
_ => None,
})
.collect::<Vec<_>>();
if flow_selectors.len() > 1 {
return Err(Error::InvalidInput(
"initial_submit may contain at most one Flow segment".to_string(),
));
}
if let Some(selector) = flow_selectors.first() {
selector
.parse::<flow::FlowSelector>()
.map_err(|error| Error::InvalidInput(error.to_string()))?;
}
if segments
.iter()
.any(|segment| matches!(segment, Segment::Unknown))
{
return Err(Error::InvalidInput(
"initial_submit must not contain unknown segment variants".to_string(),
));
}
Ok(())
}
async fn create_workspace_worker( async fn create_workspace_worker(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
Json(request): Json<BrowserCreateWorkerRequest>, Json(request): Json<CreateWorkspaceWorkerRequest>,
) -> ApiResult<Json<BrowserCreateWorkerResponse>> { ) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
let profile = request let profile = request
.profile .profile
@@ -6883,16 +6907,9 @@ async fn create_workspace_worker(
if display_name == crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY { if display_name == crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY {
return Err(Error::ReservedWorkerName(display_name).into()); return Err(Error::ReservedWorkerName(display_name).into());
} }
let initial_text = request.initial_text.trim().to_string(); let initial_submit = request.initial_submit;
let initial_input = if initial_text.is_empty() { validate_worker_initial_submit(&initial_submit)?;
None let expected_segments = initial_submit.len();
} else {
Some(EmbeddedWorkerInput {
kind: EmbeddedWorkerInputKind::User,
content: initial_text.clone(),
segments: None,
})
};
let selected_working_directory_id = request let selected_working_directory_id = request
.working_directory .working_directory
.as_ref() .as_ref()
@@ -6914,12 +6931,10 @@ async fn create_workspace_worker(
WorkerSpawnRequest { WorkerSpawnRequest {
requested_worker_name: Some(display_name.clone()), requested_worker_name: Some(display_name.clone()),
intent: WorkerSpawnIntent::WorkspaceCoding, intent: WorkerSpawnIntent::WorkspaceCoding,
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { expected_segments },
expected_segments: if initial_input.is_some() { 1 } else { 0 },
},
profile: profile_selector, profile: profile_selector,
ticket_assignment: None, ticket_assignment: None,
initial_input, initial_submit,
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
resolved_working_directory, resolved_working_directory,
@@ -10212,6 +10227,50 @@ mod tests {
); );
} }
#[test]
fn worker_initial_submit_validate_flow_shape_before_spawn() {
let valid = vec![
Segment::Flow {
selector: "builtin:coder-review".to_string(),
},
Segment::text("Implement Ticket 00001"),
];
assert!(validate_worker_initial_submit(&valid).is_ok());
assert!(validate_worker_initial_submit(&[]).is_ok());
let duplicate = vec![
Segment::Flow {
selector: "builtin:coder-review".to_string(),
},
Segment::Flow {
selector: "workspace:coder-review".to_string(),
},
];
assert!(matches!(
validate_worker_initial_submit(&duplicate),
Err(Error::InvalidInput(message)) if message.contains("at most one")
));
assert!(matches!(
validate_worker_initial_submit(&[Segment::Flow {
selector: "coder-review".to_string(),
}]),
Err(Error::InvalidInput(_))
));
assert!(matches!(
validate_worker_initial_submit(&[Segment::Unknown]),
Err(Error::InvalidInput(message)) if message.contains("unknown")
));
assert!(
serde_json::from_value::<CreateWorkspaceWorkerRequest>(serde_json::json!({
"runtime_id": "runtime-1",
"display_name": "coder",
"initial_text": "legacy parallel authority",
"working_directory": { "kind": "without_workspace" }
}))
.is_err()
);
}
#[tokio::test] #[tokio::test]
async fn worker_source_auth_rejects_cross_workspace_mutation() { async fn worker_source_auth_rejects_cross_workspace_mutation() {
let workspace = tempfile::tempdir().unwrap(); let workspace = tempfile::tempdir().unwrap();
@@ -10219,11 +10278,11 @@ mod tests {
let api = test_api(workspace.path()).await; let api = test_api(workspace.path()).await;
let Json(created) = create_workspace_worker( let Json(created) = create_workspace_worker(
State(api.clone()), State(api.clone()),
Json(BrowserCreateWorkerRequest { Json(CreateWorkspaceWorkerRequest {
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
display_name: "Scoped Worker".to_string(), display_name: "Scoped Worker".to_string(),
profile: Some("builtin:coder".to_string()), profile: Some("builtin:coder".to_string()),
initial_text: String::new(), initial_submit: Vec::new(),
working_directory: None, working_directory: None,
}), }),
) )
@@ -10252,11 +10311,11 @@ mod tests {
let Json(generic) = create_workspace_worker( let Json(generic) = create_workspace_worker(
State(api.clone()), State(api.clone()),
Json(BrowserCreateWorkerRequest { Json(CreateWorkspaceWorkerRequest {
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
display_name: "Generic Orchestrator Profile Worker".to_string(), display_name: "Generic Orchestrator Profile Worker".to_string(),
profile: Some("builtin:orchestrator".to_string()), profile: Some("builtin:orchestrator".to_string()),
initial_text: String::new(), initial_submit: Vec::new(),
working_directory: None, working_directory: None,
}), }),
) )
@@ -10266,11 +10325,11 @@ mod tests {
assert!(find_workspace_orchestrator(&api).is_none()); assert!(find_workspace_orchestrator(&api).is_none());
let reserved = create_workspace_worker( let reserved = create_workspace_worker(
State(api.clone()), State(api.clone()),
Json(BrowserCreateWorkerRequest { Json(CreateWorkspaceWorkerRequest {
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
display_name: crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.to_string(), display_name: crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.to_string(),
profile: Some("builtin:orchestrator".to_string()), profile: Some("builtin:orchestrator".to_string()),
initial_text: String::new(), initial_submit: Vec::new(),
working_directory: None, working_directory: None,
}), }),
) )
@@ -11123,7 +11182,7 @@ mod tests {
}, },
profile: ProfileSelector::Builtin(MEMORY_CONSOLIDATION_PROFILE.to_string()), profile: ProfileSelector::Builtin(MEMORY_CONSOLIDATION_PROFILE.to_string()),
ticket_assignment: None, ticket_assignment: None,
initial_input: None, initial_submit: Vec::new(),
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
resolved_working_directory: None, resolved_working_directory: None,
@@ -11305,7 +11364,7 @@ mod tests {
}, },
profile: ProfileSelector::Builtin("builtin:coder".to_string()), profile: ProfileSelector::Builtin("builtin:coder".to_string()),
ticket_assignment: None, ticket_assignment: None,
initial_input: None, initial_submit: Vec::new(),
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
resolved_working_directory: None, resolved_working_directory: None,
@@ -11521,7 +11580,7 @@ mod tests {
}, },
profile: ProfileSelector::Builtin("builtin:coder".to_string()), profile: ProfileSelector::Builtin("builtin:coder".to_string()),
ticket_assignment: None, ticket_assignment: None,
initial_input: None, initial_submit: Vec::new(),
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
resolved_working_directory: None, resolved_working_directory: None,
@@ -11663,7 +11722,7 @@ mod tests {
ticket_id: first_ticket.id.clone(), ticket_id: first_ticket.id.clone(),
operation_id: "spawn-assignment-operation".to_string(), operation_id: "spawn-assignment-operation".to_string(),
}), }),
initial_input: None, initial_submit: Vec::new(),
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
resolved_working_directory: None, resolved_working_directory: None,
@@ -11895,7 +11954,7 @@ mod tests {
}, },
profile: ProfileSelector::Builtin("builtin:coder".to_string()), profile: ProfileSelector::Builtin("builtin:coder".to_string()),
ticket_assignment: Some(assignment.clone()), ticket_assignment: Some(assignment.clone()),
initial_input: None, initial_submit: Vec::new(),
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
resolved_working_directory: None, resolved_working_directory: None,
@@ -12812,7 +12871,7 @@ mod tests {
"runtime_id": "remote-runtime", "runtime_id": "remote-runtime",
"display_name": "Coding Worker", "display_name": "Coding Worker",
"profile": "builtin:coder", "profile": "builtin:coder",
"initial_text": "", "initial_submit": [],
"working_directory": { "working_directory": {
"working_directory_id": working_directory_id, "working_directory_id": working_directory_id,
"relative_cwd": "../escape" "relative_cwd": "../escape"
@@ -13173,7 +13232,7 @@ mod tests {
serde_json::json!({ serde_json::json!({
"runtime_id": "embedded-worker-runtime", "runtime_id": "embedded-worker-runtime",
"display_name": "", "display_name": "",
"initial_text": "" "initial_submit": []
}), }),
) )
.await; .await;
@@ -13239,7 +13298,7 @@ mod tests {
"runtime_id": "remote-runtime", "runtime_id": "remote-runtime",
"display_name": "Remote Worker", "display_name": "Remote Worker",
"profile": "builtin:companion", "profile": "builtin:companion",
"initial_text": "" "initial_submit": []
})), })),
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
) )
@@ -13352,7 +13411,7 @@ mod tests {
"runtime_id": "embedded-worker-runtime", "runtime_id": "embedded-worker-runtime",
"display_name": "Coding Worker", "display_name": "Coding Worker",
"profile": "builtin:coder", "profile": "builtin:coder",
"initial_text": "", "initial_submit": [],
"kind": "internal" "kind": "internal"
})), })),
StatusCode::UNPROCESSABLE_ENTITY, StatusCode::UNPROCESSABLE_ENTITY,
@@ -13898,7 +13957,7 @@ mod tests {
}, },
profile: ProfileSelector::Builtin("builtin:coder".to_string()), profile: ProfileSelector::Builtin("builtin:coder".to_string()),
ticket_assignment: None, ticket_assignment: None,
initial_input: None, initial_submit: Vec::new(),
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
resolved_working_directory: None, resolved_working_directory: None,
@@ -14418,7 +14477,7 @@ mod tests {
"builtin:companion".to_string(), "builtin:companion".to_string(),
), ),
ticket_assignment: None, ticket_assignment: None,
initial_input: None, initial_submit: Vec::new(),
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
resolved_working_directory: None, resolved_working_directory: None,
+2
View File
@@ -69,6 +69,8 @@ Flow invocation uses the normal Submit/Run segment vector rather than a Worker-c
Runtime accepts exactly one Flow segment only when the resolved Profile enables `feature.flow` and a Workspace client is available. The Worker asks Workspace authority only for an immutable source snapshot, creates the instance locally, replaces the Flow segment with the entered state's instructions, and commits that runtime state atomically with the remaining Submit segments before LLM execution. A Worker with an active Flow rejects the duplicate input without changing its local state or events. Runtime accepts exactly one Flow segment only when the resolved Profile enables `feature.flow` and a Workspace client is available. The Worker asks Workspace authority only for an immutable source snapshot, creates the instance locally, replaces the Flow segment with the entered state's instructions, and commits that runtime state atomically with the remaining Submit segments before LLM execution. A Worker with an active Flow rejects the duplicate input without changing its local state or events.
The generic model-facing `WorkerSpawn` accepts `initial_submit: Vec<Segment>` and routes them unchanged through the shared Workspace spawn request into Runtime `CreateWorkerRequest.initial_input`. It does not have a parallel `initial_text` or a role-specific `SpawnCoder` wrapper. Backend derives the flat content projection from the canonical segment vector, validates Flow shape before spawn, and includes the segment vector in lifecycle idempotency fingerprints. Restoring the same Worker never replays spawn initial segments.
`RequestFlowTransition` accepts only: `RequestFlowTransition` accepts only:
```json ```json
@@ -509,7 +509,7 @@ Deno.test("workspace Worker sidebar links New to the dedicated create page", asy
); );
assert( assert(
newWorkerPage.includes("worker-launch-form") && newWorkerPage.includes("worker-launch-form") &&
newWorkerPage.includes("buildBrowserCreateWorkerRequest") && newWorkerPage.includes("buildCreateWorkspaceWorkerRequest") &&
newWorkerPage.includes("/workers/launch-options"), newWorkerPage.includes("/workers/launch-options"),
"New Worker page should own launch options and creation form behavior", "New Worker page should own launch options and creation form behavior",
); );
@@ -1,5 +1,5 @@
import { import {
buildBrowserCreateWorkerRequest, buildCreateWorkspaceWorkerRequest,
defaultWorkerLaunchForm, defaultWorkerLaunchForm,
} from "./worker-launch.ts"; } from "./worker-launch.ts";
import type { WorkerLaunchOptionsResponse } from "./types.ts"; import type { WorkerLaunchOptionsResponse } from "./types.ts";
@@ -182,8 +182,8 @@ Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () =>
assertEquals(form.working_directory_selector, "work/ticket"); assertEquals(form.working_directory_selector, "work/ticket");
}); });
Deno.test("buildBrowserCreateWorkerRequest sends working_directory id and relative cwd only", () => { Deno.test("buildCreateWorkspaceWorkerRequest sends working_directory id and relative cwd only", () => {
const request = buildBrowserCreateWorkerRequest({ const request = buildCreateWorkspaceWorkerRequest({
runtime_id: "embedded", runtime_id: "embedded",
display_name: "Worker", display_name: "Worker",
profile: "builtin:coder", profile: "builtin:coder",
@@ -198,7 +198,7 @@ Deno.test("buildBrowserCreateWorkerRequest sends working_directory id and relati
runtime_id: "embedded", runtime_id: "embedded",
display_name: "Worker", display_name: "Worker",
profile: "builtin:coder", profile: "builtin:coder",
initial_text: "go", initial_submit: [{ kind: "text", content: "go" }],
working_directory: { working_directory: {
working_directory_id: "wd-1-repo", working_directory_id: "wd-1-repo",
relative_cwd: "crates/yoi", relative_cwd: "crates/yoi",
@@ -206,8 +206,23 @@ Deno.test("buildBrowserCreateWorkerRequest sends working_directory id and relati
}); });
}); });
Deno.test("buildBrowserCreateWorkerRequest omits working_directory for embedded no-workdir launches", () => { Deno.test("buildCreateWorkspaceWorkerRequest sends no initial segments for an empty draft", () => {
const request = buildBrowserCreateWorkerRequest({ const request = buildCreateWorkspaceWorkerRequest({
runtime_id: "embedded",
display_name: "Worker",
profile: "builtin:companion",
initial_text: " ",
working_directory_id: "",
working_directory_repository_id: "",
working_directory_selector: "",
relative_cwd: "",
});
assertEquals(request.initial_submit, []);
});
Deno.test("buildCreateWorkspaceWorkerRequest omits working_directory for embedded no-workdir launches", () => {
const request = buildCreateWorkspaceWorkerRequest({
runtime_id: "embedded", runtime_id: "embedded",
display_name: "Worker", display_name: "Worker",
profile: "builtin:companion", profile: "builtin:companion",
@@ -222,6 +237,6 @@ Deno.test("buildBrowserCreateWorkerRequest omits working_directory for embedded
runtime_id: "embedded", runtime_id: "embedded",
display_name: "Worker", display_name: "Worker",
profile: "builtin:companion", profile: "builtin:companion",
initial_text: "chat", initial_submit: [{ kind: "text", content: "chat" }],
}); });
}); });
@@ -1,3 +1,5 @@
import type { Segment } from "$lib/generated/protocol";
import type { import type {
BrowserWorkerWorkingDirectorySelection, BrowserWorkerWorkingDirectorySelection,
WorkerLaunchOptionsResponse, WorkerLaunchOptionsResponse,
@@ -14,11 +16,11 @@ export type WorkerLaunchFormState = {
relative_cwd: string; relative_cwd: string;
}; };
export type BrowserCreateWorkerRequest = { export type CreateWorkspaceWorkerRequest = {
runtime_id: string; runtime_id: string;
display_name: string; display_name: string;
profile: string; profile: string;
initial_text: string; initial_submit: Segment[];
working_directory?: BrowserWorkerWorkingDirectorySelection; working_directory?: BrowserWorkerWorkingDirectorySelection;
}; };
@@ -92,14 +94,16 @@ export function defaultWorkerLaunchForm(
}; };
} }
export function buildBrowserCreateWorkerRequest( export function buildCreateWorkspaceWorkerRequest(
form: WorkerLaunchFormState, form: WorkerLaunchFormState,
): BrowserCreateWorkerRequest { ): CreateWorkspaceWorkerRequest {
const request: BrowserCreateWorkerRequest = { const request: CreateWorkspaceWorkerRequest = {
runtime_id: form.runtime_id, runtime_id: form.runtime_id,
display_name: form.display_name, display_name: form.display_name,
profile: form.profile, profile: form.profile,
initial_text: form.initial_text, initial_submit: form.initial_text.trim()
? [{ kind: "text", content: form.initial_text }]
: [],
}; };
if (form.working_directory_id) { if (form.working_directory_id) {
request.working_directory = { request.working_directory = {
@@ -3,7 +3,7 @@
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { workspaceApiPath } from '$lib/workspace/api/http'; import { workspaceApiPath } from '$lib/workspace/api/http';
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision'; import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
import { buildBrowserCreateWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace/sidebar/worker-launch'; import { buildCreateWorkspaceWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace/sidebar/worker-launch';
import type { import type {
BrowserCreateWorkerResponse, BrowserCreateWorkerResponse,
BrowserWorkingDirectoryCreateResponse, BrowserWorkingDirectoryCreateResponse,
@@ -210,7 +210,7 @@
const response = await fetch(workerApiPath('/workers'), { const response = await fetch(workerApiPath('/workers'), {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify(buildBrowserCreateWorkerRequest({ body: JSON.stringify(buildCreateWorkspaceWorkerRequest({
runtime_id: runtimeId, runtime_id: runtimeId,
display_name: displayName, display_name: displayName,
profile, profile,