feat(agents): role orchestrateur du contexte global expose et configurable par agent — isOrchestrator distinct des permissions MCP, DTO update_project_orchestrator (terminologie wire legacy conservee), refus idea_context_update explicite avec writer effectif + orientation idea_context_propose, surface AgentsPanel + adaptateurs frontend — #276 (QA verte: cargo fmt/check, tests cibles application/app-tauri/infrastructure/backend, npm typecheck, 69/69 tests frontend agents/adapters)
This commit is contained in:
@ -255,9 +255,10 @@ impl CreateAgentFromScratch {
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
|
||||
// Append the entry and re-validate the whole manifest (unique md_paths).
|
||||
let orchestrator = manifest.orchestrator;
|
||||
let mut entries = manifest.entries;
|
||||
entries.push(ManifestEntry::from_agent(&agent));
|
||||
let manifest = AgentManifest::new(manifest.version, entries)
|
||||
let manifest = AgentManifest::with_orchestrator(manifest.version, entries, orchestrator)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
@ -345,6 +346,83 @@ impl ListAgentsOutput {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UpdateProjectOrchestrator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`UpdateProjectOrchestrator::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateProjectOrchestratorInput {
|
||||
/// The project whose global-context writer changes.
|
||||
pub project: Project,
|
||||
/// The existing agent that becomes the single effective writer.
|
||||
pub agent_id: AgentId,
|
||||
}
|
||||
|
||||
/// Output of [`UpdateProjectOrchestrator::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateProjectOrchestratorOutput {
|
||||
/// The effective writer after the update.
|
||||
pub orchestrator: AgentId,
|
||||
}
|
||||
|
||||
/// Changes the project's single global-context writer.
|
||||
///
|
||||
/// The manifest is validated and persisted as one document before the change event is
|
||||
/// published. Selecting the already-effective writer is an idempotent no-op.
|
||||
pub struct UpdateProjectOrchestrator {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl UpdateProjectOrchestrator {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { contexts, events }
|
||||
}
|
||||
|
||||
/// Designates an existing project agent as the global-context writer.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the agent is unknown to the project,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: UpdateProjectOrchestratorInput,
|
||||
) -> Result<UpdateProjectOrchestratorOutput, AppError> {
|
||||
let mut manifest = self.contexts.load_manifest(&input.project).await?;
|
||||
if !manifest
|
||||
.entries
|
||||
.iter()
|
||||
.any(|entry| entry.agent_id == input.agent_id)
|
||||
{
|
||||
return Err(AppError::NotFound(format!("agent {}", input.agent_id)));
|
||||
}
|
||||
|
||||
if manifest.effective_orchestrator() == Some(input.agent_id) {
|
||||
return Ok(UpdateProjectOrchestratorOutput {
|
||||
orchestrator: input.agent_id,
|
||||
});
|
||||
}
|
||||
|
||||
manifest
|
||||
.designate(input.agent_id)
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
.await?;
|
||||
self.events.publish(DomainEvent::OrchestratorChanged {
|
||||
project_id: input.project.id,
|
||||
orchestrator: Some(input.agent_id),
|
||||
});
|
||||
|
||||
Ok(UpdateProjectOrchestratorOutput {
|
||||
orchestrator: input.agent_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists a project's agents by reconstructing them from the manifest entries.
|
||||
pub struct ListAgents {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
@ -579,8 +657,12 @@ impl UpdateAgentContactable {
|
||||
.with_contactable(input.contactable);
|
||||
*entry = ManifestEntry::from_agent(&agent);
|
||||
|
||||
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
let manifest = AgentManifest::with_orchestrator(
|
||||
manifest.version,
|
||||
manifest.entries,
|
||||
manifest.orchestrator,
|
||||
)
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
.await?;
|
||||
@ -625,8 +707,12 @@ impl UpdateAgentEffort {
|
||||
.with_effort(input.effort);
|
||||
*entry = ManifestEntry::from_agent(&agent);
|
||||
|
||||
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
let manifest = AgentManifest::with_orchestrator(
|
||||
manifest.version,
|
||||
manifest.entries,
|
||||
manifest.orchestrator,
|
||||
)
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
.await?;
|
||||
@ -800,6 +886,7 @@ impl ChangeAgentProfile {
|
||||
|
||||
// 4. Mutate the entry (new profile), re-validate (to_agent + manifest)
|
||||
// and persist.
|
||||
let orchestrator = manifest.orchestrator;
|
||||
let mut entries = manifest.entries;
|
||||
let mut mutated_agent = None;
|
||||
for e in &mut entries {
|
||||
@ -813,7 +900,7 @@ impl ChangeAgentProfile {
|
||||
}
|
||||
let agent =
|
||||
mutated_agent.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||
let manifest = AgentManifest::new(manifest.version, entries)
|
||||
let manifest = AgentManifest::with_orchestrator(manifest.version, entries, orchestrator)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
@ -1143,14 +1230,13 @@ impl DeleteAgent {
|
||||
/// - [`AppError::NotFound`] if the agent is not in the manifest,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: DeleteAgentInput) -> Result<(), AppError> {
|
||||
let manifest = self.contexts.load_manifest(&input.project).await?;
|
||||
let mut manifest = self.contexts.load_manifest(&input.project).await?;
|
||||
let previous_orchestrator = manifest.effective_orchestrator();
|
||||
let before = manifest.entries.len();
|
||||
let entries: Vec<ManifestEntry> = manifest
|
||||
manifest
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|e| e.agent_id != input.agent_id)
|
||||
.collect();
|
||||
if entries.len() == before {
|
||||
.retain(|entry| entry.agent_id != input.agent_id);
|
||||
if manifest.entries.len() == before {
|
||||
return Err(AppError::NotFound(format!("agent {}", input.agent_id)));
|
||||
}
|
||||
if let Some((live, close)) = &self.runtime_cleanup {
|
||||
@ -1158,11 +1244,17 @@ impl DeleteAgent {
|
||||
stop_live_agent_runtime_in_project(&input.project, input.agent_id, live, close)
|
||||
.await?;
|
||||
}
|
||||
let manifest = AgentManifest::new(manifest.version, entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
manifest.on_agent_deleted(input.agent_id);
|
||||
let next_orchestrator = manifest.effective_orchestrator();
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
.await?;
|
||||
if previous_orchestrator != next_orchestrator {
|
||||
self.events.publish(DomainEvent::OrchestratorChanged {
|
||||
project_id: input.project.id,
|
||||
orchestrator: manifest.orchestrator,
|
||||
});
|
||||
}
|
||||
self.events.publish(DomainEvent::LayoutChanged {
|
||||
project_id: input.project.id,
|
||||
});
|
||||
|
||||
@ -40,6 +40,7 @@ pub use lifecycle::{
|
||||
StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContactable,
|
||||
UpdateAgentContactableInput, UpdateAgentContactableOutput, UpdateAgentContext,
|
||||
UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput, UpdateAgentEffortOutput,
|
||||
UpdateProjectOrchestrator, UpdateProjectOrchestratorInput, UpdateProjectOrchestratorOutput,
|
||||
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
|
||||
};
|
||||
pub use model_catalogue::{
|
||||
|
||||
@ -68,7 +68,8 @@ pub use agent::{
|
||||
SaveProfileInput, SaveProfileOutput, SessionLimitService, StructuredRoutingMode,
|
||||
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContactable, UpdateAgentContactableInput,
|
||||
UpdateAgentContactableOutput, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort,
|
||||
UpdateAgentEffortInput, UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET,
|
||||
UpdateAgentEffortInput, UpdateAgentEffortOutput, UpdateProjectOrchestrator,
|
||||
UpdateProjectOrchestratorInput, UpdateProjectOrchestratorOutput, AGENT_MEMORY_RECALL_BUDGET,
|
||||
CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
||||
};
|
||||
pub use background::{
|
||||
|
||||
@ -243,7 +243,7 @@ impl UpdateProjectContext {
|
||||
let designation = manifest.orchestrator_designation();
|
||||
let resource = GuardedResource::ProjectContext;
|
||||
if !may_write_directly(requester, &resource, &designation) {
|
||||
return Err(map_guard_err(GuardError::Forbidden));
|
||||
return Err(AppError::Invalid(context_update_denial_message(&manifest)));
|
||||
}
|
||||
|
||||
let _lease = self
|
||||
@ -533,6 +533,25 @@ fn map_guard_err(e: GuardError) -> AppError {
|
||||
}
|
||||
}
|
||||
|
||||
fn context_update_denial_message(manifest: &domain::AgentManifest) -> String {
|
||||
let writer = manifest.effective_orchestrator().and_then(|id| {
|
||||
manifest
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.agent_id == id)
|
||||
.map(|entry| format!("{} ({id})", entry.name))
|
||||
});
|
||||
match writer {
|
||||
Some(writer) => format!(
|
||||
"only the global project context writer may use idea_context_update; current writer: \
|
||||
{writer}. Use idea_context_propose instead"
|
||||
),
|
||||
None => "only the global project context writer may use idea_context_update; this project \
|
||||
currently has no writer. Use idea_context_propose instead"
|
||||
.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -998,6 +1017,13 @@ mod tests {
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "INVALID");
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("only the global project context writer may use idea_context_update")
|
||||
);
|
||||
assert!(message.contains("Dev"));
|
||||
assert!(message.contains(&AgentId::from_uuid(uuid::Uuid::from_u128(7)).to_string()));
|
||||
assert!(message.contains("idea_context_propose"));
|
||||
let files = fs.files.lock().unwrap();
|
||||
assert_eq!(files.get(project_context_file()).unwrap(), b"# old");
|
||||
assert!(
|
||||
|
||||
@ -533,8 +533,12 @@ impl AssignSkillToAgent {
|
||||
skill_id: SkillId,
|
||||
assigned: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let manifest = AgentManifest::with_orchestrator(
|
||||
manifest.version,
|
||||
manifest.entries,
|
||||
manifest.orchestrator,
|
||||
)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts.save_manifest(project, &manifest).await?;
|
||||
self.events.publish(DomainEvent::SkillAssigned {
|
||||
agent_id,
|
||||
@ -591,8 +595,12 @@ impl UnassignSkillFromAgent {
|
||||
*entry = domain::ManifestEntry::from_agent(&agent);
|
||||
|
||||
if changed {
|
||||
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let manifest = AgentManifest::with_orchestrator(
|
||||
manifest.version,
|
||||
manifest.entries,
|
||||
manifest.orchestrator,
|
||||
)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
.await?;
|
||||
|
||||
@ -408,9 +408,10 @@ impl CreateAgentFromTemplate {
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?
|
||||
.with_contactable(template.default_contactable);
|
||||
|
||||
let orchestrator = manifest.orchestrator;
|
||||
let mut entries = manifest.entries;
|
||||
entries.push(ManifestEntry::from_agent(&agent));
|
||||
let manifest = AgentManifest::new(manifest.version, entries)
|
||||
let manifest = AgentManifest::with_orchestrator(manifest.version, entries, orchestrator)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
@ -624,8 +625,12 @@ impl SyncAgentWithTemplate {
|
||||
manifest.entries[idx].synced_template_version = Some(template.version);
|
||||
|
||||
// Persist the manifest (revalidated) and overwrite the agent context.
|
||||
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let manifest = AgentManifest::with_orchestrator(
|
||||
manifest.version,
|
||||
manifest.entries,
|
||||
manifest.orchestrator,
|
||||
)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
.await?;
|
||||
|
||||
@ -59,6 +59,7 @@ use application::{
|
||||
ReadAgentContextInput, ReleaseManagedTemp, ResolveManagedTemp, StructuredRoutingMode,
|
||||
StructuredSessions, TerminalSessions, UpdateAgentContactable, UpdateAgentContactableInput,
|
||||
UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
|
||||
UpdateProjectOrchestrator, UpdateProjectOrchestratorInput,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -1069,6 +1070,63 @@ async fn list_agents_default_orchestrator_is_oldest_agent_when_none_designated()
|
||||
assert_eq!(entries[1].is_orchestrator, false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_orchestrator_persists_single_writer_and_publishes_change() {
|
||||
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a1, "ctx1");
|
||||
contexts
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.manifest
|
||||
.entries
|
||||
.push(ManifestEntry::from_agent(&a2));
|
||||
let events = SpyBus::default();
|
||||
let update =
|
||||
UpdateProjectOrchestrator::new(Arc::new(contexts.clone()), Arc::new(events.clone()));
|
||||
|
||||
let out = update
|
||||
.execute(UpdateProjectOrchestratorInput {
|
||||
project: project(),
|
||||
agent_id: a2.id,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.orchestrator, a2.id);
|
||||
assert_eq!(contexts.manifest().orchestrator, Some(a2.id));
|
||||
assert_eq!(contexts.manifest().effective_orchestrator(), Some(a2.id));
|
||||
assert_eq!(
|
||||
events.events(),
|
||||
vec![DomainEvent::OrchestratorChanged {
|
||||
project_id: project().id,
|
||||
orchestrator: Some(a2.id),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_orchestrator_rejects_unknown_agent_without_mutation_or_event() {
|
||||
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a1, "ctx1");
|
||||
let events = SpyBus::default();
|
||||
let update =
|
||||
UpdateProjectOrchestrator::new(Arc::new(contexts.clone()), Arc::new(events.clone()));
|
||||
|
||||
let err = update
|
||||
.execute(UpdateProjectOrchestratorInput {
|
||||
project: project(),
|
||||
agent_id: aid(404),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "NOT_FOUND");
|
||||
assert_eq!(contexts.manifest().orchestrator, None);
|
||||
assert!(events.events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_then_update_context_roundtrips() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
@ -1125,6 +1183,30 @@ async fn update_agent_effort_sets_preset_selection_and_persists_manifest() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_mutation_preserves_explicit_project_orchestrator() {
|
||||
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a1, "ctx1");
|
||||
{
|
||||
let mut inner = contexts.0.lock().unwrap();
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(&a2));
|
||||
inner.manifest.designate(a2.id).unwrap();
|
||||
}
|
||||
let update = UpdateAgentEffort::new(Arc::new(contexts.clone()));
|
||||
|
||||
update
|
||||
.execute(UpdateAgentEffortInput {
|
||||
project: project(),
|
||||
agent_id: a1.id,
|
||||
effort: Some(EffortSelection::Preset("high".to_owned())),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(contexts.manifest().orchestrator, Some(a2.id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_agent_effort_none_clears_existing_override() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9))
|
||||
@ -1236,6 +1318,35 @@ async fn delete_removes_entry_then_unknown_is_not_found() {
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_writer_clears_explicit_designation_and_falls_back_to_oldest_remaining() {
|
||||
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a1, "ctx1");
|
||||
{
|
||||
let mut inner = contexts.0.lock().unwrap();
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(&a2));
|
||||
inner.manifest.designate(a2.id).unwrap();
|
||||
}
|
||||
let events = SpyBus::default();
|
||||
let delete = DeleteAgent::new(Arc::new(contexts.clone()), Arc::new(events.clone()));
|
||||
|
||||
delete
|
||||
.execute(DeleteAgentInput {
|
||||
project: project(),
|
||||
agent_id: a2.id,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(contexts.manifest().orchestrator, None);
|
||||
assert_eq!(contexts.manifest().effective_orchestrator(), Some(a1.id));
|
||||
assert!(events.events().contains(&DomainEvent::OrchestratorChanged {
|
||||
project_id: project().id,
|
||||
orchestrator: None,
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_agent_cleans_structured_then_pty_runtime_before_manifest_removal() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
|
||||
Reference in New Issue
Block a user