worker: separate subworkers from workspace workers

This commit is contained in:
2026-08-05 03:36:59 +09:00
parent 50726e4cf3
commit ba009c0a20
35 changed files with 747 additions and 261 deletions
+18 -18
View File
@@ -342,7 +342,7 @@ async fn feature_flags_default_to_core_tool_surface_only() {
assert_eq!(names, vec!["Bash", "Edit", "Glob", "Grep", "Read", "Write"]);
assert!(!names.iter().any(|name| name == "TaskCreate"));
assert!(!names.iter().any(|name| name == "WebSearch"));
assert!(!names.iter().any(|name| name == "SpawnWorker"));
assert!(!names.iter().any(|name| name == "SubWorkerSpawn"));
}
#[tokio::test]
@@ -386,7 +386,7 @@ permission = "write"
assert!(names.iter().any(|name| name == "TaskUpdate"));
assert!(names.iter().any(|name| name == "WebSearch"));
assert!(names.iter().any(|name| name == "WebFetch"));
assert!(!names.iter().any(|name| name == "SpawnWorker"));
assert!(!names.iter().any(|name| name == "SubWorkerSpawn"));
assert!(!names.iter().any(|name| name == "MemoryRead"));
}
@@ -394,34 +394,34 @@ permission = "write"
async fn project_role_tool_surfaces_keep_task_disabled_and_workers_role_scoped() {
struct Case {
role: &'static str,
workers_enabled: bool,
sub_worker_enabled: bool,
}
let cases = [
Case {
role: "orchestrator",
workers_enabled: true,
sub_worker_enabled: true,
},
Case {
role: "coder",
workers_enabled: false,
sub_worker_enabled: false,
},
Case {
role: "intake",
workers_enabled: false,
sub_worker_enabled: false,
},
Case {
role: "reviewer",
workers_enabled: false,
sub_worker_enabled: false,
},
Case {
role: "companion",
workers_enabled: false,
sub_worker_enabled: false,
},
];
for case in cases {
let delegation = if case.workers_enabled {
let delegation = if case.sub_worker_enabled {
r#"
[[delegation_scope.allow]]
target = "/tmp"
@@ -446,8 +446,8 @@ max_tokens = 100
[feature.task]
enabled = false
[feature.workers]
enabled = {workers_enabled}
[feature.sub_worker]
enabled = {sub_worker_enabled}
[[scope.allow]]
target = "./"
@@ -455,7 +455,7 @@ permission = "write"
{delegation}
"#,
role = case.role,
workers_enabled = case.workers_enabled,
sub_worker_enabled = case.sub_worker_enabled,
delegation = delegation,
);
let client = MockClient::new(simple_text_events());
@@ -474,16 +474,16 @@ permission = "write"
case.role
);
assert_eq!(
names.iter().any(|name| name == "SpawnWorker"),
case.workers_enabled,
"{} role Worker tool exposure mismatch: {names:?}",
names.iter().any(|name| name == "SubWorkerSpawn"),
case.sub_worker_enabled,
"{} role SubWorker tool exposure mismatch: {names:?}",
case.role
);
}
}
#[tokio::test]
async fn workers_feature_requires_delegation_scope() {
async fn sub_worker_feature_requires_delegation_scope() {
let manifest = r#"
[worker]
name = "worker-management-feature-test"
@@ -496,7 +496,7 @@ model_id = "test-model"
[engine]
max_tokens = 100
[feature.workers]
[feature.sub_worker]
enabled = true
[[scope.allow]]
@@ -510,7 +510,7 @@ permission = "write"
assert!(result.is_err());
let message = result.err().unwrap().to_string();
assert!(
message.contains("[feature.workers].enabled = true requires non-empty"),
message.contains("[feature.sub_worker].enabled = true requires non-empty"),
"unexpected error: {message}"
);
}
+10 -10
View File
@@ -1,4 +1,4 @@
//! Integration tests for the `SpawnWorker` tool.
//! Integration tests for the `SubWorkerSpawn` tool.
//!
//! These tests exercise the tool's worker-allocation delegation, subprocess
//! launch, socket handoff, and `spawned_workers.json` write through an injected
@@ -24,7 +24,7 @@ use tokio::net::UnixListener;
use worker::runtime::dir::{RuntimeDir, SpawnedWorkerRecord};
use worker::runtime::worker_allocation::{self, LockFileGuard};
use worker::spawn::registry::SpawnedWorkerRegistry;
use worker::spawn::tool::spawn_worker_tool_with_runtime_command;
use worker::spawn::tool::sub_worker_spawn_tool_with_runtime_command;
/// Serialises tests that mutate `YOI_RUNTIME_DIR` across the
/// thread-pooled test harness.
@@ -203,7 +203,7 @@ fn which_sh() -> String {
}
/// Tests don't exercise the model — they intercept the spawned
/// child via a mock socket — but `spawn_worker_tool` needs a value to
/// child via a mock socket — but `sub_worker_spawn_tool` needs a value to
/// embed in the overlay TOML. Any well-formed `ModelManifest` works.
fn dummy_model() -> ModelManifest {
ModelManifest {
@@ -289,7 +289,7 @@ async fn spawn_worker_launches_runtime_in_workspace_and_process_cwd() {
let received = accept_one_method(listener);
let registry = SpawnedWorkerRegistry::new(spawner_rd);
let def = spawn_worker_tool_with_runtime_command(
let def = sub_worker_spawn_tool_with_runtime_command(
"root".into(),
spawner_socket,
runtime_base,
@@ -349,7 +349,7 @@ async fn spawn_worker_omitted_cwd_preserves_spawner_cwd() {
let received = accept_one_method(listener);
let registry = SpawnedWorkerRegistry::new(spawner_rd);
let def = spawn_worker_tool_with_runtime_command(
let def = sub_worker_spawn_tool_with_runtime_command(
"root".into(),
spawner_socket,
runtime_base,
@@ -400,7 +400,7 @@ async fn spawn_worker_delegates_scope_and_sends_run() {
let registry = SpawnedWorkerRegistry::new(spawner_rd.clone());
let spawner_scope = shared_scope_for(allow_root.path());
let def = spawn_worker_tool_with_runtime_command(
let def = sub_worker_spawn_tool_with_runtime_command(
"root".into(),
spawner_socket.clone(),
runtime_base.clone(),
@@ -493,7 +493,7 @@ async fn spawn_worker_requires_explicit_delegation_even_with_direct_scope() {
assert!(direct.is_writable(&allow_root.path().join("direct.txt")));
let registry = SpawnedWorkerRegistry::new(spawner_rd.clone());
let def = spawn_worker_tool_with_runtime_command(
let def = sub_worker_spawn_tool_with_runtime_command(
"root".into(),
spawner_socket,
runtime_base,
@@ -560,7 +560,7 @@ async fn spawn_worker_rejects_child_non_recursive_scope_under_parent_non_recursi
let manifest = dummy_manifest_with_scopes(direct_scope, delegation_scope);
let registry = SpawnedWorkerRegistry::new(spawner_rd.clone());
let def = spawn_worker_tool_with_runtime_command(
let def = sub_worker_spawn_tool_with_runtime_command(
"root".into(),
spawner_socket,
runtime_base,
@@ -612,7 +612,7 @@ async fn spawn_worker_rejects_scope_outside_spawner() {
let registry = SpawnedWorkerRegistry::new(spawner_rd);
let spawner_scope = shared_scope_for(allow_root.path());
let def = spawn_worker_tool_with_runtime_command(
let def = sub_worker_spawn_tool_with_runtime_command(
"root".into(),
spawner_socket,
runtime_base,
@@ -686,7 +686,7 @@ async fn spawn_worker_rolls_back_reservation_when_socket_never_appears() {
let registry = SpawnedWorkerRegistry::new(spawner_rd);
let spawner_scope = shared_scope_for(allow_root.path());
let def = spawn_worker_tool_with_runtime_command(
let def = sub_worker_spawn_tool_with_runtime_command(
"root".into(),
spawner_socket,
runtime_base,
+22 -20
View File
@@ -1,5 +1,5 @@
//! Integration tests for the worker-comm tools (`SendToWorker`,
//! `ReadWorkerOutput`, `StopWorker`).
//! Integration tests for the worker-comm tools (`SubWorkerSend`,
//! `SubWorkerReadOutput`, `SubWorkerStop`).
//!
//! The real child Worker binary is not started. Instead each test stands
//! up a mock `UnixListener` that speaks the socket protocol directly:
@@ -25,7 +25,9 @@ use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use worker::runtime::dir::{RuntimeDir, SpawnedWorkerRecord};
use worker::runtime::worker_allocation::{self, LockFileGuard};
use worker::spawn::comm_tools::{read_worker_output_tool, send_to_worker_tool, stop_worker_tool};
use worker::spawn::comm_tools::{
sub_worker_read_output_tool, sub_worker_send_tool, sub_worker_stop_tool,
};
use worker::spawn::registry::SpawnedWorkerRegistry;
/// Serialises env-mutating tests. The test harness runs tasks across
@@ -148,7 +150,7 @@ fn accept_one_method(listener: UnixListener) -> JoinHandle<Option<Method>> {
}
/// Accept one connection, send the protocol's connect-time snapshot,
/// read one `Method`, then write `response` back. Used by `SendToWorker`
/// read one `Method`, then write `response` back. Used by `SubWorkerSend`
/// tests to mock the real controller's `TurnStart` acknowledgement (or
/// its `AlreadyRunning` rejection).
fn accept_method_and_respond(
@@ -171,7 +173,7 @@ fn accept_method_and_respond(
/// Pretend to be a spawned Worker whose connect-time snapshot carries a
/// fixed set of assistant items. Sends `Event::Snapshot` immediately on
/// every accept — the real Worker does the same, so `ReadWorkerOutput`'s
/// every accept — the real Worker does the same, so `SubWorkerReadOutput`'s
/// `fetch_history` just consumes the first non-Alert event.
fn serve_history(listener: UnixListener, items: Vec<Item>) -> JoinHandle<()> {
tokio::spawn(async move {
@@ -249,7 +251,7 @@ fn assistant(text: &str) -> Item {
}
// ---------------------------------------------------------------------------
// SendToWorker
// SubWorkerSend
// ---------------------------------------------------------------------------
#[tokio::test]
@@ -257,11 +259,11 @@ async fn send_to_worker_delivers_run_method() {
let (tmp, registry, _rd) = setup_registry().await;
let (socket, listener) = bind_mock_socket(tmp.path(), "child").await;
// Mock the controller's accept path: after reading the method,
// ack with `TurnStart` so `SendToWorker`'s confirmation loop succeeds.
// ack with `TurnStart` so `SubWorkerSend`'s confirmation loop succeeds.
let received = accept_method_and_respond(listener, Event::TurnStart { turn: 1 });
register_child(&registry, "child", &socket, tmp.path()).await;
let def = send_to_worker_tool(registry);
let def = sub_worker_send_tool(registry);
let (_meta, tool) = def();
let input = json!({ "name": "child", "message": "hello there" }).to_string();
let output: ToolOutput = tool.execute(&input, Default::default()).await.unwrap();
@@ -284,7 +286,7 @@ async fn send_to_worker_delivers_run_method() {
#[tokio::test]
async fn send_to_worker_errors_on_unknown_worker() {
let (_tmp, registry, _rd) = setup_registry().await;
let def = send_to_worker_tool(registry);
let def = sub_worker_send_tool(registry);
let (_meta, tool) = def();
let input = json!({ "name": "nope", "message": "hi" }).to_string();
let err = tool.execute(&input, Default::default()).await.unwrap_err();
@@ -306,7 +308,7 @@ async fn send_to_worker_errors_when_worker_already_running() {
);
register_child(&registry, "child", &socket, tmp.path()).await;
let def = send_to_worker_tool(registry);
let def = sub_worker_send_tool(registry);
let (_meta, tool) = def();
let input = json!({ "name": "child", "message": "hi" }).to_string();
let err = tool.execute(&input, Default::default()).await.unwrap_err();
@@ -323,7 +325,7 @@ async fn send_to_worker_errors_when_worker_already_running() {
}
// ---------------------------------------------------------------------------
// ReadWorkerOutput
// SubWorkerReadOutput
// ---------------------------------------------------------------------------
#[tokio::test]
@@ -339,7 +341,7 @@ async fn read_worker_output_returns_new_assistant_text_then_empty_on_second_call
];
let _server = serve_history(listener, items);
let def = read_worker_output_tool(registry);
let def = sub_worker_read_output_tool(registry);
let (_meta, tool) = def();
let input = json!({ "name": "child" }).to_string();
@@ -370,7 +372,7 @@ async fn read_worker_output_reports_stopped_on_dead_socket() {
let dead_socket = tmp.path().join("dead.sock");
register_child(&registry, "child", &dead_socket, tmp.path()).await;
let def = read_worker_output_tool(registry);
let def = sub_worker_read_output_tool(registry);
let (_meta, tool) = def();
let input = json!({ "name": "child" }).to_string();
let output: ToolOutput = tool.execute(&input, Default::default()).await.unwrap();
@@ -378,7 +380,7 @@ async fn read_worker_output_reports_stopped_on_dead_socket() {
}
// ---------------------------------------------------------------------------
// StopWorker
// SubWorkerStop
// ---------------------------------------------------------------------------
#[tokio::test]
@@ -408,7 +410,7 @@ async fn stop_worker_sends_shutdown_and_releases_scope() {
// Seed workers.json with a restored top-level `spawner` allocation whose
// scope_deny contains the delegated child path plus the live child
// allocation — mimics a parent resumed after SpawnWorker.
// allocation — mimics a parent resumed after SubWorkerSpawn.
{
let mut g = LockFileGuard::open(&lock_path).unwrap();
let rule = ScopeRule {
@@ -451,7 +453,7 @@ async fn stop_worker_sends_shutdown_and_releases_scope() {
let received = accept_one_method(listener);
register_child(&registry, "child", &socket, tmp.path()).await;
let def = stop_worker_tool(registry.clone());
let def = sub_worker_stop_tool(registry.clone());
let (_meta, tool) = def();
let input = json!({ "name": "child" }).to_string();
let output: ToolOutput = tool.execute(&input, Default::default()).await.unwrap();
@@ -492,11 +494,11 @@ async fn stop_worker_succeeds_even_when_child_unreachable() {
}
// No live listener — socket never bound. Registered record points
// at a dead path. StopWorker should still clean up local bookkeeping.
// at a dead path. SubWorkerStop should still clean up local bookkeeping.
let dead_socket = tmp.path().join("dead.sock");
register_child(&registry, "child", &dead_socket, tmp.path()).await;
let def = stop_worker_tool(registry.clone());
let def = sub_worker_stop_tool(registry.clone());
let (_meta, tool) = def();
let input = json!({ "name": "child" }).to_string();
let output: ToolOutput = tool.execute(&input, Default::default()).await.unwrap();
@@ -550,7 +552,7 @@ async fn restored_registry_uses_worker_state_without_runtime_file() {
.await
.unwrap();
let def = send_to_worker_tool(restored.clone());
let def = sub_worker_send_tool(restored.clone());
let (_meta, tool) = def();
let input = json!({ "name": "child", "message": "after restart" }).to_string();
tool.execute(&input, Default::default()).await.unwrap();
@@ -562,7 +564,7 @@ async fn restored_registry_uses_worker_state_without_runtime_file() {
other => panic!("expected Run, got {other:?}"),
}
let def = stop_worker_tool(restored.clone());
let def = sub_worker_stop_tool(restored.clone());
let (_meta, tool) = def();
tool.execute(&json!({ "name": "child" }).to_string(), Default::default())
.await