1955 lines
72 KiB
Rust
1955 lines
72 KiB
Rust
//! `SubWorkerSpawn` tool — start a parent-owned Internal Worker session.
|
|
//!
|
|
//! Resolves a child profile, validates filesystem delegation, constructs a normal Worker with the
|
|
//! parent's explicit Workspace authority, installs its enabled features, and hands it to the
|
|
//! in-process Internal Worker session actor. No Runtime Worker record, OS process, PID, Unix socket,
|
|
//! or machine-wide child allocation is created.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
use arc_swap::ArcSwap;
|
|
use async_trait::async_trait;
|
|
use fs_operation::FsPath;
|
|
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
|
use manifest::{
|
|
CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial, Permission,
|
|
PermissionConfigPartial, ProfileDiscovery, ProfileError, ProfileRegistry,
|
|
ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig,
|
|
ScopeRule, SessionConfigPartial, ToolOutputLimitsPartial, WorkerManifest, WorkerManifestConfig,
|
|
WorkerMetaConfig,
|
|
};
|
|
use serde::Deserialize;
|
|
use tokio::sync::mpsc;
|
|
use workdir::{
|
|
WorkdirDelegationPermission, WorkdirDelegationRequest, WorkdirDelegationRule,
|
|
WorkdirSessionHandle,
|
|
};
|
|
|
|
use crate::PromptCatalogSource;
|
|
use crate::controller::register_worker_tools;
|
|
use crate::internal_worker::{
|
|
EphemeralSessionStore, InternalWorkerSessionStatus, prepare_internal_worker_session,
|
|
};
|
|
use crate::prompt::catalog::PromptCatalog;
|
|
use crate::spawn::registry::SpawnedWorkerRegistry;
|
|
use crate::worker::{
|
|
ReviewerChildWorkspaceClient, ReviewerContext, Worker, WorkerFilesystemAuthority,
|
|
WorkspaceRequest, WorkspaceRequestMethod,
|
|
};
|
|
use protocol::Method;
|
|
|
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
|
struct SubWorkerSpawnInput {
|
|
/// Identifier for the spawned Internal SubWorker. Must be unique among this Worker's direct children.
|
|
name: String,
|
|
/// Profile selector for child role configuration. Omit or use `default`
|
|
/// for the effective child default profile, use `inherit` to derive
|
|
/// reusable config from the spawner, or use a registry selector such as
|
|
/// `project:coder`, `project:reviewer`, `builtin:companion`, or an
|
|
/// unambiguous profile slug. Raw/path selectors are rejected.
|
|
#[serde(default)]
|
|
profile: Option<String>,
|
|
/// Exact catalog-root dotted Prompt name (for example `default` or `role.coder`).
|
|
#[serde(default)]
|
|
instruction: Option<String>,
|
|
/// Child process/tool working directory. This is not the runtime workspace
|
|
/// root and grants no filesystem authority. When omitted, the spawned SubWorker
|
|
/// starts in the spawner's current working directory.
|
|
#[serde(default)]
|
|
cwd: Option<PathBuf>,
|
|
/// First message sent to the spawned SubWorker via `Method::Run`.
|
|
task: String,
|
|
/// Allow rules delegated to the spawned SubWorker. Must be a subset of the
|
|
/// spawner's explicit delegation authority; direct tool scope alone is not
|
|
/// sufficient. Omit `recursive` for normal workspace/worktree delegation; it defaults to true.
|
|
scope: Vec<ScopeRuleInput>,
|
|
/// Binds an actual read-only builtin Reviewer child to the current Merge Request candidate.
|
|
/// Review capability material is generated by the trusted spawn layer.
|
|
#[serde(default)]
|
|
review: Option<ReviewerHandoffInput>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
|
struct ReviewerHandoffInput {
|
|
ticket_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
|
struct ScopeRuleInput {
|
|
/// Absolute target path. Relative paths are rejected.
|
|
target: PathBuf,
|
|
/// `"read"` or `"write"`.
|
|
permission: PermissionInput,
|
|
/// When `false`, the rule matches the target itself and its direct
|
|
/// children only. Defaults to `true`.
|
|
#[serde(default = "default_true")]
|
|
recursive: bool,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, schemars::JsonSchema, Clone, Copy)]
|
|
#[serde(rename_all = "lowercase")]
|
|
enum PermissionInput {
|
|
Read,
|
|
Write,
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
impl From<PermissionInput> for Permission {
|
|
fn from(p: PermissionInput) -> Self {
|
|
match p {
|
|
PermissionInput::Read => Permission::Read,
|
|
PermissionInput::Write => Permission::Write,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct AvailableProfiles {
|
|
registry: Option<ProfileRegistry>,
|
|
diagnostic: Option<String>,
|
|
}
|
|
|
|
impl AvailableProfiles {
|
|
fn discover(cwd: &Path) -> Self {
|
|
match ProfileDiscovery::for_cwd(cwd).discover() {
|
|
Ok(registry) => Self {
|
|
registry: Some(registry),
|
|
diagnostic: None,
|
|
},
|
|
Err(error) => Self {
|
|
registry: None,
|
|
diagnostic: Some(error.to_string()),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn compact_list(&self) -> String {
|
|
let Some(registry) = &self.registry else {
|
|
return "- profile discovery failed; use `inherit` or retry after fixing discovery"
|
|
.into();
|
|
};
|
|
if registry.entries().is_empty() {
|
|
return "- no registry profiles discovered; `inherit` is still available".into();
|
|
}
|
|
registry
|
|
.entries()
|
|
.iter()
|
|
.map(|entry| {
|
|
let default = if entry.is_default { " (default)" } else { "" };
|
|
let desc = entry
|
|
.description
|
|
.as_deref()
|
|
.map(|d| format!(" — {d}"))
|
|
.unwrap_or_default();
|
|
format!("- `{}`{}{}", entry.qualified_name(), default, desc)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
fn default_label(&self) -> String {
|
|
self.registry
|
|
.as_ref()
|
|
.and_then(|registry| registry.default_entry().ok())
|
|
.map(|entry| entry.qualified_name())
|
|
.unwrap_or_else(|| "none resolved".into())
|
|
}
|
|
|
|
fn diagnostic(&self) -> &str {
|
|
self.diagnostic.as_deref().unwrap_or("")
|
|
}
|
|
|
|
fn error_suffix(&self) -> String {
|
|
format!(
|
|
"\nUse `default`, `inherit`, or one of these registry selectors:\n{}",
|
|
self.compact_list()
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum SpawnProfileSelector {
|
|
Default,
|
|
Inherit,
|
|
Registry(ProfileSelector),
|
|
}
|
|
|
|
fn parse_spawn_profile_selector(raw: Option<&str>) -> Result<SpawnProfileSelector, String> {
|
|
let Some(raw) = raw.map(str::trim).filter(|s| !s.is_empty()) else {
|
|
return Ok(SpawnProfileSelector::Default);
|
|
};
|
|
if raw == "default" {
|
|
return Ok(SpawnProfileSelector::Default);
|
|
}
|
|
if raw == "inherit" {
|
|
return Ok(SpawnProfileSelector::Inherit);
|
|
}
|
|
if raw.starts_with("path:")
|
|
|| raw.starts_with('/')
|
|
|| raw.starts_with("./")
|
|
|| raw.starts_with("../")
|
|
|| raw.contains('/')
|
|
|| raw.ends_with(".dcdl")
|
|
|| raw.ends_with(".json")
|
|
|| raw.ends_with(".toml")
|
|
|| raw.ends_with(".nix")
|
|
{
|
|
return Err(format!(
|
|
"SubWorkerSpawn.profile accepts `default`, `inherit`, or registry selectors only; path-like selector `{raw}` is not allowed"
|
|
));
|
|
}
|
|
if let Some((prefix, name)) = raw.split_once(':') {
|
|
let source = match prefix {
|
|
"builtin" => ProfileRegistrySource::Builtin,
|
|
"user" => ProfileRegistrySource::User,
|
|
"project" => ProfileRegistrySource::Project,
|
|
_ => {
|
|
return Err(format!(
|
|
"unsupported SubWorkerSpawn.profile selector prefix `{prefix}`; use builtin:, user:, project:, default, or inherit"
|
|
));
|
|
}
|
|
};
|
|
if name.is_empty() {
|
|
return Err(
|
|
"SubWorkerSpawn.profile registry selector has an empty profile name".into(),
|
|
);
|
|
}
|
|
return Ok(SpawnProfileSelector::Registry(
|
|
ProfileSelector::source_named(source, name),
|
|
));
|
|
}
|
|
Ok(SpawnProfileSelector::Registry(ProfileSelector::named(raw)))
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) enum ParentNotificationTarget {
|
|
Controller(mpsc::WeakSender<Method>),
|
|
Buffer(crate::ipc::notify_buffer::NotifyBuffer),
|
|
}
|
|
|
|
impl ParentNotificationTarget {
|
|
fn notify(&self, message: String, auto_run: bool) {
|
|
match self {
|
|
Self::Controller(parent_method_tx) => {
|
|
let Some(parent_method_tx) = parent_method_tx.upgrade() else {
|
|
tracing::warn!(
|
|
"parent Worker controller closed before Internal SubWorker completion notification"
|
|
);
|
|
return;
|
|
};
|
|
tokio::spawn(async move {
|
|
if let Err(error) = parent_method_tx
|
|
.send(Method::Notify { message, auto_run })
|
|
.await
|
|
{
|
|
tracing::warn!(
|
|
%error,
|
|
"failed to notify parent Worker about Internal SubWorker completion"
|
|
);
|
|
}
|
|
});
|
|
}
|
|
Self::Buffer(parent_notifies) => parent_notifies.push_notify(message, auto_run),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Runtime dependencies the `SubWorkerSpawn` tool needs in order to launch a
|
|
/// child SubWorker and record the handoff locally. Constructed by the Worker
|
|
/// controller once per Worker lifetime.
|
|
pub struct SubWorkerSpawnTool {
|
|
/// Spawner's own Worker name, used for direct-child identity collision checks.
|
|
spawner_name: String,
|
|
workspace_context: crate::worker::WorkerWorkspaceContext,
|
|
parent_notifications: ParentNotificationTarget,
|
|
/// Runtime-owned root used only for bounded Internal Worker tool artifacts such as Bash spill
|
|
/// output. It is not an Internal Worker identity or catalog location.
|
|
runtime_base: PathBuf,
|
|
/// Inherited runtime workspace root for Profile/project/Ticket/workflow/
|
|
/// memory context. SubWorkerSpawn `cwd` must not affect this value.
|
|
workspace_root: PathBuf,
|
|
/// Directory the spawned SubWorker's tools should use when the LLM did not
|
|
/// override it. Defaults to the spawner's cwd.
|
|
spawner_cwd: PathBuf,
|
|
/// Active provider-backed Workdir session from which child leases are captured.
|
|
source_workdir_session: Option<WorkdirSessionHandle>,
|
|
/// Parent-owned in-memory registry shared by the five SubWorker tools.
|
|
registry: Arc<SpawnedWorkerRegistry>,
|
|
/// Spawner's resolved Manifest. `profile = "inherit"` derives the
|
|
/// child config from reusable fields here, and selected profiles are
|
|
/// merged into the same internal handoff shape before launch.
|
|
spawner_manifest: WorkerManifest,
|
|
prompt_loader: PromptCatalogSource,
|
|
/// Compact selector list shared by tool description and diagnostics.
|
|
available_profiles: AvailableProfiles,
|
|
internal_client_override: Option<Box<dyn llm_engine::llm_client::LlmClient>>,
|
|
}
|
|
|
|
impl SubWorkerSpawnTool {
|
|
#[cfg(test)]
|
|
fn with_internal_client(mut self, client: Box<dyn llm_engine::llm_client::LlmClient>) -> Self {
|
|
self.internal_client_override = Some(client);
|
|
self
|
|
}
|
|
|
|
fn new(
|
|
spawner_name: String,
|
|
workspace_context: crate::worker::WorkerWorkspaceContext,
|
|
parent_notifications: ParentNotificationTarget,
|
|
runtime_base: PathBuf,
|
|
workspace_root: PathBuf,
|
|
spawner_cwd: PathBuf,
|
|
source_workdir_session: Option<WorkdirSessionHandle>,
|
|
registry: Arc<SpawnedWorkerRegistry>,
|
|
spawner_manifest: WorkerManifest,
|
|
prompt_loader: PromptCatalogSource,
|
|
available_profiles: AvailableProfiles,
|
|
) -> Self {
|
|
Self {
|
|
spawner_name,
|
|
workspace_context,
|
|
parent_notifications,
|
|
runtime_base,
|
|
workspace_root,
|
|
spawner_cwd,
|
|
source_workdir_session,
|
|
registry,
|
|
spawner_manifest,
|
|
prompt_loader,
|
|
available_profiles,
|
|
internal_client_override: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolError> {
|
|
let Some(review) = &input.review else {
|
|
return Ok(());
|
|
};
|
|
if review.ticket_id.trim().is_empty() {
|
|
return Err(ToolError::InvalidArgument(
|
|
"reviewer handoff requires non-empty ticket_id".to_string(),
|
|
));
|
|
}
|
|
if input.profile.as_deref() != Some("builtin:reviewer") {
|
|
return Err(ToolError::InvalidArgument(
|
|
"reviewer handoff requires the explicit effective profile builtin:reviewer".to_string(),
|
|
));
|
|
}
|
|
if input
|
|
.scope
|
|
.iter()
|
|
.any(|rule| matches!(rule.permission, PermissionInput::Write))
|
|
{
|
|
return Err(ToolError::InvalidArgument(
|
|
"Merge Request Reviewer SubWorkers must have read-only delegated scope".to_string(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for SubWorkerSpawnTool {
|
|
async fn execute(
|
|
&self,
|
|
input_json: &str,
|
|
_ctx: llm_engine::tool::ToolExecutionContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let input: SubWorkerSpawnInput = serde_json::from_str(input_json).map_err(|e| {
|
|
ToolError::InvalidArgument(format!("invalid SubWorkerSpawn input: {e}"))
|
|
})?;
|
|
|
|
// `delegate_scope` catches this too (as `DuplicateWorkerName`), but
|
|
// the dedicated message is kinder to the LLM — which gets the
|
|
// error back verbatim — than the generic duplicate-name error.
|
|
if input.name == self.spawner_name {
|
|
return Err(ToolError::InvalidArgument(format!(
|
|
"spawned worker name `{}` collides with spawner's own name",
|
|
input.name
|
|
)));
|
|
}
|
|
validate_reviewer_handoff(&input)?;
|
|
let name_reservation = self
|
|
.registry
|
|
.reserve_internal_name(input.name.clone())
|
|
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
|
|
|
let scope_allow = parse_scope(&input.scope)?;
|
|
let source_workdir_session =
|
|
require_active_workdir_session(self.source_workdir_session.as_ref())?;
|
|
let delegation_request =
|
|
self.workdir_delegation_request(input.cwd.as_deref(), &scope_allow)?;
|
|
let workdir_delegation = source_workdir_session
|
|
.delegate(delegation_request)
|
|
.await
|
|
.map_err(|error| {
|
|
ToolError::InvalidArgument(format!("delegate Workdir session: {error}"))
|
|
})?;
|
|
|
|
let spawn_selector =
|
|
parse_spawn_profile_selector(input.profile.as_deref()).map_err(|msg| {
|
|
ToolError::InvalidArgument(format!(
|
|
"{msg}{}",
|
|
self.available_profiles.error_suffix()
|
|
))
|
|
})?;
|
|
let spawn_config_json = self
|
|
.build_spawn_config_json(
|
|
&input.name,
|
|
input.instruction.as_deref(),
|
|
&scope_allow,
|
|
spawn_selector,
|
|
)
|
|
.map_err(|e| ToolError::InvalidArgument(format!("{e}")))?;
|
|
|
|
let mut child_config: WorkerManifestConfig = serde_json::from_str(&spawn_config_json)
|
|
.map_err(|error| {
|
|
ToolError::ExecutionFailed(format!("resolve child manifest: {error}"))
|
|
})?;
|
|
child_config.delegation_scope = ScopeConfig {
|
|
allow: scope_allow.clone(),
|
|
deny: Vec::new(),
|
|
};
|
|
let mut child_manifest =
|
|
WorkerManifest::try_from(WorkerManifestConfig::builtin_defaults().merge(child_config))
|
|
.map_err(|error| {
|
|
ToolError::ExecutionFailed(format!("resolve child manifest: {error}"))
|
|
})?;
|
|
// Delegated children stay bound to their scoped session and cannot use
|
|
// Workspace attachment tools to replace it with parent-level authority.
|
|
child_manifest.feature.manage_workdir.enabled = false;
|
|
let reviewer_capability = input.review.as_ref().map(|review| {
|
|
(
|
|
review.ticket_id.clone(),
|
|
format!(
|
|
"{}{}",
|
|
uuid::Uuid::now_v7().simple(),
|
|
uuid::Uuid::now_v7().simple()
|
|
),
|
|
)
|
|
});
|
|
let child_workspace_context =
|
|
if let Some((ticket_id, capability_token)) = &reviewer_capability {
|
|
let workspace_id =
|
|
self.workspace_context
|
|
.workspace_id()
|
|
.cloned()
|
|
.ok_or_else(|| {
|
|
ToolError::InvalidArgument(
|
|
"reviewer handoff requires Workspace identity".to_string(),
|
|
)
|
|
})?;
|
|
let parent_client = self.workspace_context.client_handle();
|
|
if !parent_client.is_available() {
|
|
return Err(ToolError::InvalidArgument(
|
|
"reviewer handoff requires Workspace API authority".to_string(),
|
|
));
|
|
}
|
|
let child_client: Arc<dyn crate::worker::WorkspaceClient> =
|
|
Arc::new(ReviewerChildWorkspaceClient::new(
|
|
parent_client.clone(),
|
|
ReviewerContext {
|
|
ticket_id: ticket_id.clone(),
|
|
},
|
|
capability_token.clone(),
|
|
));
|
|
crate::worker::WorkerWorkspaceContext::with_client(Some(workspace_id), child_client)
|
|
} else {
|
|
self.workspace_context.clone()
|
|
};
|
|
let store = EphemeralSessionStore::default();
|
|
let filesystem_authority = WorkerFilesystemAuthority::None;
|
|
let mut child = Worker::<Box<dyn llm_engine::llm_client::LlmClient>, EphemeralSessionStore>::from_internal_manifest_with_context(
|
|
child_manifest,
|
|
store.clone(),
|
|
self.prompt_loader.clone(),
|
|
child_workspace_context,
|
|
filesystem_authority,
|
|
self.internal_client_override
|
|
.as_ref()
|
|
.map(|client| client.clone_boxed()),
|
|
)
|
|
.await
|
|
.map_err(|error| ToolError::ExecutionFailed(format!("build Internal Worker: {error}")))?;
|
|
child.bind_workdir_session(Some(workdir_delegation.scoped_session.clone()));
|
|
let child_scope = child.scope().clone();
|
|
let child_registry = SpawnedWorkerRegistry::new_internal(input.name.clone(), child_scope);
|
|
register_worker_tools(
|
|
&mut child,
|
|
self.runtime_base
|
|
.join("internal-workers")
|
|
.join(&input.name)
|
|
.join("bash-output"),
|
|
self.runtime_base.clone(),
|
|
child_registry,
|
|
None,
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
ToolError::ExecutionFailed(format!("install Internal Worker features: {error}"))
|
|
})?;
|
|
#[cfg(test)]
|
|
let installed_tools = child
|
|
.engine()
|
|
.tool_server_handle()
|
|
.tool_definitions_sorted()
|
|
.into_iter()
|
|
.map(|definition| definition.name)
|
|
.collect();
|
|
let child_name = input.name.clone();
|
|
let registry = Arc::downgrade(&self.registry);
|
|
let parent_notifications = self.parent_notifications.clone();
|
|
let session_result = prepare_internal_worker_session(
|
|
child,
|
|
store,
|
|
Some(Arc::new(move |status| {
|
|
if status == InternalWorkerSessionStatus::Failed {
|
|
if let Some(registry) = registry.upgrade() {
|
|
if let Err(error) = registry.reclaim_internal_scope(&child_name) {
|
|
tracing::warn!(
|
|
child_name,
|
|
%error,
|
|
"failed to reclaim delegated scope after Internal SubWorker failure"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
let message = format!(
|
|
"SubWorker `{child_name}` turn ended with status {status:?}. Inspect its committed session with worker-observation tools before making completion decisions."
|
|
);
|
|
parent_notifications.notify(message, true);
|
|
})),
|
|
)
|
|
.await;
|
|
let session = session_result.map_err(|error| {
|
|
ToolError::ExecutionFailed(format!("prepare Internal Worker session: {error}"))
|
|
})?;
|
|
|
|
if let Some((ticket_id, capability_token)) = &reviewer_capability {
|
|
let workspace_id = self.workspace_context.workspace_id().ok_or_else(|| {
|
|
ToolError::ExecutionFailed("review capability lost Workspace identity".to_string())
|
|
})?;
|
|
let child_session_id = session.session_id_string();
|
|
let child_registration = WorkspaceRequest::json(
|
|
WorkspaceRequestMethod::Post,
|
|
format!(
|
|
"/api/w/{}/internal/reviewer-child-sessions",
|
|
workspace_id.as_str()
|
|
),
|
|
serde_json::json!({"child_session_id": child_session_id}).to_string(),
|
|
);
|
|
let child_response = self
|
|
.workspace_context
|
|
.client()
|
|
.execute(child_registration)
|
|
.map_err(|error| {
|
|
ToolError::ExecutionFailed(format!(
|
|
"register Runtime-owned Reviewer child session: {error}"
|
|
))
|
|
})?;
|
|
if !child_response.is_success() {
|
|
let _ = session.stop().await;
|
|
return Err(ToolError::ExecutionFailed(format!(
|
|
"register Runtime-owned Reviewer child session failed with status {}: {}",
|
|
child_response.status, child_response.body
|
|
)));
|
|
}
|
|
let body = serde_json::json!({
|
|
"child_session_id": child_session_id,
|
|
"capability_token": capability_token,
|
|
});
|
|
let request = WorkspaceRequest::json(
|
|
WorkspaceRequestMethod::Post,
|
|
format!(
|
|
"/api/w/{}/tickets/{}/merge-request/review-capabilities",
|
|
workspace_id.as_str(),
|
|
ticket_id
|
|
),
|
|
body.to_string(),
|
|
);
|
|
let response = self
|
|
.workspace_context
|
|
.client()
|
|
.execute(request)
|
|
.map_err(|error| {
|
|
ToolError::ExecutionFailed(format!("register review capability: {error}"))
|
|
})?;
|
|
if !response.is_success() {
|
|
let _ = session.stop().await;
|
|
return Err(ToolError::ExecutionFailed(format!(
|
|
"register review capability failed with status {}: {}",
|
|
response.status, response.body
|
|
)));
|
|
}
|
|
}
|
|
|
|
let record = crate::spawn::registry::InternalSpawnedWorkerRecord::new(
|
|
input.name.clone(),
|
|
scope_allow,
|
|
workdir_delegation,
|
|
#[cfg(test)]
|
|
installed_tools,
|
|
session.clone(),
|
|
);
|
|
if let Err(error) = name_reservation.commit(record) {
|
|
let _ = session.stop().await;
|
|
return Err(ToolError::ExecutionFailed(format!(
|
|
"register Internal Worker session: {error}"
|
|
)));
|
|
}
|
|
if let Err(error) = session.send(input.task).await {
|
|
let _ = session.stop().await;
|
|
let _ = self.registry.remove_internal(&input.name).await;
|
|
return Err(ToolError::ExecutionFailed(format!(
|
|
"start Internal Worker session: {error}"
|
|
)));
|
|
}
|
|
|
|
Ok(ToolOutput {
|
|
summary: format!("spawned internal worker `{}`", input.name),
|
|
content: None,
|
|
|
|
attachments: Vec::new(),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl SubWorkerSpawnTool {
|
|
fn workdir_delegation_request(
|
|
&self,
|
|
cwd: Option<&Path>,
|
|
scope_allow: &[ScopeRule],
|
|
) -> Result<WorkdirDelegationRequest, ToolError> {
|
|
let rules = scope_allow
|
|
.iter()
|
|
.map(|rule| {
|
|
Ok(WorkdirDelegationRule {
|
|
target: self.logical_workdir_path(&rule.target)?,
|
|
permission: match rule.permission {
|
|
Permission::Read => WorkdirDelegationPermission::Read,
|
|
Permission::Write => WorkdirDelegationPermission::Write,
|
|
},
|
|
recursive: rule.recursive,
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>, ToolError>>()?;
|
|
let cwd = cwd.unwrap_or(&self.spawner_cwd);
|
|
if !cwd.is_absolute() {
|
|
return Err(ToolError::InvalidArgument(format!(
|
|
"cwd must be absolute, got `{}`",
|
|
cwd.display()
|
|
)));
|
|
}
|
|
Ok(WorkdirDelegationRequest {
|
|
rules,
|
|
cwd: self.logical_workdir_path(cwd)?,
|
|
})
|
|
}
|
|
|
|
fn logical_workdir_path(&self, path: &Path) -> Result<FsPath, ToolError> {
|
|
let logical = if self.workspace_root == Path::new("/") {
|
|
path.strip_prefix(Path::new("/"))
|
|
} else {
|
|
path.strip_prefix(&self.workspace_root)
|
|
}
|
|
.map_err(|_| {
|
|
ToolError::InvalidArgument(format!(
|
|
"scope target `{}` is not a Workdir-owned logical path",
|
|
path.display()
|
|
))
|
|
})?;
|
|
let logical = logical.to_str().ok_or_else(|| {
|
|
ToolError::InvalidArgument(format!(
|
|
"scope target `{}` is not valid UTF-8",
|
|
path.display()
|
|
))
|
|
})?;
|
|
FsPath::new(logical).map_err(|error| {
|
|
ToolError::InvalidArgument(format!(
|
|
"scope target `{}` is not a valid logical Workdir path: {error}",
|
|
path.display()
|
|
))
|
|
})
|
|
}
|
|
}
|
|
|
|
fn require_active_workdir_session(
|
|
session: Option<&WorkdirSessionHandle>,
|
|
) -> Result<&WorkdirSessionHandle, ToolError> {
|
|
session.ok_or_else(|| {
|
|
ToolError::InvalidArgument(
|
|
"SubWorkerSpawn requires an active Workdir session; attach a Workdir before delegating filesystem access"
|
|
.to_string(),
|
|
)
|
|
})
|
|
}
|
|
|
|
fn parse_scope(rules: &[ScopeRuleInput]) -> Result<Vec<ScopeRule>, ToolError> {
|
|
if rules.is_empty() {
|
|
return Err(ToolError::InvalidArgument("scope must not be empty".into()));
|
|
}
|
|
rules
|
|
.iter()
|
|
.map(|r| {
|
|
if !r.target.is_absolute() {
|
|
return Err(ToolError::InvalidArgument(format!(
|
|
"scope.target must be absolute: {}",
|
|
r.target.display()
|
|
)));
|
|
}
|
|
Ok(ScopeRule {
|
|
target: r.target.clone(),
|
|
permission: r.permission.into(),
|
|
recursive: r.recursive,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Serialise the internal manifest config that gets handed to the child
|
|
/// Worker runtime process via the hidden `--spawn-config-json` flag.
|
|
/// `WorkerManifestConfig`'s `Serialize` impl is the single source of truth for the
|
|
/// internal handoff shape.
|
|
///
|
|
/// The child's tool working directory is carried separately through
|
|
/// the child runtime entrypoint; it is not part of the manifest.
|
|
impl SubWorkerSpawnTool {
|
|
fn build_spawn_config_json(
|
|
&self,
|
|
name: &str,
|
|
instruction_override: Option<&str>,
|
|
scope_allow: &[ScopeRule],
|
|
selector: SpawnProfileSelector,
|
|
) -> Result<String, String> {
|
|
build_spawn_config_json_for_profile(
|
|
&self.spawner_manifest,
|
|
&self.available_profiles,
|
|
&self.workspace_root,
|
|
name,
|
|
instruction_override,
|
|
scope_allow,
|
|
selector,
|
|
)
|
|
}
|
|
}
|
|
|
|
fn build_spawn_config_json_for_profile(
|
|
spawner_manifest: &WorkerManifest,
|
|
available_profiles: &AvailableProfiles,
|
|
workspace_root: &Path,
|
|
name: &str,
|
|
instruction_override: Option<&str>,
|
|
scope_allow: &[ScopeRule],
|
|
selector: SpawnProfileSelector,
|
|
) -> Result<String, String> {
|
|
let mut config = match selector {
|
|
SpawnProfileSelector::Inherit => manifest_to_reusable_config(spawner_manifest),
|
|
SpawnProfileSelector::Default | SpawnProfileSelector::Registry(_) => {
|
|
let registry = available_profiles.registry.as_ref().ok_or_else(|| {
|
|
format!(
|
|
"profile discovery failed for SubWorkerSpawn: {}{}",
|
|
available_profiles.diagnostic().if_empty("unknown error"),
|
|
available_profiles.error_suffix()
|
|
)
|
|
})?;
|
|
let profile_selector = match selector {
|
|
SpawnProfileSelector::Default => ProfileSelector::Default,
|
|
SpawnProfileSelector::Registry(selector) => selector,
|
|
SpawnProfileSelector::Inherit => unreachable!(),
|
|
};
|
|
let resolved = ProfileResolver::new()
|
|
.with_workspace_base(workspace_root)
|
|
.resolve_from_registry(
|
|
&profile_selector,
|
|
registry,
|
|
ProfileResolveOptions::with_worker_name(name),
|
|
)
|
|
.map_err(|e| profile_error_with_available(e, available_profiles))?;
|
|
manifest_to_reusable_config(&resolved.manifest)
|
|
}
|
|
};
|
|
config.worker.name = Some(name.to_string());
|
|
config.scope = ScopeConfig {
|
|
allow: scope_allow.to_vec(),
|
|
deny: Vec::new(),
|
|
};
|
|
if let Some(instruction) = instruction_override {
|
|
config.engine.instruction = Some(instruction.to_string());
|
|
}
|
|
serde_json::to_string(&config).map_err(|e| format!("spawn config serialisation: {e}"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn build_spawn_config_json(
|
|
name: &str,
|
|
instruction: &str,
|
|
scope_allow: &[ScopeRule],
|
|
model: &manifest::ModelManifest,
|
|
record_event_trace: bool,
|
|
) -> Result<String, serde_json::Error> {
|
|
let config = WorkerManifestConfig {
|
|
worker: WorkerMetaConfig {
|
|
name: Some(name.to_string()),
|
|
},
|
|
model: model.clone(),
|
|
engine: EngineManifestConfig {
|
|
instruction: Some(instruction.to_string()),
|
|
..Default::default()
|
|
},
|
|
scope: ScopeConfig {
|
|
allow: scope_allow.to_vec(),
|
|
deny: Vec::new(),
|
|
},
|
|
session: record_event_trace.then_some(SessionConfigPartial {
|
|
record_event_trace: Some(true),
|
|
}),
|
|
..Default::default()
|
|
};
|
|
serde_json::to_string(&config)
|
|
}
|
|
|
|
trait IfEmpty {
|
|
fn if_empty(&self, fallback: &str) -> String;
|
|
}
|
|
impl IfEmpty for str {
|
|
fn if_empty(&self, fallback: &str) -> String {
|
|
if self.is_empty() {
|
|
fallback.into()
|
|
} else {
|
|
self.into()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn profile_error_with_available(error: ProfileError, available: &AvailableProfiles) -> String {
|
|
format!(
|
|
"invalid SubWorkerSpawn.profile: {error}{}",
|
|
available.error_suffix()
|
|
)
|
|
}
|
|
|
|
fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfig {
|
|
WorkerManifestConfig {
|
|
worker: WorkerMetaConfig {
|
|
name: Some(manifest.worker.name.clone()),
|
|
},
|
|
model: manifest.model.clone(),
|
|
engine: EngineManifestConfig {
|
|
instruction: Some(manifest.engine.instruction.clone()),
|
|
language: Some(manifest.engine.language.clone()),
|
|
max_tokens: manifest.engine.max_tokens,
|
|
max_turns: manifest.engine.max_turns,
|
|
temperature: manifest.engine.temperature,
|
|
top_p: manifest.engine.top_p,
|
|
top_k: manifest.engine.top_k,
|
|
stop_sequences: (!manifest.engine.stop_sequences.is_empty())
|
|
.then_some(manifest.engine.stop_sequences.clone()),
|
|
reasoning: manifest.engine.reasoning.clone(),
|
|
tool_output: ToolOutputLimitsPartial {
|
|
default_max_bytes: Some(manifest.engine.tool_output.default_max_bytes),
|
|
per_tool: manifest.engine.tool_output.per_tool.clone(),
|
|
},
|
|
file_upload: FileUploadLimitsPartial {
|
|
max_bytes: Some(manifest.engine.file_upload.max_bytes),
|
|
},
|
|
},
|
|
scope: ScopeConfig {
|
|
allow: manifest.scope.allow.clone(),
|
|
deny: manifest.scope.deny.clone(),
|
|
},
|
|
// `inherit` reuses behavioral configuration, not subdelegation authority.
|
|
delegation_scope: ScopeConfig::default(),
|
|
session: Some(SessionConfigPartial {
|
|
record_event_trace: Some(manifest.session.record_event_trace),
|
|
}),
|
|
permissions: manifest
|
|
.permissions
|
|
.as_ref()
|
|
.map(|p| PermissionConfigPartial {
|
|
default_action: Some(p.default_action),
|
|
rules: p.rules.clone(),
|
|
}),
|
|
feature: manifest.feature.clone().into(),
|
|
plugins: manifest.plugins.clone(),
|
|
mcp: manifest.mcp.clone(),
|
|
compaction: manifest
|
|
.compaction
|
|
.as_ref()
|
|
.map(|c| CompactionConfigPartial {
|
|
prune_protected_tokens: Some(c.prune_protected_tokens),
|
|
prune_min_savings: Some(c.prune_min_savings),
|
|
threshold: c.threshold,
|
|
request_threshold: c.request_threshold,
|
|
retained_tokens: Some(c.retained_tokens),
|
|
overview_target_tokens: Some(c.overview_target_tokens),
|
|
overview_warning_tokens: Some(c.overview_warning_tokens),
|
|
overview_deadline_tokens: Some(c.overview_deadline_tokens),
|
|
worker_context_max_tokens: Some(c.worker_context_max_tokens),
|
|
finish_warning_remaining_tokens: Some(c.finish_warning_remaining_tokens),
|
|
final_reserve_tokens: Some(c.final_reserve_tokens),
|
|
worker_max_turns: c.worker_max_turns,
|
|
summary_target_tokens: Some(c.summary_target_tokens),
|
|
summary_max_tokens: Some(c.summary_max_tokens),
|
|
auto_read_budget_tokens: Some(c.auto_read_budget_tokens),
|
|
result_context_max_tokens: Some(c.result_context_max_tokens),
|
|
model: c.model.clone(),
|
|
}),
|
|
web: manifest.web.clone(),
|
|
memory: manifest.memory.clone(),
|
|
skills: manifest.skills.clone(),
|
|
}
|
|
}
|
|
|
|
/// Tail of the spawned child's `stderr.log` to splice into a startup
|
|
/// failure message. Capped so a chatty child can't blow up the LLM's
|
|
/// tool-result budget — debugging beyond this should read the file
|
|
/// directly.
|
|
/// Factory for the `SubWorkerSpawn` tool.
|
|
pub(crate) fn sub_worker_spawn_tool(
|
|
spawner_name: String,
|
|
workspace_context: crate::worker::WorkerWorkspaceContext,
|
|
parent_notifications: ParentNotificationTarget,
|
|
runtime_base: PathBuf,
|
|
workspace_root: PathBuf,
|
|
spawner_cwd: PathBuf,
|
|
source_workdir_session: Option<WorkdirSessionHandle>,
|
|
registry: Arc<SpawnedWorkerRegistry>,
|
|
spawner_manifest: WorkerManifest,
|
|
prompts: Arc<ArcSwap<PromptCatalog>>,
|
|
) -> ToolDefinition {
|
|
sub_worker_spawn_tool_impl(
|
|
spawner_name,
|
|
workspace_context,
|
|
parent_notifications,
|
|
runtime_base,
|
|
workspace_root,
|
|
spawner_cwd,
|
|
source_workdir_session,
|
|
registry,
|
|
spawner_manifest,
|
|
prompts,
|
|
)
|
|
}
|
|
|
|
fn sub_worker_spawn_tool_impl(
|
|
spawner_name: String,
|
|
workspace_context: crate::worker::WorkerWorkspaceContext,
|
|
parent_notifications: ParentNotificationTarget,
|
|
runtime_base: PathBuf,
|
|
workspace_root: PathBuf,
|
|
spawner_cwd: PathBuf,
|
|
source_workdir_session: Option<WorkdirSessionHandle>,
|
|
registry: Arc<SpawnedWorkerRegistry>,
|
|
spawner_manifest: WorkerManifest,
|
|
prompts: Arc<ArcSwap<PromptCatalog>>,
|
|
) -> ToolDefinition {
|
|
Arc::new(move || {
|
|
let schema = schemars::schema_for!(SubWorkerSpawnInput);
|
|
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
|
let available_profiles = AvailableProfiles::discover(&workspace_root);
|
|
let description = prompts
|
|
.load_full()
|
|
.sub_worker_spawn_tool_description(
|
|
&available_profiles.compact_list(),
|
|
&available_profiles.default_label(),
|
|
available_profiles.diagnostic(),
|
|
)
|
|
.unwrap_or_else(|e| {
|
|
format!(
|
|
"Spawn an in-process Internal SubWorker session to split context for a delegated task. Profile description rendering failed: {e}. Available profiles:\n{}",
|
|
available_profiles.compact_list()
|
|
)
|
|
});
|
|
let meta = ToolMeta::new("SubWorkerSpawn")
|
|
.description(description)
|
|
.input_schema(schema_value);
|
|
let tool: Arc<dyn Tool> = Arc::new(SubWorkerSpawnTool::new(
|
|
spawner_name.clone(),
|
|
workspace_context.clone(),
|
|
parent_notifications.clone(),
|
|
runtime_base.clone(),
|
|
workspace_root.clone(),
|
|
spawner_cwd.clone(),
|
|
source_workdir_session.clone(),
|
|
registry.clone(),
|
|
spawner_manifest.clone(),
|
|
prompts.load_full().source(),
|
|
available_profiles,
|
|
));
|
|
(meta, tool)
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use manifest::{DelegationScope, Scope, SharedScope};
|
|
use std::pin::Pin;
|
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|
use std::time::Duration;
|
|
|
|
use crate::WorkspaceId;
|
|
use async_trait::async_trait;
|
|
use futures::Stream;
|
|
use llm_engine::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
|
|
use llm_engine::llm_client::types::ContentPart;
|
|
use llm_engine::llm_client::{ClientError, LlmClient, Request};
|
|
use llm_engine::{Item, Role};
|
|
use manifest::{AuthRef, ModelManifest, SchemeKind, WorkerManifest};
|
|
use tempfile::TempDir;
|
|
|
|
use crate::worker::{
|
|
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceResponse,
|
|
};
|
|
|
|
#[test]
|
|
fn missing_active_workdir_session_fails_deterministically() {
|
|
let error = require_active_workdir_session(None).unwrap_err();
|
|
assert!(matches!(
|
|
error,
|
|
ToolError::InvalidArgument(message)
|
|
if message.contains("requires an active Workdir session")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn reviewer_handoff_requires_explicit_builtin_profile_and_read_only_scope() {
|
|
let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
|
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
|
"scope":[{"target":"/tmp/work","permission":"read"}],
|
|
"review":{"ticket_id":"T1"}
|
|
}))
|
|
.unwrap();
|
|
assert!(validate_reviewer_handoff(&valid).is_ok());
|
|
let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
|
"name":"reviewer","task":"review","profile":"builtin:coder",
|
|
"scope":[{"target":"/tmp/work","permission":"read"}],
|
|
"review":{"ticket_id":"T1"}
|
|
}))
|
|
.unwrap();
|
|
assert!(validate_reviewer_handoff(&wrong_profile).is_err());
|
|
let writable: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
|
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
|
"scope":[{"target":"/tmp/work","permission":"write"}],
|
|
"review":{"ticket_id":"T1"}
|
|
}))
|
|
.unwrap();
|
|
assert!(validate_reviewer_handoff(&writable).is_err());
|
|
}
|
|
|
|
fn abs_rule(path: &Path, permission: Permission) -> ScopeRule {
|
|
ScopeRule {
|
|
target: path.to_path_buf(),
|
|
permission,
|
|
recursive: true,
|
|
}
|
|
}
|
|
|
|
const INTERNAL_REVIEWER_PROFILE: &str = r#"
|
|
slug = "reviewer"
|
|
scope = "workspace_read"
|
|
|
|
[model]
|
|
scheme = "anthropic"
|
|
model_id = "reviewer-model"
|
|
|
|
[model.auth]
|
|
kind = "none"
|
|
|
|
[engine]
|
|
instruction = "role.reviewer"
|
|
language = "Reviewerish"
|
|
max_tokens = 3333
|
|
|
|
[feature.ticket]
|
|
enabled = true
|
|
thread = true
|
|
|
|
[feature.memory]
|
|
enabled = true
|
|
|
|
[memory]
|
|
extract_threshold = 4000
|
|
"#;
|
|
|
|
#[tokio::test]
|
|
async fn parent_controller_notification_target_does_not_keep_channel_open() {
|
|
let (parent_method_tx, mut parent_method_rx) = mpsc::channel(1);
|
|
let target = ParentNotificationTarget::Controller(parent_method_tx.downgrade());
|
|
|
|
drop(parent_method_tx);
|
|
|
|
assert!(parent_method_rx.recv().await.is_none());
|
|
target.notify("late completion".to_string(), true);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reviewer_profile_spawns_and_notifies_parent_controller() {
|
|
let runtime = TempDir::new().unwrap();
|
|
let workspace_root = runtime.path().join("project");
|
|
let available_profiles = write_project_profile_registry(
|
|
&workspace_root,
|
|
Some("reviewer"),
|
|
&[("reviewer", "reviewer.toml", INTERNAL_REVIEWER_PROFILE)],
|
|
);
|
|
let mut manifest = parent_manifest(&workspace_root, None);
|
|
manifest.delegation_scope = ScopeConfig {
|
|
allow: vec![abs_rule(&workspace_root, Permission::Write)],
|
|
deny: Vec::new(),
|
|
};
|
|
let spawner_scope = SharedScope::new(Scope::from_config(&manifest.scope).unwrap());
|
|
let registry = SpawnedWorkerRegistry::new_internal("parent".into(), spawner_scope.clone());
|
|
let workspace_context = crate::worker::WorkerWorkspaceContext::with_client(
|
|
Some(WorkspaceId::new("workspace-test").unwrap()),
|
|
Arc::new(AvailableWorkspaceClient),
|
|
);
|
|
let calls = Arc::new(AtomicUsize::new(0));
|
|
let observed_parent_write_revoked = Arc::new(AtomicBool::new(false));
|
|
let observed_instruction_override = Arc::new(AtomicBool::new(false));
|
|
let fail_requests = Arc::new(AtomicBool::new(false));
|
|
let prompt_loader = PromptCatalogSource::builtins_only();
|
|
let (parent_method_tx, mut parent_method_rx) = mpsc::channel(8);
|
|
let source_workdir_session = workdir::delegation_capable_session(Arc::new(
|
|
workdir::LocalWorkdirSession::materialized_bound(
|
|
workdir::Workdir::new("test-workdir"),
|
|
workspace_root.clone(),
|
|
workspace_root.clone(),
|
|
spawner_scope.clone(),
|
|
workdir::WorkdirSessionCapabilities::ALL,
|
|
),
|
|
));
|
|
let tool = SubWorkerSpawnTool::new(
|
|
"parent".into(),
|
|
workspace_context,
|
|
ParentNotificationTarget::Controller(parent_method_tx.downgrade()),
|
|
runtime.path().to_path_buf(),
|
|
workspace_root.clone(),
|
|
workspace_root.clone(),
|
|
Some(source_workdir_session),
|
|
registry.clone(),
|
|
manifest.clone(),
|
|
prompt_loader,
|
|
available_profiles,
|
|
)
|
|
.with_internal_client(Box::new(ScriptedInternalClient {
|
|
calls: calls.clone(),
|
|
parent_scope: spawner_scope.clone(),
|
|
delegated_path: workspace_root.clone(),
|
|
observed_parent_write_revoked: observed_parent_write_revoked.clone(),
|
|
observed_instruction_override: observed_instruction_override.clone(),
|
|
fail_requests: fail_requests.clone(),
|
|
}));
|
|
let input = serde_json::json!({
|
|
"name": "reviewer-child",
|
|
"profile": "project:reviewer",
|
|
"instruction": "role.reviewer",
|
|
"task": "review immutable commit",
|
|
"scope": [{
|
|
"target": workspace_root.clone(),
|
|
"permission": "read",
|
|
"recursive": true
|
|
}]
|
|
});
|
|
|
|
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
|
|
|
let mut invalid_input = input.clone();
|
|
invalid_input["scope"][0]["target"] =
|
|
serde_json::json!(runtime.path().join("outside-parent-scope"));
|
|
tool.execute(
|
|
&serde_json::to_string(&invalid_input).unwrap(),
|
|
llm_engine::tool::ToolExecutionContext::direct(),
|
|
)
|
|
.await
|
|
.expect_err("invalid delegation must fail before child preparation");
|
|
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
|
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
|
|
|
let output = tool
|
|
.execute(
|
|
&serde_json::to_string(&input).unwrap(),
|
|
llm_engine::tool::ToolExecutionContext::direct(),
|
|
)
|
|
.await
|
|
.expect("spawn project reviewer as Internal Worker");
|
|
assert!(output.summary.contains("internal worker `reviewer-child`"));
|
|
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
|
let record = registry
|
|
.get_internal("reviewer-child")
|
|
.expect("Internal reviewer registry record");
|
|
assert!(record.installed_tools.iter().any(|name| name == "Read"));
|
|
for denied in ["Write", "Edit", "Bash"] {
|
|
assert!(
|
|
!record.installed_tools.iter().any(|name| name == denied),
|
|
"read-only child unexpectedly received {denied}: {:?}",
|
|
record.installed_tools
|
|
);
|
|
}
|
|
assert!(
|
|
!record
|
|
.installed_tools
|
|
.iter()
|
|
.any(|name| matches!(name.as_str(), "WorkdirAttachSelf" | "WorkdirDetachSelf"))
|
|
);
|
|
assert_eq!(
|
|
record.session.wait_until_idle().await,
|
|
crate::internal_worker::InternalWorkerSessionStatus::Idle
|
|
);
|
|
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
|
assert!(!observed_parent_write_revoked.load(Ordering::SeqCst));
|
|
assert!(observed_instruction_override.load(Ordering::SeqCst));
|
|
let completion = tokio::time::timeout(Duration::from_secs(1), parent_method_rx.recv())
|
|
.await
|
|
.expect("SubWorker completion must wake the parent method channel")
|
|
.expect("parent method channel remains open");
|
|
assert!(matches!(
|
|
completion,
|
|
Method::Notify {
|
|
message,
|
|
auto_run: true,
|
|
} if message.contains("SubWorker `reviewer-child` turn ended with status Idle")
|
|
));
|
|
assert!(!runtime.path().join("reviewer-child/sock").exists());
|
|
|
|
let duplicate_error = tool
|
|
.execute(
|
|
&serde_json::to_string(&input).unwrap(),
|
|
llm_engine::tool::ToolExecutionContext::direct(),
|
|
)
|
|
.await
|
|
.expect_err("duplicate child name must be rejected before a first turn starts");
|
|
assert!(
|
|
format!("{duplicate_error:?}").contains("already registered"),
|
|
"unexpected duplicate error: {duplicate_error:?}"
|
|
);
|
|
assert_eq!(
|
|
calls.load(Ordering::SeqCst),
|
|
1,
|
|
"duplicate rejection must not invoke the child provider"
|
|
);
|
|
assert!(matches!(
|
|
parent_method_rx.try_recv(),
|
|
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
|
|
));
|
|
|
|
let context = llm_engine::tool::ToolExecutionContext::direct();
|
|
let list = (crate::spawn::comm_tools::sub_worker_list_tool(registry.clone()))().1;
|
|
let listed = list.execute("{}", context.clone()).await.unwrap();
|
|
assert!(
|
|
listed
|
|
.content
|
|
.unwrap_or_default()
|
|
.contains("reviewer-child")
|
|
);
|
|
|
|
let observation =
|
|
crate::feature::builtin::worker_observation::SpawnedSubWorkerObservationProvider::new(
|
|
registry.clone(),
|
|
);
|
|
let observed_child =
|
|
crate::feature::builtin::worker_observation::WorkerObservationSubjectRef::SubWorker {
|
|
name: "reviewer-child".to_string(),
|
|
};
|
|
let first_capture = crate::feature::builtin::worker_observation::WorkerObservationProvider::capture_worker_session(
|
|
&observation,
|
|
&observed_child,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(first_capture.items.iter().any(|item| {
|
|
matches!(item, Item::Message { role: Role::Assistant, content, .. } if content.iter().any(|part| matches!(part, ContentPart::Text { text } if text.contains("reviewed"))))
|
|
}));
|
|
|
|
let send = (crate::spawn::comm_tools::sub_worker_send_tool(registry.clone()))().1;
|
|
send.execute(
|
|
r#"{"name":"reviewer-child","message":"review follow-up"}"#,
|
|
context.clone(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
record.session.wait_until_idle().await,
|
|
crate::internal_worker::InternalWorkerSessionStatus::Idle
|
|
);
|
|
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
|
let latest_capture = crate::feature::builtin::worker_observation::WorkerObservationProvider::capture_worker_session(
|
|
&observation,
|
|
&observed_child,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(latest_capture.items.len() > first_capture.items.len());
|
|
|
|
fail_requests.store(true, Ordering::SeqCst);
|
|
send.execute(
|
|
r#"{"name":"reviewer-child","message":"trigger terminal failure"}"#,
|
|
context.clone(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
record.session.wait_until_idle().await,
|
|
InternalWorkerSessionStatus::Failed
|
|
);
|
|
assert_eq!(calls.load(Ordering::SeqCst), 3);
|
|
assert!(
|
|
spawner_scope.snapshot().is_writable(&workspace_root),
|
|
"Failed terminal child must release its delegated Workdir session"
|
|
);
|
|
assert!(
|
|
!record.workdir_delegation.is_active(),
|
|
"failed child must revoke cloned scoped sessions"
|
|
);
|
|
assert!(registry.get_internal("reviewer-child").is_some());
|
|
|
|
let stop = (crate::spawn::comm_tools::sub_worker_stop_tool(registry.clone()))().1;
|
|
stop.execute(r#"{"name":"reviewer-child"}"#, context)
|
|
.await
|
|
.unwrap();
|
|
assert!(registry.get_internal("reviewer-child").is_none());
|
|
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
|
|
|
fail_requests.store(false, Ordering::SeqCst);
|
|
let mut teardown_input = input;
|
|
teardown_input["name"] = serde_json::json!("reviewer-child-parent-drop");
|
|
tool.execute(
|
|
&serde_json::to_string(&teardown_input).unwrap(),
|
|
llm_engine::tool::ToolExecutionContext::direct(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
|
drop(list);
|
|
drop(send);
|
|
drop(stop);
|
|
drop(observation);
|
|
drop(tool);
|
|
drop(registry);
|
|
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_worker_input_schema_includes_optional_cwd() {
|
|
let schema = serde_json::to_value(schemars::schema_for!(SubWorkerSpawnInput)).unwrap();
|
|
let properties = schema
|
|
.get("properties")
|
|
.and_then(serde_json::Value::as_object)
|
|
.expect("schema properties");
|
|
assert!(properties.contains_key("cwd"), "schema: {schema}");
|
|
let required = schema
|
|
.get("required")
|
|
.and_then(serde_json::Value::as_array)
|
|
.expect("schema required list");
|
|
assert!(
|
|
!required.iter().any(|value| value.as_str() == Some("cwd")),
|
|
"cwd must remain optional: {schema}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn orchestration_delegation_allows_root_read_and_worktree_writes_not_root_writes() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let workspace_root = tmp.path().join("original");
|
|
let implementation_worktree = workspace_root.join(".worktree/ticket-1");
|
|
std::fs::create_dir_all(&implementation_worktree).unwrap();
|
|
let delegation = DelegationScope::from_config(&ScopeConfig {
|
|
allow: vec![
|
|
abs_rule(&workspace_root, Permission::Read),
|
|
abs_rule(&workspace_root.join(".worktree"), Permission::Write),
|
|
],
|
|
deny: Vec::new(),
|
|
})
|
|
.unwrap();
|
|
|
|
let coder_scope = vec![
|
|
abs_rule(&workspace_root, Permission::Read),
|
|
abs_rule(&implementation_worktree, Permission::Write),
|
|
];
|
|
assert!(
|
|
coder_scope
|
|
.iter()
|
|
.all(|rule| delegation.allows_rule(rule).unwrap())
|
|
);
|
|
|
|
let reviewer_scope = vec![abs_rule(&workspace_root, Permission::Read)];
|
|
assert!(
|
|
reviewer_scope
|
|
.iter()
|
|
.all(|rule| delegation.allows_rule(rule).unwrap())
|
|
);
|
|
|
|
let root_writer_scope = vec![abs_rule(&workspace_root, Permission::Write)];
|
|
assert!(
|
|
root_writer_scope
|
|
.iter()
|
|
.any(|rule| !delegation.allows_rule(rule).unwrap())
|
|
);
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ScriptedInternalClient {
|
|
calls: Arc<AtomicUsize>,
|
|
parent_scope: SharedScope,
|
|
delegated_path: PathBuf,
|
|
observed_parent_write_revoked: Arc<std::sync::atomic::AtomicBool>,
|
|
observed_instruction_override: Arc<std::sync::atomic::AtomicBool>,
|
|
fail_requests: Arc<AtomicBool>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl LlmClient for ScriptedInternalClient {
|
|
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
|
Box::new(self.clone())
|
|
}
|
|
|
|
async fn stream(
|
|
&self,
|
|
request: Request,
|
|
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
|
|
{
|
|
self.calls.fetch_add(1, Ordering::SeqCst);
|
|
self.observed_parent_write_revoked.store(
|
|
!self
|
|
.parent_scope
|
|
.snapshot()
|
|
.is_writable(&self.delegated_path),
|
|
Ordering::SeqCst,
|
|
);
|
|
self.observed_instruction_override.store(
|
|
request
|
|
.system_prompt
|
|
.as_deref()
|
|
.is_some_and(|prompt| prompt.contains("review")),
|
|
Ordering::SeqCst,
|
|
);
|
|
if self.fail_requests.load(Ordering::SeqCst) {
|
|
return Err(ClientError::Config(
|
|
"scripted Internal Worker failure".into(),
|
|
));
|
|
}
|
|
Ok(Box::pin(futures::stream::iter(vec![
|
|
Ok(LlmEvent::text_block_start(0)),
|
|
Ok(LlmEvent::text_delta(0, "reviewed")),
|
|
Ok(LlmEvent::text_block_stop(0, None)),
|
|
Ok(LlmEvent::Status(StatusEvent {
|
|
status: ResponseStatus::Completed,
|
|
})),
|
|
])))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct AvailableWorkspaceClient;
|
|
|
|
impl WorkspaceClient for AvailableWorkspaceClient {
|
|
fn workspace_id(&self) -> Option<&str> {
|
|
Some("workspace-test")
|
|
}
|
|
|
|
fn kind(&self) -> &str {
|
|
"test"
|
|
}
|
|
|
|
fn is_available(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn execute(
|
|
&self,
|
|
_request: WorkspaceRequest,
|
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
|
Err(WorkspaceClientError::Unavailable("not invoked".into()))
|
|
}
|
|
}
|
|
|
|
fn parent_manifest(root: &Path, deny: Option<&Path>) -> WorkerManifest {
|
|
WorkerManifestConfig {
|
|
worker: WorkerMetaConfig {
|
|
name: Some("parent".into()),
|
|
},
|
|
model: ModelManifest {
|
|
scheme: Some(SchemeKind::Anthropic),
|
|
model_id: Some("parent-model".into()),
|
|
auth: Some(AuthRef::None),
|
|
..Default::default()
|
|
},
|
|
engine: EngineManifestConfig {
|
|
instruction: Some("default".into()),
|
|
language: Some("Parentish".into()),
|
|
max_tokens: Some(1234),
|
|
stop_sequences: Some(vec!["STOP".into()]),
|
|
..Default::default()
|
|
},
|
|
scope: ScopeConfig {
|
|
allow: vec![abs_rule(root, Permission::Write)],
|
|
deny: deny
|
|
.map(|path| vec![abs_rule(path, Permission::Read)])
|
|
.unwrap_or_default(),
|
|
},
|
|
session: Some(SessionConfigPartial {
|
|
record_event_trace: Some(true),
|
|
}),
|
|
..Default::default()
|
|
}
|
|
.try_into()
|
|
.unwrap()
|
|
}
|
|
|
|
fn write_project_profile_registry(
|
|
project: &Path,
|
|
default: Option<&str>,
|
|
profiles: &[(&str, &str, &str)],
|
|
) -> AvailableProfiles {
|
|
let profile_dir = project.join("explicit-project-profiles");
|
|
std::fs::create_dir_all(&profile_dir).unwrap();
|
|
let mut registry_toml = String::new();
|
|
if let Some(default) = default {
|
|
registry_toml.push_str(&format!("default = \"{default}\"\n"));
|
|
}
|
|
registry_toml.push_str("[profile]\n");
|
|
for (name, file, body) in profiles {
|
|
std::fs::write(profile_dir.join(file), body).unwrap();
|
|
registry_toml.push_str(&format!("{name} = \"explicit-project-profiles/{file}\"\n"));
|
|
}
|
|
let registry_path = project.join("explicit-project-profiles.toml");
|
|
std::fs::write(®istry_path, registry_toml).unwrap();
|
|
AvailableProfiles {
|
|
registry: Some(
|
|
ProfileDiscovery::with_sources(None, Some(registry_path))
|
|
.discover()
|
|
.unwrap(),
|
|
),
|
|
diagnostic: None,
|
|
}
|
|
}
|
|
|
|
fn child_config_from_profile(
|
|
spawner_manifest: &WorkerManifest,
|
|
available: &AvailableProfiles,
|
|
cwd: &Path,
|
|
name: &str,
|
|
instruction_override: Option<&str>,
|
|
scope: &[ScopeRule],
|
|
selector: SpawnProfileSelector,
|
|
) -> WorkerManifestConfig {
|
|
let json = build_spawn_config_json_for_profile(
|
|
spawner_manifest,
|
|
available,
|
|
cwd,
|
|
name,
|
|
instruction_override,
|
|
scope,
|
|
selector,
|
|
)
|
|
.unwrap();
|
|
serde_json::from_str(&json).unwrap()
|
|
}
|
|
|
|
const CODER_PROFILE: &str = r#"
|
|
slug = "coder"
|
|
scope = "workspace_write"
|
|
|
|
[model]
|
|
scheme = "anthropic"
|
|
model_id = "coder-model"
|
|
|
|
[engine]
|
|
instruction = "role.coder"
|
|
language = "Coderish"
|
|
max_tokens = 2222
|
|
"#;
|
|
|
|
const REVIEWER_PROFILE: &str = r#"
|
|
slug = "reviewer"
|
|
scope = "workspace_write"
|
|
|
|
[model]
|
|
scheme = "anthropic"
|
|
model_id = "reviewer-model"
|
|
|
|
[engine]
|
|
instruction = "role.reviewer"
|
|
language = "Reviewerish"
|
|
max_tokens = 3333
|
|
"#;
|
|
|
|
#[test]
|
|
fn spawn_config_inherits_inline_spawner_model() {
|
|
let model = ModelManifest {
|
|
scheme: Some(SchemeKind::Anthropic),
|
|
base_url: Some("https://example.test".into()),
|
|
model_id: Some("claude-sonnet-4".into()),
|
|
auth: Some(AuthRef::ApiKey {
|
|
file: Some(PathBuf::from("/etc/keys/anthropic")),
|
|
}),
|
|
..Default::default()
|
|
};
|
|
|
|
let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap();
|
|
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
|
|
|
|
assert_eq!(parsed.model.scheme, Some(SchemeKind::Anthropic));
|
|
assert_eq!(parsed.model.model_id.as_deref(), Some("claude-sonnet-4"));
|
|
assert_eq!(
|
|
parsed.model.base_url.as_deref(),
|
|
Some("https://example.test")
|
|
);
|
|
let file = match parsed.model.auth {
|
|
Some(AuthRef::ApiKey { file, .. }) => file,
|
|
_ => panic!("expected ApiKey"),
|
|
};
|
|
assert_eq!(file.as_deref(), Some(Path::new("/etc/keys/anthropic")));
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_config_inherits_ref_spawner_model() {
|
|
let model = ModelManifest {
|
|
ref_: Some("anthropic/claude-sonnet-4-6".into()),
|
|
..Default::default()
|
|
};
|
|
let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap();
|
|
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
|
|
assert_eq!(
|
|
parsed.model.ref_.as_deref(),
|
|
Some("anthropic/claude-sonnet-4-6")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_config_preserves_record_event_trace_when_enabled() {
|
|
let model = ModelManifest {
|
|
ref_: Some("anthropic/claude-sonnet-4-6".into()),
|
|
..Default::default()
|
|
};
|
|
let scope = vec![ScopeRule {
|
|
target: PathBuf::from("/tmp/child"),
|
|
permission: Permission::Read,
|
|
recursive: true,
|
|
}];
|
|
|
|
let config_json =
|
|
build_spawn_config_json("child", "default", &scope, &model, true).unwrap();
|
|
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
|
|
assert_eq!(
|
|
parsed.session.as_ref().and_then(|s| s.record_event_trace),
|
|
Some(true)
|
|
);
|
|
|
|
let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults()
|
|
.merge(parsed)
|
|
.try_into()
|
|
.unwrap();
|
|
assert!(manifest.session.record_event_trace);
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_config_omits_record_event_trace_when_disabled() {
|
|
let model = ModelManifest {
|
|
ref_: Some("anthropic/claude-sonnet-4-6".into()),
|
|
..Default::default()
|
|
};
|
|
let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap();
|
|
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
|
|
|
|
assert!(parsed.session.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn omitted_profile_resolves_effective_registry_default() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let project = tmp.path().join("project");
|
|
let delegated = tmp.path().join("delegated");
|
|
std::fs::create_dir_all(&project).unwrap();
|
|
std::fs::create_dir_all(&delegated).unwrap();
|
|
let available = write_project_profile_registry(
|
|
&project,
|
|
Some("reviewer"),
|
|
&[
|
|
("coder", "coder.toml", CODER_PROFILE),
|
|
("reviewer", "reviewer.toml", REVIEWER_PROFILE),
|
|
],
|
|
);
|
|
let parent = parent_manifest(&project, None);
|
|
let scope = vec![abs_rule(&delegated, Permission::Read)];
|
|
|
|
let config = child_config_from_profile(
|
|
&parent,
|
|
&available,
|
|
&project,
|
|
"child-default",
|
|
None,
|
|
&scope,
|
|
SpawnProfileSelector::Default,
|
|
);
|
|
|
|
assert_eq!(config.worker.name.as_deref(), Some("child-default"));
|
|
assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model"));
|
|
assert_eq!(config.engine.instruction.as_deref(), Some("role.reviewer"));
|
|
assert_eq!(config.engine.language.as_deref(), Some("Reviewerish"));
|
|
assert_eq!(config.scope.allow, scope);
|
|
assert!(config.scope.deny.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn source_qualified_profile_role_config_reaches_spawn_config() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let project = tmp.path().join("project");
|
|
let delegated = tmp.path().join("delegated");
|
|
std::fs::create_dir_all(&project).unwrap();
|
|
std::fs::create_dir_all(&delegated).unwrap();
|
|
let available = write_project_profile_registry(
|
|
&project,
|
|
Some("coder"),
|
|
&[
|
|
("coder", "coder.toml", CODER_PROFILE),
|
|
("reviewer", "reviewer.toml", REVIEWER_PROFILE),
|
|
],
|
|
);
|
|
let parent = parent_manifest(&project, None);
|
|
let scope = vec![abs_rule(&delegated, Permission::Write)];
|
|
|
|
let config = child_config_from_profile(
|
|
&parent,
|
|
&available,
|
|
&project,
|
|
"review-child",
|
|
None,
|
|
&scope,
|
|
SpawnProfileSelector::Registry(ProfileSelector::source_named(
|
|
ProfileRegistrySource::Project,
|
|
"reviewer",
|
|
)),
|
|
);
|
|
|
|
assert_eq!(config.worker.name.as_deref(), Some("review-child"));
|
|
assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model"));
|
|
assert_eq!(config.engine.instruction.as_deref(), Some("role.reviewer"));
|
|
assert_eq!(config.engine.language.as_deref(), Some("Reviewerish"));
|
|
assert_eq!(config.engine.max_tokens, Some(3333));
|
|
assert_eq!(config.scope.allow, scope);
|
|
assert!(config.scope.deny.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn inherit_copies_reusable_parent_fields_and_replaces_runtime_authority() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let parent_root = tmp.path().join("parent-root");
|
|
let parent_deny = parent_root.join("secret");
|
|
let delegated = tmp.path().join("delegated");
|
|
std::fs::create_dir_all(&parent_deny).unwrap();
|
|
std::fs::create_dir_all(&delegated).unwrap();
|
|
let parent = parent_manifest(&parent_root, Some(&parent_deny));
|
|
let scope = vec![abs_rule(&delegated, Permission::Read)];
|
|
let available = AvailableProfiles {
|
|
registry: None,
|
|
diagnostic: None,
|
|
};
|
|
|
|
let config = child_config_from_profile(
|
|
&parent,
|
|
&available,
|
|
tmp.path(),
|
|
"inherited-child",
|
|
None,
|
|
&scope,
|
|
SpawnProfileSelector::Inherit,
|
|
);
|
|
|
|
assert_eq!(config.worker.name.as_deref(), Some("inherited-child"));
|
|
assert_eq!(config.model.model_id.as_deref(), Some("parent-model"));
|
|
assert_eq!(config.engine.instruction.as_deref(), Some("default"));
|
|
assert_eq!(config.engine.language.as_deref(), Some("Parentish"));
|
|
assert_eq!(config.engine.max_tokens, Some(1234));
|
|
assert_eq!(
|
|
config.engine.stop_sequences.as_deref(),
|
|
Some(&["STOP".to_string()][..])
|
|
);
|
|
assert_eq!(
|
|
config.session.as_ref().and_then(|s| s.record_event_trace),
|
|
Some(true)
|
|
);
|
|
assert_eq!(config.scope.allow, scope);
|
|
assert!(config.scope.deny.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn instruction_override_changes_only_worker_instruction() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let project = tmp.path().join("project");
|
|
let delegated = tmp.path().join("delegated");
|
|
std::fs::create_dir_all(&project).unwrap();
|
|
std::fs::create_dir_all(&delegated).unwrap();
|
|
let available = write_project_profile_registry(
|
|
&project,
|
|
Some("reviewer"),
|
|
&[("reviewer", "reviewer.toml", REVIEWER_PROFILE)],
|
|
);
|
|
let parent = parent_manifest(&project, None);
|
|
let scope = vec![abs_rule(&delegated, Permission::Write)];
|
|
|
|
let config = child_config_from_profile(
|
|
&parent,
|
|
&available,
|
|
&project,
|
|
"override-child",
|
|
Some("role.reviewer"),
|
|
&scope,
|
|
SpawnProfileSelector::Default,
|
|
);
|
|
|
|
assert_eq!(config.engine.instruction.as_deref(), Some("role.reviewer"));
|
|
assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model"));
|
|
assert_eq!(config.engine.language.as_deref(), Some("Reviewerish"));
|
|
assert_eq!(config.engine.max_tokens, Some(3333));
|
|
assert_eq!(config.scope.allow, scope);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_and_inherited_scope_are_replaced_by_delegated_scope() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let project = tmp.path().join("project");
|
|
let delegated = tmp.path().join("delegated");
|
|
let parent_root = tmp.path().join("parent-root");
|
|
std::fs::create_dir_all(&project).unwrap();
|
|
std::fs::create_dir_all(&delegated).unwrap();
|
|
std::fs::create_dir_all(&parent_root).unwrap();
|
|
let available = write_project_profile_registry(
|
|
&project,
|
|
Some("reviewer"),
|
|
&[("reviewer", "reviewer.toml", REVIEWER_PROFILE)],
|
|
);
|
|
let parent = parent_manifest(&parent_root, Some(&parent_root.join("deny")));
|
|
let scope = vec![abs_rule(&delegated, Permission::Read)];
|
|
|
|
let profile_config = child_config_from_profile(
|
|
&parent,
|
|
&available,
|
|
&project,
|
|
"profile-child",
|
|
None,
|
|
&scope,
|
|
SpawnProfileSelector::Default,
|
|
);
|
|
let inherit_config = child_config_from_profile(
|
|
&parent,
|
|
&available,
|
|
&project,
|
|
"inherit-child",
|
|
None,
|
|
&scope,
|
|
SpawnProfileSelector::Inherit,
|
|
);
|
|
|
|
for config in [profile_config, inherit_config] {
|
|
assert_eq!(config.scope.allow, scope);
|
|
assert!(config.scope.deny.is_empty());
|
|
assert!(!config.scope.allow.iter().any(|rule| rule.target == project));
|
|
assert!(
|
|
!config
|
|
.scope
|
|
.allow
|
|
.iter()
|
|
.any(|rule| rule.target == parent_root)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_ambiguous_and_no_default_diagnostics_include_available_selectors() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let project = tmp.path().join("project");
|
|
std::fs::create_dir_all(&project).unwrap();
|
|
let available = write_project_profile_registry(
|
|
&project,
|
|
None,
|
|
&[("coder", "coder.toml", CODER_PROFILE)],
|
|
);
|
|
let parent = parent_manifest(&project, None);
|
|
let scope = vec![abs_rule(&project, Permission::Read)];
|
|
|
|
let invalid = parse_spawn_profile_selector(Some("./reviewer.toml"))
|
|
.map_err(|msg| format!("{msg}{}", available.error_suffix()))
|
|
.unwrap_err();
|
|
assert!(invalid.contains("Use `default`, `inherit`"));
|
|
assert!(invalid.contains("`project:coder`"));
|
|
|
|
let default_error = build_spawn_config_json_for_profile(
|
|
&parent,
|
|
&available,
|
|
&project,
|
|
"child",
|
|
None,
|
|
&scope,
|
|
SpawnProfileSelector::Default,
|
|
)
|
|
.unwrap_err();
|
|
assert!(default_error.contains("no default profile is configured"));
|
|
|
|
let user_config = tmp.path().join("user-profiles.toml");
|
|
std::fs::write(&user_config, "[profile]\ncoder = \"user-coder.toml\"\n").unwrap();
|
|
let project_config = project.join("explicit-project-profiles.toml");
|
|
let ambiguous = AvailableProfiles {
|
|
registry: Some(
|
|
ProfileDiscovery::with_sources(Some(user_config), Some(project_config))
|
|
.discover()
|
|
.unwrap(),
|
|
),
|
|
diagnostic: None,
|
|
};
|
|
let ambiguous_error = build_spawn_config_json_for_profile(
|
|
&parent,
|
|
&ambiguous,
|
|
&project,
|
|
"child",
|
|
None,
|
|
&scope,
|
|
SpawnProfileSelector::Registry(ProfileSelector::named("coder")),
|
|
)
|
|
.unwrap_err();
|
|
assert!(ambiguous_error.contains("ambiguous"), "{ambiguous_error}");
|
|
assert!(ambiguous_error.contains("user:coder"));
|
|
assert!(ambiguous_error.contains("project:coder"));
|
|
assert!(ambiguous_error.contains("Use `default`, `inherit`"));
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_profile_selector_rejects_path_like_values() {
|
|
for raw in [
|
|
"./reviewer.toml",
|
|
"path:./reviewer.toml",
|
|
"/tmp/reviewer.toml",
|
|
"legacy.nix",
|
|
] {
|
|
let err = parse_spawn_profile_selector(Some(raw)).unwrap_err();
|
|
assert!(err.contains("registry selectors only"), "{raw}: {err}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_profile_selector_accepts_default_inherit_and_registry() {
|
|
assert_eq!(
|
|
parse_spawn_profile_selector(None).unwrap(),
|
|
SpawnProfileSelector::Default
|
|
);
|
|
assert_eq!(
|
|
parse_spawn_profile_selector(Some("inherit")).unwrap(),
|
|
SpawnProfileSelector::Inherit
|
|
);
|
|
assert_eq!(
|
|
parse_spawn_profile_selector(Some("project:reviewer")).unwrap(),
|
|
SpawnProfileSelector::Registry(ProfileSelector::source_named(
|
|
ProfileRegistrySource::Project,
|
|
"reviewer"
|
|
))
|
|
);
|
|
assert_eq!(
|
|
parse_spawn_profile_selector(Some("coder")).unwrap(),
|
|
SpawnProfileSelector::Registry(ProfileSelector::named("coder"))
|
|
);
|
|
}
|
|
}
|