worker: add typed feature services for coder spawn

This commit is contained in:
2026-08-14 00:02:13 +09:00
parent e47eca53a2
commit f9d328f2db
12 changed files with 962 additions and 163 deletions
+71 -13
View File
@@ -20,7 +20,7 @@ use crate::{
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig, ScopeConfig,
SessionConfig, SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig,
ToolPermissionRule, WebConfig, WorkerManifest, WorkerMeta,
ToolPermissionRule, WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta,
};
/// Partial-form Worker manifest. Every field is optional; one or more
@@ -89,7 +89,7 @@ pub struct FeatureConfigPartial {
#[serde(default)]
pub flow: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub worker: Option<FeatureFlagConfigPartial>,
pub worker: Option<WorkerFeatureConfigPartial>,
#[serde(default)]
pub objective: Option<FeatureFlagConfigPartial>,
#[serde(default)]
@@ -97,6 +97,8 @@ pub struct FeatureConfigPartial {
#[serde(default)]
pub ticket: Option<TicketFeatureConfigPartial>,
#[serde(default)]
pub orchestration: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub plugins: Option<FeatureFlagConfigPartial>,
}
@@ -113,7 +115,7 @@ impl FeatureConfigPartial {
FeatureFlagConfigPartial::merge,
),
flow: merge_option(self.flow, other.flow, FeatureFlagConfigPartial::merge),
worker: merge_option(self.worker, other.worker, FeatureFlagConfigPartial::merge),
worker: merge_option(self.worker, other.worker, WorkerFeatureConfigPartial::merge),
objective: merge_option(
self.objective,
other.objective,
@@ -125,6 +127,11 @@ impl FeatureConfigPartial {
FeatureFlagConfigPartial::merge,
),
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
orchestration: merge_option(
self.orchestration,
other.orchestration,
FeatureFlagConfigPartial::merge,
),
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
}
}
@@ -144,6 +151,32 @@ impl FeatureFlagConfigPartial {
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkerFeatureConfigPartial {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub direct_spawn: Option<bool>,
}
impl WorkerFeatureConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
enabled: other.enabled.or(self.enabled),
direct_spawn: other.direct_spawn.or(self.direct_spawn),
}
}
}
impl From<WorkerFeatureConfigPartial> for WorkerFeatureConfig {
fn from(value: WorkerFeatureConfigPartial) -> Self {
Self {
enabled: value.enabled.unwrap_or(false),
direct_spawn: value.direct_spawn.unwrap_or(true),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MemoryFeatureConfigPartial {
#[serde(default)]
@@ -168,7 +201,7 @@ pub struct TicketFeatureConfigPartial {
pub authoring: Option<bool>,
pub thread: Option<bool>,
pub intake: Option<bool>,
pub orchestration_control: Option<bool>,
pub workflow: Option<bool>,
}
impl TicketFeatureConfigPartial {
@@ -178,7 +211,7 @@ impl TicketFeatureConfigPartial {
authoring: other.authoring.or(self.authoring),
thread: other.thread.or(self.thread),
intake: other.intake.or(self.intake),
orchestration_control: other.orchestration_control.or(self.orchestration_control),
workflow: other.workflow.or(self.workflow),
}
}
}
@@ -200,7 +233,7 @@ impl From<FeatureConfigPartial> for FeatureConfig {
flow: value.flow.map(FeatureFlagConfig::from).unwrap_or_default(),
worker: value
.worker
.map(FeatureFlagConfig::from)
.map(WorkerFeatureConfig::from)
.unwrap_or_default(),
objective: value
.objective
@@ -214,6 +247,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
.ticket
.map(TicketFeatureConfig::from)
.unwrap_or_default(),
orchestration: value
.orchestration
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
plugins: value
.plugins
.map(FeatureFlagConfig::from)
@@ -238,6 +275,15 @@ impl From<FeatureFlagConfig> for FeatureFlagConfigPartial {
}
}
impl From<WorkerFeatureConfig> for WorkerFeatureConfigPartial {
fn from(value: WorkerFeatureConfig) -> Self {
Self {
enabled: Some(value.enabled),
direct_spawn: Some(value.direct_spawn),
}
}
}
impl From<MemoryFeatureConfigPartial> for MemoryFeatureConfig {
fn from(value: MemoryFeatureConfigPartial) -> Self {
Self {
@@ -263,7 +309,7 @@ impl From<TicketFeatureConfigPartial> for TicketFeatureConfig {
authoring: value.authoring.unwrap_or_default(),
thread: value.thread.unwrap_or_default(),
intake: value.intake.unwrap_or_default(),
orchestration_control: value.orchestration_control.unwrap_or_default(),
workflow: value.workflow.unwrap_or_default(),
}
}
}
@@ -275,7 +321,7 @@ impl From<TicketFeatureConfig> for TicketFeatureConfigPartial {
authoring: Some(value.authoring),
thread: Some(value.thread),
intake: Some(value.intake),
orchestration_control: Some(value.orchestration_control),
workflow: Some(value.workflow),
}
}
}
@@ -293,6 +339,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
objective: Some(value.objective.into()),
manage_workdir: Some(value.manage_workdir.into()),
ticket: Some(value.ticket.into()),
orchestration: Some(value.orchestration.into()),
plugins: Some(value.plugins.into()),
}
}
@@ -1866,7 +1913,10 @@ enabled = true
authoring = false
thread = false
intake = false
orchestration_control = false
workflow = false
[feature.orchestration]
enabled = false
"#,
)
.unwrap();
@@ -1900,7 +1950,8 @@ orchestration_control = false
assert!(!manifest.feature.ticket.authoring);
assert!(!manifest.feature.ticket.thread);
assert!(!manifest.feature.ticket.intake);
assert!(!manifest.feature.ticket.orchestration_control);
assert!(!manifest.feature.ticket.workflow);
assert!(!manifest.feature.orchestration.enabled);
assert!(!manifest.feature.memory.enabled);
assert!(!manifest.feature.memory.staging);
assert!(!manifest.feature.objective.enabled);
@@ -1921,7 +1972,10 @@ enabled = true
authoring = false
thread = false
intake = false
orchestration_control = false
workflow = false
[feature.orchestration]
enabled = false
"#,
)
.unwrap();
@@ -1929,7 +1983,10 @@ orchestration_control = false
r#"
[feature.ticket]
thread = true
orchestration_control = true
workflow = true
[feature.orchestration]
enabled = true
[feature.memory]
staging = true
@@ -1977,7 +2034,8 @@ enabled = true
assert!(!manifest.feature.ticket.authoring);
assert!(manifest.feature.ticket.thread);
assert!(!manifest.feature.ticket.intake);
assert!(manifest.feature.ticket.orchestration_control);
assert!(manifest.feature.ticket.workflow);
assert!(manifest.feature.orchestration.enabled);
assert!(manifest.feature.objective.enabled);
assert!(manifest.feature.web.enabled);
assert!(!manifest.feature.sub_worker.enabled);
+42 -3
View File
@@ -117,7 +117,7 @@ pub struct FeatureConfig {
#[serde(default)]
pub flow: FeatureFlagConfig,
#[serde(default)]
pub worker: FeatureFlagConfig,
pub worker: WorkerFeatureConfig,
#[serde(default)]
pub objective: FeatureFlagConfig,
#[serde(default)]
@@ -125,6 +125,8 @@ pub struct FeatureConfig {
#[serde(default)]
pub ticket: TicketFeatureConfig,
#[serde(default)]
pub orchestration: FeatureFlagConfig,
#[serde(default)]
pub plugins: FeatureFlagConfig,
}
@@ -137,10 +139,11 @@ impl Default for FeatureConfig {
image: FeatureFlagConfig::disabled(),
sub_worker: FeatureFlagConfig::disabled(),
flow: FeatureFlagConfig::disabled(),
worker: FeatureFlagConfig::disabled(),
worker: WorkerFeatureConfig::disabled(),
objective: FeatureFlagConfig::disabled(),
manage_workdir: FeatureFlagConfig::disabled(),
ticket: TicketFeatureConfig::default(),
orchestration: FeatureFlagConfig::disabled(),
plugins: FeatureFlagConfig::disabled(),
}
}
@@ -168,6 +171,42 @@ impl Default for FeatureFlagConfig {
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerFeatureConfig {
#[serde(default)]
pub enabled: bool,
/// Exposes the generic WorkerSpawn tool. Stateful lifecycle services remain
/// available to dependent semantic features when this is false.
#[serde(default = "default_true")]
pub direct_spawn: bool,
}
impl WorkerFeatureConfig {
pub const fn disabled() -> Self {
Self {
enabled: false,
direct_spawn: true,
}
}
pub const fn enabled() -> Self {
Self {
enabled: true,
direct_spawn: true,
}
}
}
impl Default for WorkerFeatureConfig {
fn default() -> Self {
Self::disabled()
}
}
const fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct MemoryFeatureConfig {
#[serde(default)]
@@ -210,7 +249,7 @@ pub struct TicketFeatureConfig {
#[serde(default)]
pub intake: bool,
#[serde(default)]
pub orchestration_control: bool,
pub workflow: bool,
}
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are
+18 -12
View File
@@ -1002,16 +1002,19 @@ fn apply_role_profile(
value["feature"]["image"] = serde_json::json!({ "enabled": true });
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
value["feature"]["worker"] =
serde_json::json!({ "enabled": matches!(slug, "companion" | "orchestrator") });
value["feature"]["worker"] = serde_json::json!({
"enabled": matches!(slug, "companion" | "orchestrator"),
"direct_spawn": slug != "orchestrator"
});
value["feature"]["manage_workdir"] = serde_json::json!({ "enabled": slug == "orchestrator" });
value["feature"]["orchestration"] = serde_json::json!({ "enabled": slug == "orchestrator" });
let ticket = match slug {
"companion" => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }),
"intake" => {
serde_json::json!({ "enabled": true, "authoring": true, "thread": true, "intake": true })
}
"orchestrator" => {
serde_json::json!({ "enabled": true, "thread": true, "orchestration_control": true })
serde_json::json!({ "enabled": true, "thread": true, "workflow": true })
}
"coder" => serde_json::json!({ "enabled": true, "thread": true }),
"reviewer" => serde_json::json!({ "enabled": true, "thread": true }),
@@ -1474,7 +1477,7 @@ mod tests {
assert!(companion.feature.ticket.thread);
assert!(companion.feature.objective.enabled);
assert!(!companion.feature.ticket.intake);
assert!(!companion.feature.ticket.orchestration_control);
assert!(!companion.feature.orchestration.enabled);
assert_eq!(
companion.compaction.as_ref().unwrap().threshold,
Some(240000)
@@ -1503,7 +1506,7 @@ mod tests {
assert!(intake.feature.objective.enabled);
assert!(!intake.feature.manage_workdir.enabled);
assert!(intake.feature.ticket.intake);
assert!(!intake.feature.ticket.orchestration_control);
assert!(!intake.feature.orchestration.enabled);
assert!(intake.scope.allow.is_empty());
assert!(intake.delegation_scope.allow.is_empty());
assert_eq!(intake.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
@@ -1514,6 +1517,7 @@ mod tests {
assert!(orchestrator.feature.task.enabled);
assert!(!orchestrator.feature.sub_worker.enabled);
assert!(orchestrator.feature.worker.enabled);
assert!(!orchestrator.feature.worker.direct_spawn);
assert!(orchestrator.feature.ticket.enabled);
assert!(orchestrator.feature.ticket.enabled);
assert!(!orchestrator.feature.ticket.authoring);
@@ -1521,7 +1525,8 @@ mod tests {
assert!(orchestrator.feature.objective.enabled);
assert!(orchestrator.feature.manage_workdir.enabled);
assert!(!orchestrator.feature.ticket.intake);
assert!(orchestrator.feature.ticket.orchestration_control);
assert!(orchestrator.feature.ticket.workflow);
assert!(orchestrator.feature.orchestration.enabled);
assert!(orchestrator.scope.allow.is_empty());
assert!(orchestrator.delegation_scope.allow.is_empty());
assert_eq!(
@@ -1548,7 +1553,7 @@ mod tests {
assert!(coder.feature.objective.enabled);
assert!(!coder.feature.manage_workdir.enabled);
assert!(!coder.feature.ticket.intake);
assert!(!coder.feature.ticket.orchestration_control);
assert!(!coder.feature.orchestration.enabled);
let reviewer = resolve("reviewer");
assert!(reviewer.feature.task.enabled);
assert!(!reviewer.feature.sub_worker.enabled);
@@ -1561,7 +1566,7 @@ mod tests {
assert!(reviewer.feature.objective.enabled);
assert!(!reviewer.feature.manage_workdir.enabled);
assert!(!reviewer.feature.ticket.intake);
assert!(!reviewer.feature.ticket.orchestration_control);
assert!(!reviewer.feature.orchestration.enabled);
assert!(reviewer.scope.allow.is_empty());
assert!(reviewer.delegation_scope.allow.is_empty());
assert_eq!(reviewer.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
@@ -1574,7 +1579,7 @@ mod tests {
let prompt = include_str!("../../../resources/prompts/role/orchestrator.md");
assert!(prompt.contains("assigned Coder owns its review/fix loop"));
assert!(prompt.contains("spawn the implementation Coder with `WorkerSpawn.ticket_id`"));
assert!(prompt.contains("then use `SpawnTicketCoder`"));
assert!(prompt.contains("verify its current assignment names that Coder"));
assert!(prompt.contains("never route implementation to an unassigned Coder"));
assert!(prompt.contains(
@@ -1728,7 +1733,8 @@ enabled = true
authoring = false
thread = false
intake = false
orchestration_control = false
[feature.orchestration]
enabled = false
"#,
);
let workspace = tmp.path().join("workspace");
@@ -1749,7 +1755,7 @@ orchestration_control = false
assert!(!resolved.manifest.feature.ticket.authoring);
assert!(!resolved.manifest.feature.ticket.thread);
assert!(!resolved.manifest.feature.ticket.intake);
assert!(!resolved.manifest.feature.ticket.orchestration_control);
assert!(!resolved.manifest.feature.orchestration.enabled);
assert_eq!(
resolved.manifest.delegation_scope.allow[0].target,
workspace
@@ -1836,7 +1842,7 @@ worker_context_max_tokens = 68000
assert!(resolved.manifest.feature.ticket.authoring);
assert!(resolved.manifest.feature.ticket.thread);
assert!(!resolved.manifest.feature.ticket.intake);
assert!(!resolved.manifest.feature.ticket.orchestration_control);
assert!(!resolved.manifest.feature.orchestration.enabled);
assert_eq!(
resolved.profile.as_ref().unwrap().name.as_deref(),
Some("companion")
+19 -3
View File
@@ -748,7 +748,7 @@ where
authoring: feature_config.ticket.authoring,
thread: feature_config.ticket.thread,
intake: feature_config.ticket.intake,
orchestration_control: feature_config.ticket.orchestration_control,
workflow: feature_config.ticket.workflow,
};
// Ticket tools are typed operations over the current workspace Ticket backend.
// Workspace access must be authority-bound to the Backend Workspace API; the
@@ -800,9 +800,16 @@ where
));
}
feature_registry.add_module(
crate::feature::builtin::manage_worker::manage_worker_feature(workspace_client),
crate::feature::builtin::manage_worker::manage_worker_feature(
workspace_client,
feature_config.worker.direct_spawn,
),
);
}
if feature_config.orchestration.enabled {
feature_registry
.add_module(crate::feature::builtin::orchestration::orchestration_feature());
}
for module in crate::feature::plugin::plugin_tool_features_if_enabled(
feature_config.plugins.enabled,
&worker.manifest().plugins,
@@ -937,7 +944,16 @@ where
);
}
}
let _feature_install_report = worker.install_features(feature_registry);
let feature_install_report = worker.install_features(feature_registry);
if feature_install_report.has_errors() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"Worker feature installation failed: {}",
feature_install_report.error_message()
),
));
}
if let Some(tracker) = tracker {
worker.attach_tracker(tracker);
}
+234 -48
View File
@@ -11,6 +11,7 @@
//! [`crate::hook::HookRegistryBuilder`], and provider output is represented as
//! ordinary feature reports/diagnostics instead of a separate authority layer.
use std::any::{Any, type_name};
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::sync::Arc;
@@ -497,8 +498,8 @@ impl ServiceRequirement {
}
}
/// Contribution service registry skeleton used during feature installation.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
/// Typed concrete services installed by stateful Feature modules.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FeatureServiceRegistry {
providers: HashMap<ServiceId, FeatureServiceProvider>,
}
@@ -512,11 +513,32 @@ impl FeatureServiceRegistry {
self.providers.contains_key(id)
}
fn register_provider(
pub fn service<T>(&self, id: &ServiceId) -> Result<Arc<T>, FeatureInstallError>
where
T: ?Sized + Send + Sync + 'static,
{
let provider = self.providers.get(id).ok_or_else(|| {
FeatureInstallError::InvalidDescriptor(format!(
"feature service provider is unavailable: {id}"
))
})?;
provider.service::<T>().ok_or_else(|| {
FeatureInstallError::InvalidDescriptor(format!(
"feature service {id} has incompatible concrete type; requested {}",
type_name::<T>()
))
})
}
fn register_provider<T>(
&mut self,
feature_id: FeatureId,
declaration: ServiceDeclaration,
) -> Result<(), FeatureInstallError> {
instance: Arc<T>,
) -> Result<(), FeatureInstallError>
where
T: ?Sized + Send + Sync + 'static,
{
if let Some(existing) = self.providers.get(&declaration.id) {
return Err(FeatureInstallError::DuplicateService {
service: declaration.id.to_string(),
@@ -529,19 +551,38 @@ impl FeatureServiceRegistry {
FeatureServiceProvider {
feature_id,
declaration,
instance: Arc::new(instance),
},
);
Ok(())
}
}
/// Provider metadata for one service declaration.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
/// Provider metadata and concrete instance for one service declaration.
#[derive(Clone, Debug)]
pub struct FeatureServiceProvider {
pub feature_id: FeatureId,
pub declaration: ServiceDeclaration,
instance: Arc<dyn Any + Send + Sync>,
}
impl FeatureServiceProvider {
fn service<T>(&self) -> Option<Arc<T>>
where
T: ?Sized + Send + Sync + 'static,
{
self.instance.downcast_ref::<Arc<T>>().map(Arc::clone)
}
}
impl PartialEq for FeatureServiceProvider {
fn eq(&self, other: &Self) -> bool {
self.feature_id == other.feature_id && self.declaration == other.declaration
}
}
impl Eq for FeatureServiceProvider {}
/// Feature descriptor advertised before installation.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FeatureDescriptor {
@@ -742,6 +783,7 @@ struct FeatureContributionDeclarations {
instructions: HashSet<FeatureInstructionId>,
background_tasks: HashSet<String>,
provided_services: HashSet<(ServiceId, String)>,
required_services: HashSet<ServiceId>,
protocol_providers: HashSet<ProviderId>,
}
@@ -773,6 +815,11 @@ impl FeatureContributionDeclarations {
.iter()
.map(|service| (service.id.clone(), service.version.clone()))
.collect(),
required_services: descriptor
.requires_services
.iter()
.map(|service| service.id.clone())
.collect(),
protocol_providers: descriptor
.protocol_providers
.iter()
@@ -1099,7 +1146,7 @@ impl BackgroundTaskRegistrar<'_> {
}
}
/// Service registrar for descriptor/report-only provider metadata.
/// Service registrar for concrete typed Feature state.
pub struct FeatureServiceRegistrar<'a> {
feature_id: &'a FeatureId,
declarations: &'a FeatureContributionDeclarations,
@@ -1108,7 +1155,14 @@ pub struct FeatureServiceRegistrar<'a> {
}
impl FeatureServiceRegistrar<'_> {
pub fn provide(&mut self, declaration: ServiceDeclaration) -> Result<(), FeatureInstallError> {
pub fn provide<T>(
&mut self,
declaration: ServiceDeclaration,
instance: Arc<T>,
) -> Result<(), FeatureInstallError>
where
T: ?Sized + Send + Sync + 'static,
{
if !self.declarations.contains_provided_service(&declaration) {
return Err(reject_undeclared_contribution(
self.feature_id,
@@ -1117,19 +1171,28 @@ impl FeatureServiceRegistrar<'_> {
declaration.id.to_string(),
));
}
if self
.report
.provided_services
.iter()
.any(|service| service.id == declaration.id && service.version == declaration.version)
{
return Ok(());
}
self.service_registry
.register_provider(self.feature_id.clone(), declaration.clone())?;
self.service_registry.register_provider(
self.feature_id.clone(),
declaration.clone(),
instance,
)?;
self.report.provided_services.push(declaration);
Ok(())
}
pub fn require<T>(&self, service: &ServiceId) -> Result<Arc<T>, FeatureInstallError>
where
T: ?Sized + Send + Sync + 'static,
{
if !self.declarations.required_services.contains(service) {
return Err(FeatureInstallError::UndeclaredContribution {
kind: FeatureContributionKind::Service,
name: service.to_string(),
feature: self.feature_id.to_string(),
});
}
self.service_registry.service(service)
}
}
/// Registrar for startup-discovered protocol-backed provider contributions.
@@ -1248,8 +1311,11 @@ impl ProtocolProviderRegistrar<'_> {
.iter()
.any(|provided| provided.id == service.id && provided.version == service.version)
{
self.service_registry
.register_provider(self.feature_id.clone(), service.clone())?;
self.service_registry.register_provider(
self.feature_id.clone(),
service.clone(),
Arc::new(()),
)?;
self.report.provided_services.push(service);
}
}
@@ -1360,13 +1426,36 @@ impl FeatureInstallContext<'_> {
}
/// Aggregate install output for a registry installation.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FeatureRegistryInstallReport {
pub reports: Vec<FeatureInstallReport>,
pub services: FeatureServiceRegistry,
}
impl FeatureRegistryInstallReport {
pub fn has_errors(&self) -> bool {
self.reports.iter().any(|report| {
report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
})
}
pub fn error_message(&self) -> String {
self.reports
.iter()
.flat_map(|report| {
report
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
.map(move |diagnostic| format!("{}: {}", report.feature_id, diagnostic.message))
})
.collect::<Vec<_>>()
.join("; ")
}
pub fn installed_tool_names(&self) -> Vec<String> {
self.reports
.iter()
@@ -1453,7 +1542,9 @@ impl FeatureRegistryBuilder {
hook_builder,
registered_tool_names,
);
if !report.has_errors() {
worker.register_tools(pending_tools);
}
report
}
@@ -1481,7 +1572,32 @@ impl FeatureRegistryBuilder {
let mut reports = Vec::with_capacity(self.modules.len());
let mut seen_features = HashSet::new();
for (module, descriptor) in self.modules.into_iter().zip(descriptors.into_iter()) {
let mut pending_modules: Vec<_> = self.modules.into_iter().zip(descriptors).collect();
let mut ordered_modules = Vec::with_capacity(pending_modules.len());
let mut declared_services = HashSet::new();
while !pending_modules.is_empty() {
let next = pending_modules
.iter()
.position(|(_, descriptor)| {
descriptor
.requires_services
.iter()
.filter(|requirement| requirement.required)
.all(|requirement| declared_services.contains(&requirement.id))
})
.unwrap_or(0);
let entry = pending_modules.remove(next);
declared_services.extend(
entry
.1
.provides_services
.iter()
.map(|service| service.id.clone()),
);
ordered_modules.push(entry);
}
for (module, descriptor) in ordered_modules {
let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
let mut report = FeatureInstallReport::new(&descriptor);
@@ -1538,22 +1654,6 @@ impl FeatureRegistryBuilder {
report.declared_background_tasks.push(background_task);
}
for service in descriptor.provides_services.iter().cloned() {
match service_registry.register_provider(descriptor.id.clone(), service.clone()) {
Ok(()) => report.provided_services.push(service),
Err(error) => {
report
.diagnostics
.push(FeatureDiagnostic::error(error.to_string()));
report.mark_skipped(
FeatureContributionKind::Service,
service.id.to_string(),
error.to_string(),
);
}
}
}
let install_result = {
let mut context = FeatureInstallContext {
feature_id: &descriptor.id,
@@ -2096,8 +2196,11 @@ mod tests {
fn install(
&self,
_context: &mut FeatureInstallContext<'_>,
context: &mut FeatureInstallContext<'_>,
) -> Result<(), FeatureInstallError> {
for service in self.descriptor.provides_services.iter().cloned() {
context.services().provide(service, Arc::new(()))?;
}
Ok(())
}
}
@@ -2171,11 +2274,10 @@ mod tests {
&self,
context: &mut FeatureInstallContext<'_>,
) -> Result<(), FeatureInstallError> {
context.services().provide(ServiceDeclaration::new(
self.service.clone(),
"1",
"runtime service provider",
))
context.services().provide(
ServiceDeclaration::new(self.service.clone(), "1", "runtime service provider"),
Arc::new(()),
)
}
}
@@ -2311,16 +2413,26 @@ mod tests {
report.reports[1].resolved_service_requirements[0].id,
service
);
assert!(!report.reports[2].installed);
let missing_report = report
.reports
.iter()
.find(|feature| feature.feature_id == FeatureId::builtin("missing"))
.unwrap();
assert!(!missing_report.installed);
assert!(
report.reports[2]
missing_report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("required service requirement"))
);
assert!(report.reports[3].installed);
let optional_report = report
.reports
.iter()
.find(|feature| feature.feature_id == FeatureId::builtin("optional"))
.unwrap();
assert!(optional_report.installed);
assert_eq!(
report.reports[3].skipped[0].kind,
optional_report.skipped[0].kind,
FeatureContributionKind::Service
);
}
@@ -2364,6 +2476,80 @@ mod tests {
assert!(report.reports[0].skipped.is_empty());
}
#[test]
fn service_registry_keeps_typed_concrete_instance() {
trait CounterService: Send + Sync {
fn value(&self) -> usize;
}
struct Counter(usize);
impl CounterService for Counter {
fn value(&self) -> usize {
self.0
}
}
let mut registry = FeatureServiceRegistry::default();
let service_id = ServiceId::builtin("typed-counter");
let service: Arc<dyn CounterService> = Arc::new(Counter(7));
registry
.register_provider(
FeatureId::builtin("provider"),
ServiceDeclaration::new(service_id.clone(), "1", "typed counter"),
service,
)
.unwrap();
assert_eq!(
registry
.service::<dyn CounterService>(&service_id)
.unwrap()
.value(),
7
);
}
#[test]
fn service_dependencies_install_in_provider_order() {
let service = ServiceId::builtin("ordered-service");
let provider = FeatureDescriptor::builtin("provider", "Provider").with_provided_service(
ServiceDeclaration::new(service.clone(), "1", "ordered service"),
);
let consumer = FeatureDescriptor::builtin("consumer", "Consumer").with_service_requirement(
ServiceRequirement::required(service, "consumer depends on provider"),
);
let mut hook_builder = HookRegistryBuilder::default();
let mut pending_tools = Vec::new();
let report = FeatureRegistryBuilder::new()
.with_module(ServiceFeature {
descriptor: consumer,
})
.with_module(ServiceFeature {
descriptor: provider,
})
.install_into_pending(&mut pending_tools, &mut hook_builder);
assert!(!report.has_errors(), "{}", report.error_message());
assert_eq!(report.reports[0].feature_id, FeatureId::builtin("provider"));
assert_eq!(report.reports[1].feature_id, FeatureId::builtin("consumer"));
}
#[test]
fn missing_required_service_is_fatal() {
let descriptor = FeatureDescriptor::builtin("consumer", "Consumer")
.with_service_requirement(ServiceRequirement::required(
ServiceId::builtin("missing-service"),
"must fail closed",
));
let mut hook_builder = HookRegistryBuilder::default();
let mut pending_tools = Vec::new();
let report = FeatureRegistryBuilder::new()
.with_module(ServiceFeature { descriptor })
.install_into_pending(&mut pending_tools, &mut hook_builder);
assert!(report.has_errors());
assert!(
report
.error_message()
.contains("required service requirement")
);
}
#[test]
fn builtin_internal_task_feature_descriptor_has_exact_tools_hooks() {
let descriptor = builtin::task_tools_feature().descriptor();
+1
View File
@@ -11,6 +11,7 @@ pub mod memory;
pub mod memory_extract;
pub mod merge_request;
pub mod objective;
pub mod orchestration;
pub mod session_explore;
pub mod task;
pub mod ticket;
@@ -12,35 +12,117 @@ use serde::{Deserialize, Serialize};
use protocol::Segment;
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration,
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule,
ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration,
};
use crate::worker::{
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
WorkspaceResponse,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
const FEATURE_ID: &str = "worker";
const FEATURE_NAME: &str = "Worker";
const FEATURE_DESCRIPTION: &str =
"Workspace-authority tools for managing Workdir-bound Backend/Runtime Worker sessions.";
pub const WORKER_LIFECYCLE_SERVICE_ID: &str = "worker.lifecycle";
const WORKER_LIFECYCLE_SERVICE_VERSION: &str = "1";
#[async_trait]
pub trait WorkerLifecycleService: Send + Sync {
async fn spawn(
&self,
request: WorkerLifecycleSpawnRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError>;
}
#[derive(Debug, Clone)]
pub struct WorkerLifecycleSpawnRequest {
pub runtime_id: String,
pub working_directory_id: String,
pub relative_cwd: Option<String>,
pub profile: String,
pub ticket_id: Option<String>,
pub operation_id: Option<String>,
pub display_name: String,
pub initial_submit: Vec<Segment>,
}
struct WorkspaceWorkerLifecycleService {
client: Arc<dyn WorkspaceClient>,
workspace_id: String,
}
#[async_trait]
impl WorkerLifecycleService for WorkspaceWorkerLifecycleService {
async fn spawn(
&self,
request: WorkerLifecycleSpawnRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let ticket_assignment = match (request.ticket_id, request.operation_id) {
(Some(ticket_id), Some(operation_id)) => Some(WorkerSpawnTicketAssignmentRequest {
ticket_id,
operation_id,
}),
(None, None) => None,
_ => {
return Err(WorkspaceClientError::Request(
"ticket_id and operation_id must be provided together".to_string(),
));
}
};
let body = WorkerSpawnRequest {
runtime_id: request.runtime_id,
display_name: request.display_name,
profile: request.profile,
ticket_assignment,
initial_submit: request.initial_submit,
working_directory: WorkerWorkingDirectorySelection {
working_directory_id: request.working_directory_id,
relative_cwd: request.relative_cwd,
},
};
self.client.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{}/workers", self.workspace_id),
serde_json::to_string(&body)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?,
))
}
}
#[derive(Clone, Debug)]
pub struct ManageWorkerFeature {
client: Arc<dyn WorkspaceClient>,
direct_spawn: bool,
}
pub fn manage_worker_feature(client: Arc<dyn WorkspaceClient>) -> ManageWorkerFeature {
ManageWorkerFeature { client }
pub fn manage_worker_feature(
client: Arc<dyn WorkspaceClient>,
direct_spawn: bool,
) -> ManageWorkerFeature {
ManageWorkerFeature {
client,
direct_spawn,
}
}
impl FeatureModule for ManageWorkerFeature {
fn descriptor(&self) -> FeatureDescriptor {
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
.with_description(FEATURE_DESCRIPTION);
.with_description(FEATURE_DESCRIPTION)
.with_provided_service(ServiceDeclaration::new(
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
WORKER_LIFECYCLE_SERVICE_VERSION,
"Workspace-authoritative Worker lifecycle operations",
));
for operation in WorkerOperation::ALL {
if operation != WorkerOperation::Spawn || self.direct_spawn {
descriptor = descriptor.with_tool(ToolDeclaration::new(
operation.tool_name(),
operation.description(),
));
}
}
descriptor
}
@@ -55,7 +137,23 @@ impl FeatureModule for ManageWorkerFeature {
)
})?
.to_string();
let lifecycle: Arc<dyn WorkerLifecycleService> =
Arc::new(WorkspaceWorkerLifecycleService {
client: self.client.clone(),
workspace_id: workspace_id.clone(),
});
context.services().provide(
ServiceDeclaration::new(
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
WORKER_LIFECYCLE_SERVICE_VERSION,
"Workspace-authoritative Worker lifecycle operations",
),
lifecycle,
)?;
for operation in WorkerOperation::ALL {
if operation == WorkerOperation::Spawn && !self.direct_spawn {
continue;
}
let definition = match operation {
WorkerOperation::List => definition::<WorkerListInput>(
operation,
@@ -170,7 +268,7 @@ struct WorkspaceWorkerTool {
workspace_id: String,
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WorkerOperation {
List,
Spawn,
@@ -254,27 +352,24 @@ impl Tool for WorkspaceWorkerTool {
}
WorkerOperation::Spawn => {
let input = parse::<WorkerSpawnInput>(input_json, "WorkerSpawn")?;
let ticket_assignment = input
let ticket_id = input
.ticket_id
.map(|ticket_id| authority_id(&ticket_id, "ticket_id"))
.transpose()?;
let operation_id = ticket_id
.as_ref()
.map(|ticket_id| {
let ticket_id = authority_id(&ticket_id, "ticket_id")?;
let call_id = non_empty(ctx.call_id.clone(), "tool call_id")?;
Ok::<_, ToolError>(WorkerSpawnTicketAssignmentRequest {
operation_id: format!("worker-spawn:{ticket_id}:{call_id}"),
ticket_id,
})
Ok::<_, ToolError>(format!("worker-spawn:{ticket_id}:{call_id}"))
})
.transpose()?;
let request = WorkerSpawnRequest {
let lifecycle = WorkspaceWorkerLifecycleService {
client: self.client.clone(),
workspace_id: self.workspace_id.clone(),
};
let response = lifecycle
.spawn(WorkerLifecycleSpawnRequest {
runtime_id: authority_id(&input.runtime_id, "runtime_id")?,
display_name: input
.display_name
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "Workspace Worker".to_string()),
profile: non_empty(input.profile, "profile")?,
ticket_assignment,
initial_submit: input.initial_submit,
working_directory: WorkerWorkingDirectorySelection {
working_directory_id: authority_id(
&input.working_directory_id,
"working_directory_id",
@@ -283,14 +378,18 @@ impl Tool for WorkspaceWorkerTool {
.relative_cwd
.map(|value| validate_relative_cwd(&value))
.transpose()?,
},
};
WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{}/workers", self.workspace_id),
serde_json::to_string(&request)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
)
profile: non_empty(input.profile, "profile")?,
ticket_id,
operation_id,
display_name: input
.display_name
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "Workspace Worker".to_string()),
initial_submit: input.initial_submit,
})
.await
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
return tool_output(self.operation, response);
}
WorkerOperation::Stop => {
let input = parse::<WorkerStopInput>(input_json, "WorkerStop")?;
@@ -325,6 +424,14 @@ impl Tool for WorkspaceWorkerTool {
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?
}
};
tool_output(self.operation, response)
}
}
fn tool_output(
operation: WorkerOperation,
response: WorkspaceResponse,
) -> Result<ToolOutput, ToolError> {
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Workspace Worker operation returned HTTP {}: {}",
@@ -332,11 +439,10 @@ impl Tool for WorkspaceWorkerTool {
)));
}
Ok(ToolOutput {
summary: format!("{} completed", self.operation.tool_name()),
summary: format!("{} completed", operation.tool_name()),
content: Some(response.body),
attachments: Vec::new(),
})
}
}
fn definition<I: JsonSchema + 'static>(
@@ -500,6 +606,23 @@ mod tests {
assert!(body.get("initial_text").is_none());
}
#[test]
fn worker_service_can_remain_enabled_without_direct_spawn_surface() {
let client = Arc::new(RecordingWorkspaceClient::default());
let descriptor = manage_worker_feature(client, false).descriptor();
let tools: Vec<_> = descriptor
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect();
assert!(!tools.contains(&"WorkerSpawn"));
assert!(tools.contains(&"WorkerList"));
assert_eq!(
descriptor.provides_services[0].id,
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID)
);
}
#[test]
fn worker_tool_family_is_distinct_from_sub_worker_tools() {
assert_eq!(
@@ -0,0 +1,340 @@
//! Semantic Ticket orchestration tools backed by Feature Services.
use std::sync::Arc;
use async_trait::async_trait;
use llm_engine::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput,
};
use protocol::Segment;
use schemars::JsonSchema;
use serde::Deserialize;
use super::manage_worker::{
WORKER_LIFECYCLE_SERVICE_ID, WorkerLifecycleService, WorkerLifecycleSpawnRequest,
};
use super::ticket::{TICKET_SERVICE_ID, TicketService};
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ServiceId,
ServiceRequirement, ToolContribution, ToolDeclaration,
};
const FEATURE_ID: &str = "orchestration";
const TOOL_NAME: &str = "SpawnTicketCoder";
const CODER_PROFILE: &str = "builtin:coder";
const CODER_FLOW: &str = "builtin:coder-review";
#[derive(Debug, Default)]
pub struct OrchestrationFeature;
pub fn orchestration_feature() -> OrchestrationFeature {
OrchestrationFeature
}
impl FeatureModule for OrchestrationFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin(FEATURE_ID, "Orchestration")
.with_description("Semantic Ticket orchestration operations.")
.with_service_requirement(ServiceRequirement::required(
ServiceId::builtin(TICKET_SERVICE_ID),
"SpawnTicketCoder requires current typed Ticket authority",
))
.with_service_requirement(ServiceRequirement::required(
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
"SpawnTicketCoder requires Workspace Worker lifecycle authority",
))
.with_tool(ToolDeclaration::new(
TOOL_NAME,
"Spawn and atomically assign a Coder Worker for an inprogress Ticket. The profile, Flow, display name, assignment operation, and initial message are fixed by orchestration policy.",
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let ticket_service = context
.services()
.require::<dyn TicketService>(&ServiceId::builtin(TICKET_SERVICE_ID))?;
let worker_service =
context
.services()
.require::<dyn WorkerLifecycleService>(&ServiceId::builtin(
WORKER_LIFECYCLE_SERVICE_ID,
))?;
context.tools().register(ToolContribution::new(
TOOL_NAME,
definition(ticket_service, worker_service),
))
}
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SpawnTicketCoderInput {
ticket_id: String,
runtime_id: String,
working_directory_id: String,
#[serde(default)]
relative_cwd: Option<String>,
}
struct SpawnTicketCoderTool {
ticket_service: Arc<dyn TicketService>,
worker_service: Arc<dyn WorkerLifecycleService>,
}
#[async_trait]
impl Tool for SpawnTicketCoderTool {
async fn execute(
&self,
input_json: &str,
ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let input: SpawnTicketCoderInput = serde_json::from_str(input_json).map_err(|error| {
ToolError::InvalidArgument(format!("invalid {TOOL_NAME} input: {error}"))
})?;
let ticket_id = authority_id(input.ticket_id, "ticket_id")?;
let workflow_state = self
.ticket_service
.workflow_state(&ticket_id)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if workflow_state != ticket::TicketWorkflowState::InProgress {
return Err(ToolError::ExecutionFailed(format!(
"Ticket {ticket_id} must be inprogress before spawning its Coder; current state is {}",
workflow_state.as_str()
)));
}
let call_id = non_empty(ctx.call_id, "tool call_id")?;
let relative_cwd = input.relative_cwd.map(validate_relative_cwd).transpose()?;
let response = self
.worker_service
.spawn(WorkerLifecycleSpawnRequest {
runtime_id: authority_id(input.runtime_id, "runtime_id")?,
working_directory_id: authority_id(
input.working_directory_id,
"working_directory_id",
)?,
relative_cwd,
profile: CODER_PROFILE.to_string(),
ticket_id: Some(ticket_id.clone()),
operation_id: Some(format!("spawn-ticket-coder:{ticket_id}:{call_id}")),
display_name: format!("Coder · {ticket_id}"),
initial_submit: vec![
Segment::Flow {
selector: CODER_FLOW.to_string(),
},
Segment::text(format!("Implement Ticket {ticket_id}.")),
],
})
.await
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Workspace Worker operation returned HTTP {}: {}",
response.status, response.body
)));
}
Ok(ToolOutput {
summary: format!("Spawned Coder for Ticket {ticket_id}"),
content: Some(response.body),
attachments: Vec::new(),
})
}
}
fn definition(
ticket_service: Arc<dyn TicketService>,
worker_service: Arc<dyn WorkerLifecycleService>,
) -> ToolDefinition {
Arc::new(move || {
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketCoderInput))
.unwrap_or_else(|_| serde_json::json!({}));
let meta = ToolMeta::new(TOOL_NAME)
.description("Spawn and atomically assign a policy-configured Coder for a Ticket.")
.input_schema(schema);
let tool: Arc<dyn Tool> = Arc::new(SpawnTicketCoderTool {
ticket_service: ticket_service.clone(),
worker_service: worker_service.clone(),
});
(meta, tool)
})
}
fn authority_id(value: String, field: &str) -> Result<String, ToolError> {
let value = non_empty(value, field)?;
if value.contains('/') || value.contains('?') || value.contains('#') {
return Err(ToolError::InvalidArgument(format!(
"{field} must be an authority id, not a path or URL"
)));
}
Ok(value)
}
fn non_empty(value: String, field: &str) -> Result<String, ToolError> {
let value = value.trim().to_string();
if value.is_empty() {
return Err(ToolError::InvalidArgument(format!(
"{field} must not be empty"
)));
}
Ok(value)
}
fn validate_relative_cwd(value: String) -> Result<String, ToolError> {
let value = value.trim();
if value.is_empty()
|| value.starts_with('/')
|| value.split('/').any(|part| matches!(part, "" | "." | ".."))
{
return Err(ToolError::InvalidArgument(
"relative_cwd must be a normalized relative path inside the Workdir".to_string(),
));
}
Ok(value.to_string())
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use ticket::{TicketError, TicketWorkflowState};
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
use super::*;
#[derive(Default)]
struct RecordingTicketService;
impl TicketService for RecordingTicketService {
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
Ok(TicketWorkflowState::InProgress)
}
}
struct FixedTicketService(TicketWorkflowState);
impl TicketService for FixedTicketService {
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
Ok(self.0)
}
}
#[derive(Default)]
struct RecordingService {
requests: Mutex<Vec<WorkerLifecycleSpawnRequest>>,
}
#[async_trait]
impl WorkerLifecycleService for RecordingService {
async fn spawn(
&self,
request: WorkerLifecycleSpawnRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.requests.lock().unwrap().push(request);
Ok(WorkspaceResponse {
status: 200,
body: r#"{"worker_id":"42"}"#.to_string(),
})
}
}
#[tokio::test]
async fn spawn_ticket_coder_fixes_profile_flow_assignment_and_message() {
let service = Arc::new(RecordingService::default());
let tool = SpawnTicketCoderTool {
ticket_service: Arc::new(RecordingTicketService),
worker_service: service.clone(),
};
tool.execute(
&serde_json::json!({
"ticket_id": "00001KZXN51C7",
"runtime_id": "runtime-1",
"working_directory_id": "workdir-1"
})
.to_string(),
ToolExecutionContext::new("call-7", "batch-1", 0),
)
.await
.unwrap();
let requests = service.requests.lock().unwrap();
let request = &requests[0];
assert_eq!(request.profile, CODER_PROFILE);
assert_eq!(request.ticket_id.as_deref(), Some("00001KZXN51C7"));
assert_eq!(
request.operation_id.as_deref(),
Some("spawn-ticket-coder:00001KZXN51C7:call-7")
);
assert_eq!(request.display_name, "Coder · 00001KZXN51C7");
assert_eq!(
request.initial_submit,
vec![
Segment::Flow {
selector: CODER_FLOW.to_string()
},
Segment::text("Implement Ticket 00001KZXN51C7.")
]
);
}
#[tokio::test]
async fn spawn_ticket_coder_rejects_ticket_before_worker_side_effect() {
let worker_service = Arc::new(RecordingService::default());
let tool = SpawnTicketCoderTool {
ticket_service: Arc::new(FixedTicketService(TicketWorkflowState::Queued)),
worker_service: worker_service.clone(),
};
let error = tool
.execute(
&serde_json::json!({
"ticket_id": "00001KZXN51C7",
"runtime_id": "runtime-1",
"working_directory_id": "workdir-1"
})
.to_string(),
ToolExecutionContext::new("call-queued", "batch-1", 0),
)
.await
.unwrap_err();
assert!(error.to_string().contains("must be inprogress"));
assert!(worker_service.requests.lock().unwrap().is_empty());
}
#[test]
fn orchestration_descriptor_requires_ticket_and_worker_services() {
let descriptor = orchestration_feature().descriptor();
let required: Vec<_> = descriptor
.requires_services
.iter()
.map(|requirement| requirement.id.clone())
.collect();
assert_eq!(
required,
vec![
ServiceId::builtin(TICKET_SERVICE_ID),
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
]
);
}
#[test]
fn input_surface_does_not_expose_profile_flow_or_assignment_controls() {
let schema = serde_json::to_string(&schemars::schema_for!(SpawnTicketCoderInput)).unwrap();
for field in [
"ticket_id",
"runtime_id",
"working_directory_id",
"relative_cwd",
] {
assert!(schema.contains(field));
}
for forbidden in [
"profile",
"selector",
"operation_id",
"display_name",
"initial_submit",
] {
assert!(!schema.contains(forbidden), "schema leaked {forbidden}");
}
}
}
+55 -29
View File
@@ -15,7 +15,7 @@ use ticket::{
Ticket, TicketBackend, TicketBackendOperation, TicketBackendOperationResult,
TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketStateChange,
TicketSummary,
TicketSummary, TicketWorkflowState,
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
};
@@ -24,7 +24,7 @@ use super::merge_request;
use crate::feature::{
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId,
FeatureModule, ToolContribution, ToolDeclaration,
FeatureModule, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
@@ -34,6 +34,24 @@ const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over
The tools operate through the ticket crate backend and do not grant generic filesystem write scope.";
const TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow";
const TICKET_WORKFLOW_PROMPT_REF: &str = "$yoi/common/tickets";
pub const TICKET_SERVICE_ID: &str = "ticket.authority";
const TICKET_SERVICE_VERSION: &str = "1";
pub trait TicketService: Send + Sync {
fn workflow_state(&self, ticket_id: &str) -> Result<TicketWorkflowState, TicketError>;
}
struct BackendTicketService {
backend: TicketToolBackend,
}
impl TicketService for BackendTicketService {
fn workflow_state(&self, ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
self.backend
.show(ticket_id.into())
.map(|ticket| ticket.meta.workflow_state)
}
}
fn ticket_workflow_instruction() -> FeatureInstructionDeclaration {
FeatureInstructionDeclaration::new(
@@ -49,7 +67,7 @@ pub struct TicketFeatureAccess {
pub authoring: bool,
pub thread: bool,
pub intake: bool,
pub orchestration_control: bool,
pub workflow: bool,
}
impl TicketFeatureAccess {
@@ -58,7 +76,7 @@ impl TicketFeatureAccess {
authoring: false,
thread: false,
intake: false,
orchestration_control: false,
workflow: false,
}
}
@@ -67,7 +85,7 @@ impl TicketFeatureAccess {
authoring: true,
thread: true,
intake: false,
orchestration_control: false,
workflow: false,
}
}
@@ -76,16 +94,16 @@ impl TicketFeatureAccess {
authoring: true,
thread: true,
intake: true,
orchestration_control: false,
workflow: false,
}
}
pub const fn orchestration_control() -> Self {
pub const fn workflow() -> Self {
Self {
authoring: false,
thread: true,
intake: false,
orchestration_control: true,
workflow: true,
}
}
@@ -94,7 +112,7 @@ impl TicketFeatureAccess {
authoring: false,
thread: true,
intake: false,
orchestration_control: false,
workflow: false,
}
}
@@ -103,7 +121,7 @@ impl TicketFeatureAccess {
authoring: false,
thread: false,
intake: false,
orchestration_control: false,
workflow: false,
}
}
@@ -120,8 +138,7 @@ impl TicketFeatureAccess {
|| (self.authoring && AUTHORING_TOOL_NAMES.contains(&name))
|| (self.thread && THREAD_TOOL_NAMES.contains(&name))
|| (self.intake && INTAKE_TOOL_NAMES.contains(&name))
|| (self.orchestration_control
&& ORCHESTRATION_CONTROL_ADDITIONAL_TOOL_NAMES.contains(&name))
|| (self.workflow && WORKFLOW_ADDITIONAL_TOOL_NAMES.contains(&name))
}
}
@@ -163,7 +180,7 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
];
#[cfg(test)]
const ORCHESTRATION_CONTROL_TOOL_NAMES: &[&str] = &[
const WORKFLOW_TOOL_NAMES: &[&str] = &[
"TicketList",
"TicketShow",
"TicketComment",
@@ -177,7 +194,7 @@ const ORCHESTRATION_CONTROL_TOOL_NAMES: &[&str] = &[
"TicketOrchestrationPlanQuery",
];
const ORCHESTRATION_CONTROL_ADDITIONAL_TOOL_NAMES: &[&str] = &[
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
"TicketWorkflowState",
"TicketClose",
"TicketRelationRecord",
@@ -331,7 +348,12 @@ impl FeatureModule for TicketFeature {
fn descriptor(&self) -> FeatureDescriptor {
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
.with_description(FEATURE_DESCRIPTION)
.with_instruction(ticket_workflow_instruction());
.with_instruction(ticket_workflow_instruction())
.with_provided_service(ServiceDeclaration::new(
ServiceId::builtin(TICKET_SERVICE_ID),
TICKET_SERVICE_VERSION,
"Current typed Ticket authority",
));
let enabled_tool_names = self.enabled_tool_names();
for name in enabled_tool_names {
descriptor = descriptor.with_tool(ToolDeclaration::new(
@@ -370,6 +392,17 @@ impl FeatureModule for TicketFeature {
let Some(backend) = self.tool_backend(context) else {
return Ok(());
};
let ticket_service: Arc<dyn TicketService> = Arc::new(BackendTicketService {
backend: backend.clone(),
});
context.services().provide(
ServiceDeclaration::new(
ServiceId::builtin(TICKET_SERVICE_ID),
TICKET_SERVICE_VERSION,
"Current typed Ticket authority",
),
ticket_service,
)?;
context
.instructions()
.register(FeatureInstructionContribution::new(
@@ -1018,24 +1051,19 @@ mod tests {
}
#[test]
fn orchestration_control_descriptor_declares_orchestration_tools() {
fn workflow_descriptor_declares_workflow_tools() {
let temp = TempDir::new().unwrap();
let feature = ticket_tools_feature_with_access(
temp.path(),
TicketFeatureAccess::orchestration_control(),
);
let feature =
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::workflow());
let descriptor = feature.descriptor();
assert_eq!(
feature.access(),
TicketFeatureAccess::orchestration_control()
);
assert_eq!(feature.access(), TicketFeatureAccess::workflow());
assert_eq!(
descriptor
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>(),
ORCHESTRATION_CONTROL_TOOL_NAMES
WORKFLOW_TOOL_NAMES
);
}
@@ -1058,10 +1086,8 @@ mod tests {
assert!(workspace_tools.contains(&"TicketQueue"));
assert!(!workspace_tools.contains(&"TicketWorkflowState"));
let orchestration = ticket_tools_feature_with_access(
temp.path(),
TicketFeatureAccess::orchestration_control(),
);
let orchestration =
ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::workflow());
let orchestration_descriptor = orchestration.descriptor();
let orchestration_tools = orchestration_descriptor
.tools
+5 -2
View File
@@ -724,11 +724,14 @@ impl FeatureModule for PluginToolFeature {
if instance.is_none() {
instance = Some(self.ensure_instance()?);
}
context.services().provide(ServiceDeclaration::new(
context.services().provide(
ServiceDeclaration::new(
plugin_service_id(&self.record, &service.name),
self.record.manifest.version.clone(),
service.description.clone(),
))?;
),
Arc::new(instance.as_ref().expect("instance initialized").clone()),
)?;
exposed += 1;
}
}
+3 -2
View File
@@ -8,8 +8,9 @@ import "./base.dcdl" // {
memory = { enabled = true; };
web = { enabled = true; };
sub_worker = { enabled = false; };
worker = { enabled = true; };
worker = { enabled = true; direct_spawn = false; };
manage_workdir = { enabled = true; };
ticket = { enabled = true; thread = true; orchestration_control = true; };
ticket = { enabled = true; thread = true; workflow = true; };
orchestration = { enabled = true; };
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
You are the Ticket Orchestrator role.
Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. Record `inprogress` before implementation side effects, then spawn the implementation Coder with `WorkerSpawn.ticket_id` and exactly one Flow segment so Worker creation and the current Ticket assignment are one guarded operation. After spawn, reread the Ticket and verify its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers, and stop for human authority when merge/closure is not explicitly delegated.
Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. Record `inprogress` before implementation side effects, then use `SpawnTicketCoder` so Worker creation, the fixed Coder profile/Flow, and the current Ticket assignment are one guarded operation. After spawn, reread the Ticket and verify its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers, and stop for human authority when merge/closure is not explicitly delegated.
The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If current-revision durable review evidence is missing, indeterminate, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker.