feat(background): déclencheur in-app des tâches de fond via MCP idea_run_in_background (BE-1+BE-2)
Ferme la boucle B8 côté surface agent : un agent peut lancer une commande en tâche de fond depuis l'app, sans dépendance à un déclencheur externe. - domain : variante OrchestratorCommand::RunInBackground. - infrastructure : outil MCP idea_run_in_background (déclaration + schéma + map_tool_call), tests de mapping et compteur de catalogue. - application : handler → SpawnBackgroundCommand (owner=requester, WakeOwner). - app-tauri : câblage .with_spawn_background_command + catalogue MCP 23→24. Tests verts : domain 452 / application 467 / infrastructure 489 / app-tauri 236, build --release vert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -41,12 +41,11 @@ use application::{
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentWakePort,
|
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentWakePort,
|
||||||
BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskStore,
|
BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskStore, Clock, Embedder,
|
||||||
Clock, Embedder, EmbedderEnvInspector,
|
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort,
|
||||||
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
IdGenerator, IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore,
|
||||||
IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner,
|
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore,
|
||||||
ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, TemplateStore,
|
TemplateStore, WakeError, WakeReason,
|
||||||
WakeError, WakeReason,
|
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||||
@ -61,9 +60,9 @@ use serde_json::{json, Map, Value};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
AdaptiveMemoryRecall, BackgroundCompletionSink, BackgroundTaskReadyToDeliver,
|
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
|
||||||
ClaudePermissionProjector, ClaudeTranscriptInspector, CliAgentRuntime,
|
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
||||||
CodexPermissionProjector, CommandBackgroundRunner, embedder_from_profile, EmbedderEnvProbe,
|
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
|
||||||
FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||||
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMemoryStore,
|
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMemoryStore,
|
||||||
FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
|
FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
|
||||||
@ -1992,7 +1991,10 @@ impl AppState {
|
|||||||
.with_background_tasks(
|
.with_background_tasks(
|
||||||
Arc::clone(&background_tasks_port),
|
Arc::clone(&background_tasks_port),
|
||||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
),
|
)
|
||||||
|
// Producteur B8 côté agent MCP : `idea_run_in_background` passe par le
|
||||||
|
// même use case que la commande Tauri, sans aller-retour frontend.
|
||||||
|
.with_spawn_background_command(Arc::clone(&spawn_background_command)),
|
||||||
);
|
);
|
||||||
|
|
||||||
let stop_live_agent = Arc::new(
|
let stop_live_agent = Arc::new(
|
||||||
@ -4104,6 +4106,7 @@ mod mcp_serve_peer_tests {
|
|||||||
for expected in [
|
for expected in [
|
||||||
"idea_list_agents",
|
"idea_list_agents",
|
||||||
"idea_ask_agent",
|
"idea_ask_agent",
|
||||||
|
"idea_run_in_background",
|
||||||
"idea_launch_agent",
|
"idea_launch_agent",
|
||||||
"idea_stop_agent",
|
"idea_stop_agent",
|
||||||
"idea_update_context",
|
"idea_update_context",
|
||||||
@ -4140,8 +4143,8 @@ mod mcp_serve_peer_tests {
|
|||||||
assert!(!names.contains(&"idea_reply"));
|
assert!(!names.contains(&"idea_reply"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tools.len(),
|
tools.len(),
|
||||||
23,
|
24,
|
||||||
"exactly the twenty-three exposed idea_* tools; got {names:?}"
|
"exactly the twenty-four exposed idea_* tools; got {names:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
drop(client); // EOF ⇒ serve loop ends
|
drop(client); // EOF ⇒ serve loop ends
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
//! infrastructure adapter's job. That keeps this fully unit-testable with fakes.
|
//! infrastructure adapter's job. That keeps this fully unit-testable with fakes.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex as StdMutex};
|
use std::sync::{Arc, Mutex as StdMutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@ -39,6 +40,7 @@ use crate::agent::{
|
|||||||
ListAgents, ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext,
|
ListAgents, ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext,
|
||||||
UpdateAgentContextInput, CODEX_SUBMIT_DELAY_MS,
|
UpdateAgentContextInput, CODEX_SUBMIT_DELAY_MS,
|
||||||
};
|
};
|
||||||
|
use crate::background::{SpawnBackgroundCommand, SpawnBackgroundCommandInput};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::orchestrator::rendezvous::{run_inactivity_watchdog, WatchdogOutcome};
|
use crate::orchestrator::rendezvous::{run_inactivity_watchdog, WatchdogOutcome};
|
||||||
use crate::orchestrator::{
|
use crate::orchestrator::{
|
||||||
@ -89,6 +91,55 @@ fn bound_task_text(value: &str, max_bytes: usize) -> String {
|
|||||||
value[..end].to_owned()
|
value[..end].to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_background_cwd(
|
||||||
|
project_root: &ProjectPath,
|
||||||
|
cwd: Option<String>,
|
||||||
|
) -> Result<ProjectPath, AppError> {
|
||||||
|
let root = Path::new(project_root.as_str());
|
||||||
|
let raw = cwd.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||||
|
let candidate = match raw {
|
||||||
|
None => root.to_path_buf(),
|
||||||
|
Some(value) => {
|
||||||
|
let path = Path::new(value);
|
||||||
|
if path.is_absolute() {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
root.join(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let normalized = normalize_path_no_parent(&candidate)?;
|
||||||
|
if !normalized.starts_with(root) {
|
||||||
|
return Err(AppError::Invalid(
|
||||||
|
"background task cwd must stay under the project root".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Some(path) = normalized.to_str() else {
|
||||||
|
return Err(AppError::Invalid(
|
||||||
|
"background task cwd is not valid UTF-8".to_owned(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
ProjectPath::new(path).map_err(|_| AppError::Invalid("invalid background task cwd".to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_path_no_parent(path: &Path) -> Result<PathBuf, AppError> {
|
||||||
|
let mut out = PathBuf::new();
|
||||||
|
for component in path.components() {
|
||||||
|
match component {
|
||||||
|
Component::Prefix(prefix) => out.push(prefix.as_os_str()),
|
||||||
|
Component::RootDir => out.push(component.as_os_str()),
|
||||||
|
Component::CurDir => {}
|
||||||
|
Component::Normal(part) => out.push(part),
|
||||||
|
Component::ParentDir => {
|
||||||
|
return Err(AppError::Invalid(
|
||||||
|
"background task cwd must not contain `..`".to_owned(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
/// Bound on the synchronous inter-agent rendezvous (`agent.message` → `AskAgent`).
|
/// Bound on the synchronous inter-agent rendezvous (`agent.message` → `AskAgent`).
|
||||||
///
|
///
|
||||||
/// A target agent's turn can be long (reasoning + tool use), so the cap is
|
/// A target agent's turn can be long (reasoning + tool use), so the cap is
|
||||||
@ -433,6 +484,9 @@ pub struct OrchestratorService {
|
|||||||
/// `idea_ask_agent` comme [`BackgroundTaskKind::HeadlessRendezvous`]. `None` ⇒
|
/// `idea_ask_agent` comme [`BackgroundTaskKind::HeadlessRendezvous`]. `None` ⇒
|
||||||
/// comportement historique sans persistance de tâche (call sites non câblés).
|
/// comportement historique sans persistance de tâche (call sites non câblés).
|
||||||
background_tasks: Option<Arc<dyn BackgroundTaskStore>>,
|
background_tasks: Option<Arc<dyn BackgroundTaskStore>>,
|
||||||
|
/// Use case B8 qui produit une tâche de fond command-backed depuis la surface
|
||||||
|
/// agent MCP `idea_run_in_background`. `None` ⇒ outil non configuré.
|
||||||
|
spawn_background_command: Option<Arc<SpawnBackgroundCommand>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
|
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
|
||||||
@ -504,9 +558,17 @@ impl OrchestratorService {
|
|||||||
ask_liveness_probe: None,
|
ask_liveness_probe: None,
|
||||||
ask_ceiling: ASK_AGENT_CEILING,
|
ask_ceiling: ASK_AGENT_CEILING,
|
||||||
background_tasks: None,
|
background_tasks: None,
|
||||||
|
spawn_background_command: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Branche le producteur B8 command-backed pour `idea_run_in_background`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_spawn_background_command(mut self, spawn: Arc<SpawnBackgroundCommand>) -> Self {
|
||||||
|
self.spawn_background_command = Some(spawn);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Branche le store des tâches de fond pour tracer les rendez-vous inter-agents
|
/// Branche le store des tâches de fond pour tracer les rendez-vous inter-agents
|
||||||
/// comme [`BackgroundTaskKind::HeadlessRendezvous`] (B6).
|
/// comme [`BackgroundTaskKind::HeadlessRendezvous`] (B6).
|
||||||
///
|
///
|
||||||
@ -1096,9 +1158,70 @@ impl OrchestratorService {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
OrchestratorCommand::RunInBackground {
|
||||||
|
owner,
|
||||||
|
label,
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
cwd,
|
||||||
|
deadline_ms,
|
||||||
|
} => {
|
||||||
|
self.run_in_background(project, owner, label, command, args, cwd, deadline_ms)
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_in_background(
|
||||||
|
&self,
|
||||||
|
project: &Project,
|
||||||
|
owner: AgentId,
|
||||||
|
label: String,
|
||||||
|
command: String,
|
||||||
|
args: Vec<String>,
|
||||||
|
cwd: Option<String>,
|
||||||
|
deadline_ms: Option<u64>,
|
||||||
|
) -> Result<OrchestratorOutcome, AppError> {
|
||||||
|
let spawn = self.spawn_background_command.as_ref().ok_or_else(|| {
|
||||||
|
AppError::Invalid("background command runner is not configured".to_owned())
|
||||||
|
})?;
|
||||||
|
let cwd = resolve_background_cwd(&project.root, cwd)?;
|
||||||
|
let output = spawn
|
||||||
|
.execute(SpawnBackgroundCommandInput {
|
||||||
|
project_id: project.id,
|
||||||
|
owner_agent_id: owner,
|
||||||
|
label,
|
||||||
|
command: domain::ports::SpawnSpec {
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
cwd,
|
||||||
|
env: Vec::new(),
|
||||||
|
context_plan: None,
|
||||||
|
sandbox: None,
|
||||||
|
},
|
||||||
|
wake_policy: BackgroundTaskWakePolicy::WakeOwner,
|
||||||
|
deadline_ms,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let state = match output.task.state {
|
||||||
|
BackgroundTaskState::Queued => "Queued",
|
||||||
|
BackgroundTaskState::Running => "Running",
|
||||||
|
BackgroundTaskState::Waiting => "Waiting",
|
||||||
|
BackgroundTaskState::Completed => "Completed",
|
||||||
|
BackgroundTaskState::Failed => "Failed",
|
||||||
|
BackgroundTaskState::Cancelled => "Cancelled",
|
||||||
|
BackgroundTaskState::Expired => "Expired",
|
||||||
|
};
|
||||||
|
Ok(OrchestratorOutcome {
|
||||||
|
detail: serde_json::json!({
|
||||||
|
"taskId": output.task.id.to_string(),
|
||||||
|
"state": state,
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
reply: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the injected C7 use cases, or a typed error when unwired.
|
/// Returns the injected C7 use cases, or a typed error when unwired.
|
||||||
fn require_context_guard(&self) -> Result<&ContextGuardUseCases, AppError> {
|
fn require_context_guard(&self) -> Result<&ContextGuardUseCases, AppError> {
|
||||||
self.context_guard.as_deref().ok_or_else(|| {
|
self.context_guard.as_deref().ok_or_else(|| {
|
||||||
|
|||||||
@ -21,10 +21,11 @@ use domain::ids::SkillId;
|
|||||||
use domain::ids::{AgentId, NodeId, ProfileId, ProjectId};
|
use domain::ids::{AgentId, NodeId, ProfileId, ProjectId};
|
||||||
use domain::markdown::MarkdownDoc;
|
use domain::markdown::MarkdownDoc;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
AgentContextStore, AgentRuntime, BackgroundCompletionStream, BackgroundTaskHandle,
|
||||||
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryStore, OutputStream,
|
BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec, BackgroundTaskStore,
|
||||||
PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError,
|
ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError,
|
||||||
SessionPlan, SkillStore, SpawnSpec, StoreError,
|
IdGenerator, MemoryError, MemoryStore, OutputStream, PreparedContext, ProfileStore, PtyError,
|
||||||
|
PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
||||||
@ -34,14 +35,17 @@ use domain::project::{Project, ProjectPath};
|
|||||||
use domain::remote::RemoteRef;
|
use domain::remote::RemoteRef;
|
||||||
use domain::skill::{Skill, SkillScope};
|
use domain::skill::{Skill, SkillScope};
|
||||||
use domain::terminal::{SessionKind, TerminalSession};
|
use domain::terminal::{SessionKind, TerminalSession};
|
||||||
|
use domain::{
|
||||||
|
BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, OrchestratorCommand,
|
||||||
|
OrchestratorRequest, PtySize, SessionId, TaskId,
|
||||||
|
};
|
||||||
use domain::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
use domain::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||||
use domain::{OrchestratorCommand, OrchestratorRequest, PtySize, SessionId};
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
CloseTerminal, CreateAgentFromScratch, CreateSkill, GetLiveStateLean, HarvestMemoryFromTurn,
|
CloseTerminal, CreateAgentFromScratch, CreateSkill, GetLiveStateLean, HarvestMemoryFromTurn,
|
||||||
LaunchAgent, ListAgents, LiveStateProvider, LiveStateReadProvider, OrchestratorService,
|
LaunchAgent, ListAgents, LiveStateProvider, LiveStateReadProvider, OrchestratorService,
|
||||||
TerminalSessions, UpdateAgentContext, UpdateLiveState,
|
SpawnBackgroundCommand, TerminalSessions, UpdateAgentContext, UpdateLiveState,
|
||||||
};
|
};
|
||||||
use domain::live_state::{LiveEntry, LiveState, WorkStatus};
|
use domain::live_state::{LiveEntry, LiveState, WorkStatus};
|
||||||
use domain::ports::LiveStateStore;
|
use domain::ports::LiveStateStore;
|
||||||
@ -370,6 +374,104 @@ impl PtyPort for FakePty {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct RecordingBackgroundStore {
|
||||||
|
tasks: Arc<Mutex<HashMap<TaskId, BackgroundTask>>>,
|
||||||
|
}
|
||||||
|
impl RecordingBackgroundStore {
|
||||||
|
fn task(&self, id: TaskId) -> Option<BackgroundTask> {
|
||||||
|
self.tasks.lock().unwrap().get(&id).cloned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl BackgroundTaskStore for RecordingBackgroundStore {
|
||||||
|
async fn create(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> {
|
||||||
|
let mut tasks = self.tasks.lock().unwrap();
|
||||||
|
if tasks.contains_key(&task.id) {
|
||||||
|
return Err(BackgroundTaskPortError::AlreadyExists);
|
||||||
|
}
|
||||||
|
tasks.insert(task.id, task.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, id: TaskId) -> Result<Option<BackgroundTask>, BackgroundTaskPortError> {
|
||||||
|
Ok(self.task(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> {
|
||||||
|
self.tasks.lock().unwrap().insert(task.id, task.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_open_for_agent(
|
||||||
|
&self,
|
||||||
|
agent_id: AgentId,
|
||||||
|
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
|
||||||
|
Ok(self
|
||||||
|
.tasks
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.filter(|task| task.owner_agent_id == agent_id && !task.is_terminal())
|
||||||
|
.cloned()
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_undelivered_completions(
|
||||||
|
&self,
|
||||||
|
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
|
||||||
|
Ok(self
|
||||||
|
.tasks
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.filter(|task| task.has_pending_completion_delivery())
|
||||||
|
.cloned()
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_completion_delivered(
|
||||||
|
&self,
|
||||||
|
task_id: TaskId,
|
||||||
|
) -> Result<(), BackgroundTaskPortError> {
|
||||||
|
let task = self
|
||||||
|
.task(task_id)
|
||||||
|
.ok_or(BackgroundTaskPortError::NotFound)?
|
||||||
|
.mark_completion_delivered()
|
||||||
|
.map_err(|err| BackgroundTaskPortError::Invalid(err.to_string()))?;
|
||||||
|
self.save(&task).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct RecordingBackgroundRunner {
|
||||||
|
specs: Arc<Mutex<Vec<BackgroundTaskSpec>>>,
|
||||||
|
}
|
||||||
|
impl RecordingBackgroundRunner {
|
||||||
|
fn specs(&self) -> Vec<BackgroundTaskSpec> {
|
||||||
|
self.specs.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl BackgroundTaskRunner for RecordingBackgroundRunner {
|
||||||
|
async fn spawn(
|
||||||
|
&self,
|
||||||
|
spec: BackgroundTaskSpec,
|
||||||
|
) -> Result<BackgroundTaskHandle, BackgroundTaskPortError> {
|
||||||
|
let task_id = spec.task_id;
|
||||||
|
self.specs.lock().unwrap().push(spec);
|
||||||
|
Ok(BackgroundTaskHandle { task_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cancel(&self, _task_id: TaskId) -> Result<(), BackgroundTaskPortError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribe_completions(&self) -> BackgroundCompletionStream {
|
||||||
|
Box::new(std::iter::empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default, Clone)]
|
#[derive(Default, Clone)]
|
||||||
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
||||||
impl SpyBus {
|
impl SpyBus {
|
||||||
@ -683,6 +785,71 @@ async fn spawn_unknown_agent_creates_then_launches() {
|
|||||||
assert!(launched, "AgentLaunched must be published");
|
assert!(launched, "AgentLaunched must be published");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn run_in_background_dispatch_creates_running_command_for_requester() {
|
||||||
|
let fx = fixture(FakeContexts::new());
|
||||||
|
let store = Arc::new(RecordingBackgroundStore::default());
|
||||||
|
let runner = Arc::new(RecordingBackgroundRunner::default());
|
||||||
|
let spawn = Arc::new(SpawnBackgroundCommand::new(
|
||||||
|
Arc::clone(&store) as Arc<dyn BackgroundTaskStore>,
|
||||||
|
Arc::clone(&runner) as Arc<dyn BackgroundTaskRunner>,
|
||||||
|
Arc::new(FixedMillisClock(42)) as Arc<dyn domain::ports::Clock>,
|
||||||
|
Arc::new(SeqIds::new()) as Arc<dyn IdGenerator>,
|
||||||
|
));
|
||||||
|
let service = fx.service.with_spawn_background_command(spawn);
|
||||||
|
let owner = aid(77);
|
||||||
|
|
||||||
|
let out = service
|
||||||
|
.dispatch(
|
||||||
|
&project(),
|
||||||
|
OrchestratorCommand::RunInBackground {
|
||||||
|
owner,
|
||||||
|
label: "B8 T1".to_owned(),
|
||||||
|
command: "bash".to_owned(),
|
||||||
|
args: vec![
|
||||||
|
"-lc".to_owned(),
|
||||||
|
"sleep 45 && echo DONE_MARKER_DEVBACKEND_B8_T1".to_owned(),
|
||||||
|
],
|
||||||
|
cwd: Some("crates".to_owned()),
|
||||||
|
deadline_ms: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("dispatch ok");
|
||||||
|
|
||||||
|
let payload: serde_json::Value = serde_json::from_str(&out.detail).unwrap();
|
||||||
|
assert_eq!(payload["state"], "Running");
|
||||||
|
let task_id =
|
||||||
|
TaskId::from_uuid(uuid::Uuid::parse_str(payload["taskId"].as_str().unwrap()).unwrap());
|
||||||
|
let task = store.task(task_id).expect("task persisted");
|
||||||
|
assert_eq!(task.project_id, project().id);
|
||||||
|
assert_eq!(task.owner_agent_id, owner);
|
||||||
|
assert_eq!(task.state, BackgroundTaskState::Running);
|
||||||
|
assert_eq!(task.wake_policy, BackgroundTaskWakePolicy::WakeOwner);
|
||||||
|
assert_eq!(
|
||||||
|
task.kind,
|
||||||
|
domain::BackgroundTaskKind::Command {
|
||||||
|
label: "B8 T1".to_owned(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let specs = runner.specs();
|
||||||
|
assert_eq!(specs.len(), 1);
|
||||||
|
assert_eq!(specs[0].task_id, task_id);
|
||||||
|
assert_eq!(specs[0].owner_agent_id, owner);
|
||||||
|
assert_eq!(specs[0].wake_policy, BackgroundTaskWakePolicy::WakeOwner);
|
||||||
|
let command = specs[0].command.as_ref().expect("command spec");
|
||||||
|
assert_eq!(command.command, "bash");
|
||||||
|
assert_eq!(
|
||||||
|
command.args,
|
||||||
|
vec![
|
||||||
|
"-lc".to_owned(),
|
||||||
|
"sleep 45 && echo DONE_MARKER_DEVBACKEND_B8_T1".to_owned(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(command.cwd.as_str(), "/home/me/proj/crates");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn spawn_known_agent_launches_without_recreating() {
|
async fn spawn_known_agent_launches_without_recreating() {
|
||||||
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");
|
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");
|
||||||
|
|||||||
@ -159,6 +159,24 @@ pub struct OrchestratorRequest {
|
|||||||
/// the other actions.
|
/// the other actions.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub last_delegation: Option<String>,
|
pub last_delegation: Option<String>,
|
||||||
|
/// Human-facing label for `background.run`. Required by that action, ignored
|
||||||
|
/// by the other actions.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub label: Option<String>,
|
||||||
|
/// Executable for `background.run`. Required by that action, ignored by the
|
||||||
|
/// other actions.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub command: Option<String>,
|
||||||
|
/// Arguments for `background.run`. Optional and defaults to an empty list.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub args: Vec<String>,
|
||||||
|
/// Optional working directory for `background.run`, resolved by the
|
||||||
|
/// application layer under the project root.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cwd: Option<String>,
|
||||||
|
/// Optional absolute deadline for `background.run`, epoch milliseconds.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deadline_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A validated orchestrator command — the only thing the application layer acts on.
|
/// A validated orchestrator command — the only thing the application layer acts on.
|
||||||
@ -323,6 +341,25 @@ pub enum OrchestratorCommand {
|
|||||||
/// The ticket of the most recent delegation it issued, if any.
|
/// The ticket of the most recent delegation it issued, if any.
|
||||||
last_delegation: Option<TicketId>,
|
last_delegation: Option<TicketId>,
|
||||||
},
|
},
|
||||||
|
/// Start a command-backed first-class background task (`idea_run_in_background`).
|
||||||
|
///
|
||||||
|
/// The owner is the connected peer's handshake identity (`requestedBy`), never
|
||||||
|
/// a model-supplied parameter. The application layer resolves the project from
|
||||||
|
/// the dispatch context and forces `WakeOwner`.
|
||||||
|
RunInBackground {
|
||||||
|
/// Owning agent, parsed from `requestedBy`.
|
||||||
|
owner: AgentId,
|
||||||
|
/// Human-facing label.
|
||||||
|
label: String,
|
||||||
|
/// Executable to run.
|
||||||
|
command: String,
|
||||||
|
/// Command arguments.
|
||||||
|
args: Vec<String>,
|
||||||
|
/// Optional working directory, resolved under the project root downstream.
|
||||||
|
cwd: Option<String>,
|
||||||
|
/// Optional absolute deadline, epoch milliseconds.
|
||||||
|
deadline_ms: Option<u64>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where IdeA should place a launched agent session.
|
/// Where IdeA should place a launched agent session.
|
||||||
@ -433,6 +470,19 @@ impl OrchestratorRequest {
|
|||||||
last_delegation: self
|
last_delegation: self
|
||||||
.parse_optional_ticket(action, self.last_delegation.as_deref())?,
|
.parse_optional_ticket(action, self.last_delegation.as_deref())?,
|
||||||
}),
|
}),
|
||||||
|
"background.run" => Ok(OrchestratorCommand::RunInBackground {
|
||||||
|
owner: self.require_from(action)?,
|
||||||
|
label: self.require("label", action, self.label.as_deref())?,
|
||||||
|
command: self.require("command", action, self.command.as_deref())?,
|
||||||
|
args: self.args.clone(),
|
||||||
|
cwd: self
|
||||||
|
.cwd
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_owned),
|
||||||
|
deadline_ms: self.deadline_ms,
|
||||||
|
}),
|
||||||
"list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents),
|
"list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents),
|
||||||
"create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill {
|
"create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill {
|
||||||
name: self.require_name(action)?,
|
name: self.require_name(action)?,
|
||||||
@ -1139,6 +1189,60 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn background_run_keys_owner_on_requester_and_parses_command() {
|
||||||
|
let owner = uuid::Uuid::from_u128(33);
|
||||||
|
let r = req(&format!(
|
||||||
|
r#"{{ "type":"background.run", "requestedBy":"{owner}", "label":"long test",
|
||||||
|
"command":"bash", "args":["-lc","sleep 1 && echo done"],
|
||||||
|
"cwd":"subdir", "deadlineMs":12345 }}"#
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
r.validate().unwrap(),
|
||||||
|
OrchestratorCommand::RunInBackground {
|
||||||
|
owner: AgentId::from_uuid(owner),
|
||||||
|
label: "long test".to_owned(),
|
||||||
|
command: "bash".to_owned(),
|
||||||
|
args: vec!["-lc".to_owned(), "sleep 1 && echo done".to_owned()],
|
||||||
|
cwd: Some("subdir".to_owned()),
|
||||||
|
deadline_ms: Some(12345),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn background_run_requires_handshake_owner_label_and_command() {
|
||||||
|
assert_eq!(
|
||||||
|
req(r#"{ "type":"background.run", "label":"x", "command":"bash" }"#).validate(),
|
||||||
|
Err(OrchestratorError::MissingField {
|
||||||
|
action: "background.run".to_owned(),
|
||||||
|
field: "requestedBy".to_owned(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let owner = uuid::Uuid::from_u128(33);
|
||||||
|
assert_eq!(
|
||||||
|
req(&format!(
|
||||||
|
r#"{{ "type":"background.run", "requestedBy":"{owner}", "command":"bash" }}"#
|
||||||
|
))
|
||||||
|
.validate(),
|
||||||
|
Err(OrchestratorError::MissingField {
|
||||||
|
action: "background.run".to_owned(),
|
||||||
|
field: "label".to_owned(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
req(&format!(
|
||||||
|
r#"{{ "type":"background.run", "requestedBy":"{owner}", "label":"x" }}"#
|
||||||
|
))
|
||||||
|
.validate(),
|
||||||
|
Err(OrchestratorError::MissingField {
|
||||||
|
action: "background.run".to_owned(),
|
||||||
|
field: "command".to_owned(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_action_is_rejected() {
|
fn unknown_action_is_rejected() {
|
||||||
let r = req(r#"{ "action": "delete_everything", "name": "a" }"#);
|
let r = req(r#"{ "action": "delete_everything", "name": "a" }"#);
|
||||||
|
|||||||
@ -60,6 +60,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
|
|||||||
| "idea_context_read"
|
| "idea_context_read"
|
||||||
| "idea_memory_read"
|
| "idea_memory_read"
|
||||||
| "idea_skill_read"
|
| "idea_skill_read"
|
||||||
|
| "idea_run_in_background"
|
||||||
| "idea_workstate_read"
|
| "idea_workstate_read"
|
||||||
) || is_ticket_tool(tool)
|
) || is_ticket_tool(tool)
|
||||||
}
|
}
|
||||||
@ -103,6 +104,27 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
ToolDef {
|
||||||
|
name: "idea_run_in_background",
|
||||||
|
description: "Run a command as a first-class IdeA background task. Returns the task id \
|
||||||
|
immediately; completion is delivered later to your inbox.",
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"label": { "type": "string", "description": "Human-facing label for the background task." },
|
||||||
|
"command": { "type": "string", "description": "Executable to run." },
|
||||||
|
"args": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"description": "Command arguments."
|
||||||
|
},
|
||||||
|
"cwd": { "type": "string", "description": "Working directory under the project root. Defaults to the project root." },
|
||||||
|
"deadline_ms": { "type": "number", "description": "Optional absolute deadline, epoch milliseconds." }
|
||||||
|
},
|
||||||
|
"required": ["label", "command"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}),
|
||||||
|
},
|
||||||
ToolDef {
|
ToolDef {
|
||||||
name: "idea_launch_agent",
|
name: "idea_launch_agent",
|
||||||
description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \
|
description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \
|
||||||
@ -316,6 +338,16 @@ pub fn map_tool_call(
|
|||||||
task: s("task"),
|
task: s("task"),
|
||||||
..base()
|
..base()
|
||||||
},
|
},
|
||||||
|
"idea_run_in_background" => OrchestratorRequest {
|
||||||
|
request_type: Some("background.run".to_owned()),
|
||||||
|
requested_by: Some(requester.to_owned()),
|
||||||
|
label: s("label"),
|
||||||
|
command: s("command"),
|
||||||
|
args: string_array(args.get("args"), name)?,
|
||||||
|
cwd: s("cwd"),
|
||||||
|
deadline_ms: optional_u64(args.get("deadline_ms"), name)?,
|
||||||
|
..base()
|
||||||
|
},
|
||||||
"idea_stop_agent" => OrchestratorRequest {
|
"idea_stop_agent" => OrchestratorRequest {
|
||||||
request_type: Some("agent.stop".to_owned()),
|
request_type: Some("agent.stop".to_owned()),
|
||||||
target_agent: s("target"),
|
target_agent: s("target"),
|
||||||
@ -417,6 +449,11 @@ fn base() -> OrchestratorRequest {
|
|||||||
intent: None,
|
intent: None,
|
||||||
progress: None,
|
progress: None,
|
||||||
last_delegation: None,
|
last_delegation: None,
|
||||||
|
label: None,
|
||||||
|
command: None,
|
||||||
|
args: Vec::new(),
|
||||||
|
cwd: None,
|
||||||
|
deadline_ms: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -430,6 +467,33 @@ fn parse_node_id(value: Option<&Value>) -> Option<domain::NodeId> {
|
|||||||
.map(domain::NodeId::from_uuid)
|
.map(domain::NodeId::from_uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn string_array(value: Option<&Value>, tool: &str) -> Result<Vec<String>, ToolMapError> {
|
||||||
|
let Some(value) = value else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let items = value
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| ToolMapError::BadArguments(tool.to_owned()))?;
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
item.as_str()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.ok_or_else(|| ToolMapError::BadArguments(tool.to_owned()))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_u64(value: Option<&Value>, tool: &str) -> Result<Option<u64>, ToolMapError> {
|
||||||
|
let Some(value) = value else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
value
|
||||||
|
.as_u64()
|
||||||
|
.map(Some)
|
||||||
|
.ok_or_else(|| ToolMapError::BadArguments(tool.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -479,6 +543,66 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_in_background_maps_to_background_command_with_handshake_owner() {
|
||||||
|
let cmd = map_tool_call(
|
||||||
|
"idea_run_in_background",
|
||||||
|
&json!({
|
||||||
|
"label": "B8 T1",
|
||||||
|
"command": "bash",
|
||||||
|
"args": ["-lc", "sleep 45 && echo DONE"],
|
||||||
|
"cwd": "crates",
|
||||||
|
"deadline_ms": 123456
|
||||||
|
}),
|
||||||
|
REQ,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
OrchestratorCommand::RunInBackground {
|
||||||
|
owner: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()),
|
||||||
|
label: "B8 T1".to_owned(),
|
||||||
|
command: "bash".to_owned(),
|
||||||
|
args: vec!["-lc".to_owned(), "sleep 45 && echo DONE".to_owned()],
|
||||||
|
cwd: Some("crates".to_owned()),
|
||||||
|
deadline_ms: Some(123456),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(tool_returns_reply("idea_run_in_background"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_in_background_rejects_absent_requester_and_malformed_args() {
|
||||||
|
let no_requester = map_tool_call(
|
||||||
|
"idea_run_in_background",
|
||||||
|
&json!({ "label": "x", "command": "bash" }),
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
assert!(matches!(no_requester, Err(ToolMapError::Invalid(_))));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
map_tool_call(
|
||||||
|
"idea_run_in_background",
|
||||||
|
&json!({ "label": "x", "command": "bash", "args": "nope" }),
|
||||||
|
REQ,
|
||||||
|
),
|
||||||
|
Err(ToolMapError::BadArguments(
|
||||||
|
"idea_run_in_background".to_owned()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
map_tool_call(
|
||||||
|
"idea_run_in_background",
|
||||||
|
&json!({ "label": "x", "command": "bash", "args": [1] }),
|
||||||
|
REQ,
|
||||||
|
),
|
||||||
|
Err(ToolMapError::BadArguments(
|
||||||
|
"idea_run_in_background".to_owned()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn launch_agent_maps_to_spawn_background_by_default() {
|
fn launch_agent_maps_to_spawn_background_by_default() {
|
||||||
let cmd = map("idea_launch_agent", &json!({ "target": "Dev" })).unwrap();
|
let cmd = map("idea_launch_agent", &json!({ "target": "Dev" })).unwrap();
|
||||||
|
|||||||
@ -486,6 +486,7 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
|||||||
for expected in [
|
for expected in [
|
||||||
"idea_list_agents",
|
"idea_list_agents",
|
||||||
"idea_ask_agent",
|
"idea_ask_agent",
|
||||||
|
"idea_run_in_background",
|
||||||
"idea_launch_agent",
|
"idea_launch_agent",
|
||||||
"idea_stop_agent",
|
"idea_stop_agent",
|
||||||
"idea_update_context",
|
"idea_update_context",
|
||||||
@ -520,8 +521,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
|
|||||||
assert!(!names.contains(&"idea_reply"));
|
assert!(!names.contains(&"idea_reply"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tools.len(),
|
tools.len(),
|
||||||
23,
|
24,
|
||||||
"exactly the twenty-three exposed idea_* tools; got {names:?}"
|
"exactly the twenty-four exposed idea_* tools; got {names:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Every tool advertises an object input schema.
|
// Every tool advertises an object input schema.
|
||||||
|
|||||||
Reference in New Issue
Block a user