diff --git a/crates/agent-runtime/src/models.rs b/crates/agent-runtime/src/models.rs index 0391980a..e80382f5 100644 --- a/crates/agent-runtime/src/models.rs +++ b/crates/agent-runtime/src/models.rs @@ -30,6 +30,8 @@ impl WorkSpec { self.context.as_deref() == Some("design") } + /// Mirror of `delivery::ports::WorkSpec::to_prompt` (this crate deliberately + /// avoids depending on `delivery`); the two must stay in sync. pub fn to_prompt(&self) -> String { let mut p = String::new(); if let Some(instr) = &self.instructions { diff --git a/crates/delivery/src/adapters/local.rs b/crates/delivery/src/adapters/local.rs index c31af2e8..839e0095 100644 --- a/crates/delivery/src/adapters/local.rs +++ b/crates/delivery/src/adapters/local.rs @@ -46,24 +46,6 @@ impl LocalCommandAgentExecutor { } } -fn spec_to_prompt(spec: &WorkSpec) -> String { - let mut p = String::new(); - if let Some(instr) = &spec.instructions { - p.push_str(&format!("# Behavior (skills)\n{instr}\n\n")); - } - p.push_str(&format!("# Task: {}\n\n{}\n", spec.title, spec.description)); - if !spec.acceptance_criteria.is_empty() { - p.push_str("\nAcceptance criteria:\n"); - for c in &spec.acceptance_criteria { - p.push_str(&format!("- {c}\n")); - } - } - if let Some(ctx) = &spec.context { - p.push_str(&format!("\nContext: {ctx}\n")); - } - p -} - enum Line { Event(NewExecutionEvent), Result { reference: String, summary: String }, @@ -118,7 +100,7 @@ impl AgentExecutor for LocalCommandAgentExecutor { .map_err(|e| ExecError::Backend(format!("spawn {program}: {e}")))?; if let Some(mut stdin) = child.stdin.take() { stdin - .write_all(spec_to_prompt(spec).as_bytes()) + .write_all(spec.to_prompt().as_bytes()) .await .map_err(|e| ExecError::Backend(e.to_string()))?; } @@ -139,7 +121,7 @@ impl AgentExecutor for LocalCommandAgentExecutor { if let Some(mut stdin) = child.stdin.take() { stdin - .write_all(spec_to_prompt(spec).as_bytes()) + .write_all(spec.to_prompt().as_bytes()) .await .map_err(|e| ExecError::Backend(e.to_string()))?; // stdin drops here, sending EOF to the child (line reads would block forever otherwise) diff --git a/crates/delivery/src/adapters/queue.rs b/crates/delivery/src/adapters/queue.rs index 99370ea0..702ec876 100644 --- a/crates/delivery/src/adapters/queue.rs +++ b/crates/delivery/src/adapters/queue.rs @@ -22,7 +22,7 @@ use crate::ports::{ RuntimeInfo, WorkQueue, WorkSpec, }; -const KNOWN_CAPS: [ExecutorKind; 5] = ExecutorKind::ALL; +const KNOWN_CAPS: &[ExecutorKind] = ExecutorKind::ALL; #[derive(Default)] pub struct InMemoryWorkQueue { @@ -94,8 +94,8 @@ impl WorkQueue for InMemoryWorkQueue { async fn stats(&self) -> Vec { let q = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); KNOWN_CAPS - .into_iter() - .map(|k| QueueStat { + .iter() + .map(|&k| QueueStat { executor: k, ready: q.iter().filter(|s| s.executor == k).count() as u64, in_flight: 0, diff --git a/crates/delivery/src/adapters/redis_queue.rs b/crates/delivery/src/adapters/redis_queue.rs index 42ae4d60..3c462c83 100644 --- a/crates/delivery/src/adapters/redis_queue.rs +++ b/crates/delivery/src/adapters/redis_queue.rs @@ -25,7 +25,7 @@ const ACKMAP: &str = "fleet:ackmap"; const RT_INDEX: &str = "fleet:rt:index"; const SEP: char = '\u{1f}'; -fn known_caps() -> [ExecutorKind; 5] { +fn known_caps() -> &'static [ExecutorKind] { ExecutorKind::ALL } @@ -71,7 +71,7 @@ impl RedisStreamQueue { let client = redis::Client::open(url)?; let mut conn = client.get_multiplexed_async_connection().await?; for cap in known_caps() { - let key = stream_key(cap); + let key = stream_key(*cap); // Existing group yields BUSYGROUP; ignore for idempotency. let res: redis::RedisResult<()> = redis::cmd("XGROUP") .arg("CREATE") @@ -174,7 +174,7 @@ impl WorkQueue for RedisStreamQueue { let grace_ms = grace.as_millis() as usize; let mut conn = self.conn.clone(); let mut requeued = 0usize; - let mut keys: Vec = known_caps().into_iter().map(stream_key).collect(); + let mut keys: Vec = known_caps().iter().map(|&cap| stream_key(cap)).collect(); let rt_names: Vec = conn.smembers(RT_INDEX).await.unwrap_or_default(); keys.extend(rt_names.iter().map(|n| rt_stream_key(n))); for key in keys { @@ -220,12 +220,12 @@ impl WorkQueue for RedisStreamQueue { let mut conn = self.conn.clone(); let mut out = Vec::with_capacity(known_caps().len()); for cap in known_caps() { - let key = stream_key(cap); + let key = stream_key(*cap); let groups: StreamInfoGroupsReply = match conn.xinfo_groups(&key).await { Ok(g) => g, Err(_) => { out.push(QueueStat { - executor: cap, + executor: *cap, ready: 0, in_flight: 0, oldest_in_flight_ms: 0, @@ -246,7 +246,7 @@ impl WorkQueue for RedisStreamQueue { } else { 0 }; - out.push(QueueStat { executor: cap, ready, in_flight, oldest_in_flight_ms }); + out.push(QueueStat { executor: *cap, ready, in_flight, oldest_in_flight_ms }); } out } diff --git a/crates/delivery/src/domain/delivery.rs b/crates/delivery/src/domain/delivery.rs index d003ce3d..2515526c 100644 --- a/crates/delivery/src/domain/delivery.rs +++ b/crates/delivery/src/domain/delivery.rs @@ -17,9 +17,9 @@ pub enum ExecutorKind { impl ExecutorKind { /// Single authoritative list of executor kinds; queue adapters and tests consume - /// it, so a new variant only touches this file. - pub const ALL: [ExecutorKind; 5] = - [Self::ClaudeCode, Self::Codex, Self::OpenCode, Self::CodeBuddy, Self::DeepCode]; + /// it. A slice (not a fixed-size array) so adding a variant only touches this file. + pub const ALL: &[ExecutorKind] = + &[Self::ClaudeCode, Self::Codex, Self::OpenCode, Self::CodeBuddy, Self::DeepCode]; pub fn as_str(&self) -> &'static str { match self { @@ -267,7 +267,7 @@ mod tests { #[test] fn enum_str_roundtrips() { - for e in ExecutorKind::ALL { + for &e in ExecutorKind::ALL { assert_eq!(ExecutorKind::parse(e.as_str()), Some(e)); } assert_eq!(ExecutorKind::parse("X"), None); diff --git a/crates/delivery/src/ports/agent_executor.rs b/crates/delivery/src/ports/agent_executor.rs index f3ab4b34..21eef785 100644 --- a/crates/delivery/src/ports/agent_executor.rs +++ b/crates/delivery/src/ports/agent_executor.rs @@ -27,6 +27,31 @@ pub struct WorkSpec { pub target_runtime: Option, } +impl WorkSpec { + /// Canonical prompt rendering for executor CLIs. `agent-runtime` keeps a + /// standalone mirror of this function (it deliberately avoids depending on + /// this crate); the two must stay in sync. + pub fn to_prompt(&self) -> String { + let mut p = String::new(); + if let Some(instr) = &self.instructions { + p.push_str(&format!("# Behavior (skills)\n{instr}\n\n")); + } + p.push_str(&format!("# Task: {}\n\n{}\n", self.title, self.description)); + if !self.acceptance_criteria.is_empty() { + p.push_str("\nAcceptance criteria:\n"); + for c in &self.acceptance_criteria { + p.push_str(&format!("- {c}\n")); + } + } + if let Some(ctx) = &self.context { + if ctx != "design" { + p.push_str(&format!("\nContext: {ctx}\n")); + } + } + p + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum DispatchOutcome { Accepted { run_id: String }, @@ -41,3 +66,32 @@ pub trait AgentExecutor: Send + Sync { sink: &dyn EventSink, ) -> Result; } + +#[cfg(test)] +mod tests { + use super::*; + + fn spec() -> WorkSpec { + WorkSpec { + attempt_id: "a".into(), + decomposition_id: "d".into(), + task_id: "t".into(), + title: "login".into(), + description: "do it".into(), + acceptance_criteria: vec!["c1".into()], + executor: ExecutorKind::ClaudeCode, + context: Some("design".into()), + instructions: Some("be careful".into()), + target_runtime: None, + } + } + + #[test] + fn prompt_renders_instructions_task_criteria_and_skips_design_context() { + let p = spec().to_prompt(); + assert!(p.contains("# Behavior (skills)\nbe careful")); + assert!(p.contains("# Task: login")); + assert!(p.contains("- c1")); + assert!(!p.contains("Context: design")); + } +} diff --git a/crates/server/src/mcp_tools.rs b/crates/server/src/mcp_tools.rs index 251efa22..59bb679b 100644 --- a/crates/server/src/mcp_tools.rs +++ b/crates/server/src/mcp_tools.rs @@ -95,6 +95,7 @@ impl FromRequestParts for SessionHeader { } use delivery::application::DeliveryService; +use delivery::domain::ExecutorKind; use requirement::application::{CreateRequirementUseCase, RequirementCmdError, RequirementService}; use skill::application::{CreateSkillUseCase, SkillService}; use task::application::{BreakdownUseCase, CreateDecompositionUseCase, TaskService}; @@ -485,7 +486,7 @@ pub fn router( .requires("TASK", "ADD")) .tool(Tool::new( "shepherd_dispatch_delivery", - "把任务派发给 AI 执行者(executor: CLAUDE_CODE | CODEX | OPENCODE | CODEBUDDY | DEEPCODE);自动驱动任务、过验证门并回灌验证。\ + "把任务派发给 AI 执行者(executor 枚举见 schema);自动驱动任务、过验证门并回灌验证。\ 可直接传 skillIds(+projectId)自动组合行为规范注入,无需先调 compose。", obj( json!({ @@ -494,7 +495,10 @@ pub fn router( "title": { "type": "string" }, "description": { "type": "string" }, "acceptanceCriteria": { "type": "array", "items": { "type": "string" } }, - "executor": { "type": "string", "enum": ["CLAUDE_CODE", "CODEX", "OPENCODE", "CODEBUDDY", "DEEPCODE"] }, + "executor": { + "type": "string", + "enum": ExecutorKind::ALL.iter().map(|k| json!(k.as_str())).collect::>() + }, "projectId": { "type": "string", "description": "skillIds 非空时必填(用于组合)" }, "skillIds": { "type": "array", "items": { "type": "string" }, "description": "可选:自动组合这些 skill 为行为规范" }, "instructions": { "type": "string", "description": "可选:显式行为规范(与 skillIds 组合结果合并)" } diff --git a/crates/shepherd-cli/src/client.rs b/crates/shepherd-cli/src/client.rs index 26f5d446..8925ebff 100644 --- a/crates/shepherd-cli/src/client.rs +++ b/crates/shepherd-cli/src/client.rs @@ -338,6 +338,7 @@ pub fn scaffold_files() -> Vec<(&'static str, &'static str)> { ] } +// Keep in sync with delivery::ExecutorKind::ALL (crates/delivery/src/domain/delivery.rs). pub fn normalize_agent(t: &str) -> R { match t.to_ascii_lowercase().replace('-', "_").as_str() { "claude_code" => Ok("CLAUDE_CODE".into()), diff --git a/web/src/api.ts b/web/src/api.ts index 8e74e8fb..cca0b35d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1148,7 +1148,8 @@ export interface DeliveryEvent { detail?: unknown } -/** Dispatchable executor type → display name (shared by task-center filter and dispatch picker). */ +/** Dispatchable executor type → display name (shared by task-center filter and dispatch picker). + * Keep in sync with delivery::ExecutorKind::ALL (crates/delivery/src/domain/delivery.rs). */ export const EXECUTOR_LABEL: Record = { CLAUDE_CODE: 'Claude Code', CODEX: 'Codex', @@ -1167,7 +1168,7 @@ export interface TaskCenterItem { description: string /** Module (title of the owning requirement; empty string when none). */ module: string - /** Executor: CLAUDE_CODE / CODEX / OPENCODE / CODEBUDDY / DEEPCODE. */ + /** Executor kind; display label via EXECUTOR_LABEL (kept in sync with the server enum). */ executor: string /** Target runtime name for directed dispatch; empty = any runtime with the capability. */ targetRuntime?: string | null