diff --git a/Cargo.lock b/Cargo.lock index 289c66aa..7bbb1436 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4444,6 +4444,7 @@ dependencies = [ "goose", "http-body-util", "opengoose-board", + "opengoose-evolver", "opengoose-rig", "opengoose-skills", "proptest", @@ -4478,6 +4479,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "opengoose-evolver" +version = "0.2.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "futures", + "goose", + "opengoose-board", + "opengoose-rig", + "opengoose-skills", + "proptest", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "opengoose-rig" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index cdaa1a7f..f22ed4ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/opengoose-rig", "crates/opengoose-skills", "crates/opengoose-sandbox", + "crates/opengoose-evolver", ] exclude = [ "crates/opengoose-sandbox/guest/init", @@ -20,6 +21,7 @@ license = "MIT" opengoose-board = { path = "crates/opengoose-board" } opengoose-rig = { path = "crates/opengoose-rig" } opengoose-skills = { path = "crates/opengoose-skills" } +opengoose-evolver = { path = "crates/opengoose-evolver" } # Goose — agent runtime (the only LLM interface) goose = { git = "https://github.com/block/goose", tag = "v1.28.0", default-features = false } diff --git a/crates/opengoose-board/src/beads.rs b/crates/opengoose-board/src/beads.rs index 026da92c..c3ccc7d8 100644 --- a/crates/opengoose-board/src/beads.rs +++ b/crates/opengoose-board/src/beads.rs @@ -12,6 +12,23 @@ use chrono::{DateTime, Duration, Utc}; /// 1. open 상태만 /// 2. 블로킹 의존성 없는 것만 /// 3. 우선순위 정렬 (P0 > P1 > P2) +/// +/// # Examples +/// +/// ``` +/// use opengoose_board::beads::filter_ready; +/// use opengoose_board::work_item::*; +/// use std::collections::HashSet; +/// +/// let items = vec![WorkItem { +/// id: 1, title: "task".into(), description: String::new(), +/// created_by: RigId::new("human"), created_at: chrono::Utc::now(), +/// status: Status::Open, priority: Priority::P1, tags: vec![], +/// claimed_by: None, updated_at: chrono::Utc::now(), +/// }]; +/// let ready = filter_ready(items.into_iter(), &HashSet::new()); +/// assert_eq!(ready.len(), 1); +/// ``` pub fn filter_ready( items: impl Iterator, blocked_ids: &std::collections::HashSet, @@ -43,6 +60,16 @@ pub fn find_compactable( } /// prime() — 에이전트 컨텍스트 요약. Phase 1: 최소 구현. +/// +/// # Examples +/// +/// ``` +/// use opengoose_board::beads::prime_summary; +/// use opengoose_board::work_item::RigId; +/// +/// let summary = prime_summary(&[], &RigId::new("worker")); +/// assert!(summary.contains("0 open")); +/// ``` pub fn prime_summary(items: &[WorkItem], rig_id: &RigId) -> String { let (open, claimed, done) = items .iter() diff --git a/crates/opengoose-board/src/board.rs b/crates/opengoose-board/src/board.rs index 49efe5b0..e431e33f 100644 --- a/crates/opengoose-board/src/board.rs +++ b/crates/opengoose-board/src/board.rs @@ -58,6 +58,23 @@ impl Board { }) } + /// Create an in-memory Board for testing. + /// + /// # Examples + /// + /// ``` + /// # tokio::runtime::Runtime::new().unwrap().block_on(async { + /// let board = opengoose_board::Board::in_memory().await.unwrap(); + /// let item = board.post(opengoose_board::work_item::PostWorkItem { + /// title: "Test".into(), + /// description: String::new(), + /// created_by: opengoose_board::work_item::RigId::new("human"), + /// priority: opengoose_board::work_item::Priority::P1, + /// tags: vec![], + /// }).await.unwrap(); + /// assert_eq!(item.title, "Test"); + /// # }); + /// ``` pub async fn in_memory() -> Result { Self::connect("sqlite::memory:").await } diff --git a/crates/opengoose-board/src/rigs.rs b/crates/opengoose-board/src/rigs.rs index b3f2daf3..1e98ca0f 100644 --- a/crates/opengoose-board/src/rigs.rs +++ b/crates/opengoose-board/src/rigs.rs @@ -86,7 +86,7 @@ mod tests { assert_eq!(rig.rig_type, "ai"); let level = board - .trust_level("ai-01") + .trust_level(&RigId::new("ai-01")) .await .expect("async operation should succeed"); assert_eq!(level, "L1"); @@ -118,7 +118,7 @@ mod tests { .await .expect("add_stamp should succeed"); let level = board - .trust_level("ai-01") + .trust_level(&RigId::new("ai-01")) .await .expect("async operation should succeed"); assert_eq!(level, "L1.5"); @@ -162,7 +162,7 @@ mod tests { .await .expect("add_stamp should succeed"); let level = board - .trust_level("ai-01") + .trust_level(&RigId::new("ai-01")) .await .expect("async operation should succeed"); assert_eq!(level, "L2"); diff --git a/crates/opengoose-board/src/stamp_ops.rs b/crates/opengoose-board/src/stamp_ops.rs index 4f7250cc..c6b3927f 100644 --- a/crates/opengoose-board/src/stamp_ops.rs +++ b/crates/opengoose-board/src/stamp_ops.rs @@ -3,7 +3,7 @@ use crate::board::{AddStampParams, Board, db_err, stamp_weighted_value}; use crate::entity; use crate::stamps::{Severity, TrustLevel}; -use crate::work_item::BoardError; +use crate::work_item::{BoardError, RigId}; use chrono::Utc; use sea_orm::*; @@ -64,19 +64,19 @@ impl Board { /// 특정 rig의 모든 stamp 조회. pub async fn stamps_for_rig( &self, - rig_id: &str, + rig_id: &RigId, ) -> Result, BoardError> { entity::stamp::Entity::find() - .filter(entity::stamp::Column::TargetRig.eq(rig_id)) + .filter(entity::stamp::Column::TargetRig.eq(rig_id.as_ref())) .all(&self.db) .await .map_err(db_err) } /// 가중 점수 (시간 감쇠 적용). 30일 반감기. - pub async fn weighted_score(&self, rig_id: &str) -> Result { + pub async fn weighted_score(&self, rig_id: &RigId) -> Result { let stamps = entity::stamp::Entity::find() - .filter(entity::stamp::Column::TargetRig.eq(rig_id)) + .filter(entity::stamp::Column::TargetRig.eq(rig_id.as_ref())) .all(&self.db) .await .map_err(db_err)?; @@ -86,7 +86,7 @@ impl Board { } /// 신뢰 수준. stamps.rs의 TrustLevel::from_score() 재사용. - pub async fn trust_level(&self, rig_id: &str) -> Result<&'static str, BoardError> { + pub async fn trust_level(&self, rig_id: &RigId) -> Result<&'static str, BoardError> { let score = self.weighted_score(rig_id).await?; Ok(TrustLevel::from_score(score).as_str()) } @@ -94,7 +94,7 @@ impl Board { /// 특정 rig의 stamps + 차원별/전체 가중 점수를 한 번에 조회. pub async fn stamps_with_scores( &self, - rig_id: &str, + rig_id: &RigId, ) -> Result< ( Vec, @@ -104,7 +104,7 @@ impl Board { BoardError, > { let stamps = entity::stamp::Entity::find() - .filter(entity::stamp::Column::TargetRig.eq(rig_id)) + .filter(entity::stamp::Column::TargetRig.eq(rig_id.as_ref())) .all(&self.db) .await .map_err(db_err)?; @@ -195,6 +195,7 @@ impl Board { mod tests { use super::*; use crate::test_helpers::{new_board, post_req, stamp_params}; + use crate::work_item::RigId; // ── add_stamp ──────────────────────────────────────────── @@ -450,19 +451,19 @@ mod tests { .expect("board operation should succeed"); let stamps = board - .stamps_for_rig("rig-x") + .stamps_for_rig(&RigId::new("rig-x")) .await .expect("async operation should succeed"); assert_eq!(stamps.len(), 2); let stamps_y = board - .stamps_for_rig("rig-y") + .stamps_for_rig(&RigId::new("rig-y")) .await .expect("async operation should succeed"); assert_eq!(stamps_y.len(), 1); let empty = board - .stamps_for_rig("nobody") + .stamps_for_rig(&RigId::new("nobody")) .await .expect("async operation should succeed"); assert!(empty.is_empty()); @@ -474,7 +475,7 @@ mod tests { async fn weighted_score_zero_for_unknown_rig() { let board = new_board().await; let score = board - .weighted_score("nonexistent") + .weighted_score(&RigId::new("nonexistent")) .await .expect("weighted_score should succeed"); assert_eq!(score, 0.0); @@ -507,7 +508,7 @@ mod tests { .expect("add_stamp should succeed"); let score = board - .weighted_score("rig-a") + .weighted_score(&RigId::new("rig-a")) .await .expect("weighted_score should succeed"); // Fresh stamps: Leaf(1.0)*0.8 + Branch(2.0)*0.5 = 0.8 + 1.0 = 1.8 @@ -532,7 +533,7 @@ mod tests { .expect("add_stamp should succeed"); let score = board - .weighted_score("rig-a") + .weighted_score(&RigId::new("rig-a")) .await .expect("weighted_score should succeed"); assert!(score < 0.0, "expected negative score, got {score}"); @@ -544,7 +545,7 @@ mod tests { async fn trust_level_l1_for_unknown_rig() { let board = new_board().await; let level = board - .trust_level("ghost") + .trust_level(&RigId::new("ghost")) .await .expect("trust_level should succeed"); assert_eq!(level, "L1"); @@ -569,7 +570,7 @@ mod tests { } let level = board - .trust_level("rig-a") + .trust_level(&RigId::new("rig-a")) .await .expect("trust_level should succeed"); // 3 * Root(4.0) * 1.0 = ~12.0 => L2 @@ -582,7 +583,7 @@ mod tests { async fn stamps_with_scores_empty_for_unknown_rig() { let board = new_board().await; let (stamps, dims, total) = board - .stamps_with_scores("nobody") + .stamps_with_scores(&RigId::new("nobody")) .await .expect("stamps_with_scores should succeed"); assert!(stamps.is_empty()); @@ -642,7 +643,7 @@ mod tests { .expect("add_stamp should succeed"); let (stamps, dims, total) = board - .stamps_with_scores("rig-a") + .stamps_with_scores(&RigId::new("rig-a")) .await .expect("stamps_with_scores should succeed"); @@ -916,7 +917,7 @@ mod tests { .await .expect("add_stamp should succeed"); let leaf_score = board - .weighted_score("rig-leaf") + .weighted_score(&RigId::new("rig-leaf")) .await .expect("weighted_score should succeed"); @@ -933,7 +934,7 @@ mod tests { .await .expect("add_stamp should succeed"); let branch_score = board - .weighted_score("rig-branch") + .weighted_score(&RigId::new("rig-branch")) .await .expect("weighted_score should succeed"); @@ -945,7 +946,7 @@ mod tests { .await .expect("add_stamp should succeed"); let root_score = board - .weighted_score("rig-root") + .weighted_score(&RigId::new("rig-root")) .await .expect("weighted_score should succeed"); @@ -985,7 +986,7 @@ mod tests { .expect("add_stamp should succeed"); let score = board - .weighted_score("rig-a") + .weighted_score(&RigId::new("rig-a")) .await .expect("weighted_score should succeed"); assert!( @@ -1010,7 +1011,7 @@ mod tests { .expect("add_stamp should succeed"); let score = board - .weighted_score("rig-a") + .weighted_score(&RigId::new("rig-a")) .await .expect("weighted_score should succeed"); assert_eq!(score, 0.0, "zero score stamp should contribute nothing"); @@ -1025,7 +1026,7 @@ mod tests { // Helper: add N Root stamps (each ~4.0 weighted) to reach target score // L1: score < 3.0 (0 stamps) let level = board - .trust_level("rig-fresh") + .trust_level(&RigId::new("rig-fresh")) .await .expect("trust_level should succeed"); assert_eq!(level, "L1", "no stamps => L1"); @@ -1042,7 +1043,7 @@ mod tests { .await .expect("add_stamp should succeed"); let level = board - .trust_level("rig-l15") + .trust_level(&RigId::new("rig-l15")) .await .expect("trust_level should succeed"); assert_eq!(level, "L1.5", "score ~4.0 => L1.5"); @@ -1061,7 +1062,7 @@ mod tests { .expect("add_stamp should succeed"); } let level = board - .trust_level("rig-l2") + .trust_level(&RigId::new("rig-l2")) .await .expect("trust_level should succeed"); assert_eq!(level, "L2", "score ~12.0 => L2"); @@ -1080,7 +1081,7 @@ mod tests { .expect("add_stamp should succeed"); } let level = board - .trust_level("rig-l25") + .trust_level(&RigId::new("rig-l25")) .await .expect("trust_level should succeed"); assert_eq!(level, "L2.5", "score ~28.0 => L2.5"); @@ -1099,7 +1100,7 @@ mod tests { .expect("add_stamp should succeed"); } let level = board - .trust_level("rig-l3") + .trust_level(&RigId::new("rig-l3")) .await .expect("trust_level should succeed"); assert_eq!(level, "L3", "score ~52.0 => L3"); @@ -1121,7 +1122,7 @@ mod tests { .expect("add_stamp should succeed"); let level = board - .trust_level("rig-bad") + .trust_level(&RigId::new("rig-bad")) .await .expect("trust_level should succeed"); assert_eq!(level, "L1", "negative score stays at L1"); @@ -1151,7 +1152,7 @@ mod tests { assert_eq!(stamps.len(), 3, "3 stamps on one item"); let (_, dims, total) = board - .stamps_with_scores("rig-a") + .stamps_with_scores(&RigId::new("rig-a")) .await .expect("stamps_with_scores should succeed"); assert!(dims.quality > 0.0); @@ -1194,7 +1195,7 @@ mod tests { assert_eq!(stamps.len(), 2, "both stamps recorded"); let (_, dims, _) = board - .stamps_with_scores("rig-a") + .stamps_with_scores(&RigId::new("rig-a")) .await .expect("stamps_with_scores should succeed"); // quality should reflect both stamps: ~0.5 + ~0.3 = ~0.8 @@ -1229,7 +1230,7 @@ mod tests { .expect("add_stamp should succeed"); let (stamps, dims, total) = board - .stamps_with_scores("rig-a") + .stamps_with_scores(&RigId::new("rig-a")) .await .expect("stamps_with_scores should succeed"); assert_eq!(stamps.len(), 2); @@ -1272,7 +1273,7 @@ mod tests { .expect("add_stamp should succeed"); let (_, dims, total) = board - .stamps_with_scores("rig-a") + .stamps_with_scores(&RigId::new("rig-a")) .await .expect("stamps_with_scores should succeed"); // Quality: Branch(2.0) * -0.8 = -1.6 @@ -1354,7 +1355,7 @@ mod tests { async fn stamps_for_rig_empty_string_rig_id() { let board = new_board().await; let stamps = board - .stamps_for_rig("") + .stamps_for_rig(&RigId::new("")) .await .expect("stamps_for_rig should succeed for empty rig id"); assert!(stamps.is_empty()); @@ -1364,7 +1365,7 @@ mod tests { async fn weighted_score_empty_string_rig() { let board = new_board().await; let score = board - .weighted_score("") + .weighted_score(&RigId::new("")) .await .expect("weighted_score should succeed for empty rig id"); assert_eq!(score, 0.0); @@ -1374,7 +1375,7 @@ mod tests { async fn stamps_with_scores_empty_string_rig() { let board = new_board().await; let (stamps, dims, total) = board - .stamps_with_scores("") + .stamps_with_scores(&RigId::new("")) .await .expect("stamps_with_scores should succeed for empty rig id"); assert!(stamps.is_empty()); diff --git a/crates/opengoose-board/src/work_item.rs b/crates/opengoose-board/src/work_item.rs index da2351f6..071f75b6 100644 --- a/crates/opengoose-board/src/work_item.rs +++ b/crates/opengoose-board/src/work_item.rs @@ -17,10 +17,28 @@ use std::path::PathBuf; pub struct RigId(pub String); impl RigId { + /// Create a RigId without validation. + /// + /// # Examples + /// + /// ``` + /// let id = opengoose_board::work_item::RigId::new("worker-1"); + /// assert_eq!(id.to_string(), "worker-1"); + /// ``` pub fn new(id: impl Into) -> Self { Self(id.into()) } + /// Create a RigId with validation. + /// + /// # Examples + /// + /// ``` + /// use opengoose_board::work_item::RigId; + /// assert!(RigId::try_new("valid-id").is_ok()); + /// assert!(RigId::try_new("").is_err()); + /// assert!(RigId::try_new("has/slash").is_err()); + /// ``` pub fn try_new(id: impl Into) -> Result { let s = id.into(); if s.is_empty() { @@ -39,6 +57,12 @@ impl RigId { } } +impl AsRef for RigId { + fn as_ref(&self) -> &str { + &self.0 + } +} + impl std::fmt::Display for RigId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) @@ -75,6 +99,15 @@ pub enum Status { impl Status { /// 머지 시 사용: 더 진행된 상태가 이긴다. + /// + /// Merge precedence: higher value wins. + /// + /// # Examples + /// + /// ``` + /// use opengoose_board::work_item::Status; + /// assert!(Status::Done.precedence() > Status::Open.precedence()); + /// ``` pub fn precedence(self) -> u8 { match self { Status::Open => 0, diff --git a/crates/opengoose-board/src/work_items/mod.rs b/crates/opengoose-board/src/work_items/mod.rs index cd5a1ff7..304fa891 100644 --- a/crates/opengoose-board/src/work_items/mod.rs +++ b/crates/opengoose-board/src/work_items/mod.rs @@ -2,7 +2,7 @@ // // Split into three submodules: // - transitions: state-changing operations (post, claim, submit, unclaim, mark_stuck, retry, abandon) -// - queries: read-only operations (get, list, ready, claimed_by, completed_by_rig) +// - queries: read-only operations (get, list, ready, claimed_by, completed_by) // - helpers: internal utilities (transition, sync_item, get_or_err, find_model, blocked_item_ids, compact) mod helpers; diff --git a/crates/opengoose-board/src/work_items/queries.rs b/crates/opengoose-board/src/work_items/queries.rs index 5da63597..29f3d201 100644 --- a/crates/opengoose-board/src/work_items/queries.rs +++ b/crates/opengoose-board/src/work_items/queries.rs @@ -43,7 +43,7 @@ impl Board { pub async fn claimed_by(&self, rig_id: &RigId) -> Result, BoardError> { let mut items: Vec = entity::work_item::Entity::find() .filter(entity::work_item::Column::Status.eq(Status::Claimed.to_value())) - .filter(entity::work_item::Column::ClaimedBy.eq(&rig_id.0)) + .filter(entity::work_item::Column::ClaimedBy.eq(rig_id.as_ref())) .all(&self.db) .await .map_err(db_err)? @@ -56,10 +56,10 @@ impl Board { } /// 특정 rig이 완료한 작업 항목 조회 (SQL 필터). - pub async fn completed_by_rig(&self, rig_id: &str) -> Result, BoardError> { + pub async fn completed_by(&self, rig_id: &RigId) -> Result, BoardError> { entity::work_item::Entity::find() .filter(entity::work_item::Column::Status.eq(Status::Done.to_value())) - .filter(entity::work_item::Column::ClaimedBy.eq(rig_id)) + .filter(entity::work_item::Column::ClaimedBy.eq(rig_id.as_ref())) .all(&self.db) .await .map(|models| models.into_iter().map(WorkItem::from).collect()) @@ -275,9 +275,9 @@ mod tests { .expect("submit should succeed"); let completed = board - .completed_by_rig("worker") + .completed_by(&rig) .await - .expect("completed_by_rig should succeed"); + .expect("completed_by should succeed"); assert_eq!(completed.len(), 1); assert_eq!(completed[0].id, item1.id); } diff --git a/crates/opengoose-evolver/Cargo.toml b/crates/opengoose-evolver/Cargo.toml new file mode 100644 index 00000000..d6c00bc1 --- /dev/null +++ b/crates/opengoose-evolver/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "opengoose-evolver" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +opengoose-board = { workspace = true } +opengoose-rig = { workspace = true } +opengoose-skills = { workspace = true } +goose = { workspace = true } +async-trait = { workspace = true } +anyhow = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +futures = "0.3" + +[dev-dependencies] +chrono = { workspace = true } +serde_json = { workspace = true } +tempfile = "3" +proptest = "1" diff --git a/crates/opengoose/src/evolver/mod.rs b/crates/opengoose-evolver/src/lib.rs similarity index 71% rename from crates/opengoose/src/evolver/mod.rs rename to crates/opengoose-evolver/src/lib.rs index 8d95679f..300ad5b5 100644 --- a/crates/opengoose/src/evolver/mod.rs +++ b/crates/opengoose-evolver/src/lib.rs @@ -13,7 +13,7 @@ use opengoose_rig::work_mode::evolve_session_id; pub use loop_driver::run; -pub(super) const EVOLVER_SYSTEM_PROMPT: &str = "You are a skill analyst for OpenGoose.\n\ +pub(crate) const EVOLVER_SYSTEM_PROMPT: &str = "You are a skill analyst for OpenGoose.\n\ Analyze failed tasks and extract concrete, actionable lessons as SKILL.md files.\n\n\ Rules:\n\ - description MUST start with 'Use when...' (triggering conditions only)\n\ @@ -70,3 +70,26 @@ impl AgentCaller for RealAgentCaller<'_> { Ok(response_text) } } + +pub(crate) use opengoose_rig::home_dir; + +// --------------------------------------------------------------------------- +// read_conversation_log — moved from binary crate's skills::evolve +// --------------------------------------------------------------------------- + +pub(crate) fn read_conversation_log(work_item_id: i64) -> String { + let session_id = format!("task-{work_item_id}"); + opengoose_rig::conversation_log::read_log(&session_id) + .map(|content| opengoose_skills::evolution::prompts::summarize_for_prompt(&content, 4000)) + .unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// test_env_lock — local test lock for environment variable isolation +// --------------------------------------------------------------------------- + +#[cfg(test)] +pub(crate) fn test_env_lock() -> &'static std::sync::Mutex<()> { + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + &ENV_LOCK +} diff --git a/crates/opengoose/src/evolver/loop_driver.rs b/crates/opengoose-evolver/src/loop_driver.rs similarity index 92% rename from crates/opengoose/src/evolver/loop_driver.rs rename to crates/opengoose-evolver/src/loop_driver.rs index e96edf45..129cc6b7 100644 --- a/crates/opengoose/src/evolver/loop_driver.rs +++ b/crates/opengoose-evolver/src/loop_driver.rs @@ -1,9 +1,9 @@ // Evolver main loop — lazy Agent init, stamp_notify listener, fallback sweep. -use super::{EVOLVER_SYSTEM_PROMPT, FALLBACK_SWEEP_SECS, LOW_STAMP_THRESHOLD, RealAgentCaller}; -use crate::runtime::{AgentConfig, create_agent}; +use crate::{EVOLVER_SYSTEM_PROMPT, FALLBACK_SWEEP_SECS, LOW_STAMP_THRESHOLD, RealAgentCaller}; use goose::agents::Agent; use opengoose_board::Board; +use opengoose_rig::agent_factory::{AgentConfig, create_agent}; use std::sync::Arc; use tokio::sync::Notify; use tracing::{info, warn}; @@ -46,7 +46,7 @@ pub async fn run(board: Arc, stamp_notify: Arc) { LAST_SWEEP_EPOCH.store(now_epoch, Ordering::Relaxed); info!("evolver: running idle-time sweep"); let caller = RealAgentCaller { agent }; - if let Err(e) = super::sweep::run_sweep(&board, &caller).await { + if let Err(e) = crate::sweep::run_sweep(&board, &caller).await { warn!("evolver: sweep failed: {e}"); } } @@ -89,7 +89,7 @@ pub async fn run(board: Arc, stamp_notify: Arc) { .as_ref() .expect("agent initialized above or loop continued"), }; - if let Err(e) = super::pipeline::process_stamp(&board, &caller, stamp).await { + if let Err(e) = crate::pipeline::process_stamp(&board, &caller, stamp).await { warn!("evolver: failed to process stamp {}: {e}", stamp.id); } } diff --git a/crates/opengoose/src/evolver/pipeline.rs b/crates/opengoose-evolver/src/pipeline.rs similarity index 92% rename from crates/opengoose/src/evolver/pipeline.rs rename to crates/opengoose-evolver/src/pipeline.rs index 20897cfb..43cbf4fd 100644 --- a/crates/opengoose/src/evolver/pipeline.rs +++ b/crates/opengoose-evolver/src/pipeline.rs @@ -1,9 +1,19 @@ // Stamp processing pipeline — prepare_context, execute_action, process_stamp. -use super::AgentCaller; -use crate::skills::{evolve, load}; +use crate::AgentCaller; use opengoose_board::Board; use opengoose_board::work_item::{PostWorkItem, Priority, RigId}; +use opengoose_skills::evolution::parser::{EvolveAction, parse_evolve_response}; +use opengoose_skills::evolution::prompts::{ + UpdatePromptParams, build_evolve_prompt, build_update_prompt, +}; +use opengoose_skills::evolution::validator::validate_skill_output; +use opengoose_skills::evolution::writer::{ + WriteSkillParams, update_effectiveness_versioned, update_existing_skill, + write_skill_to_rig_scope, +}; +use opengoose_skills::loader::{LoadedSkill, SkillScope, load_skills}; +use opengoose_skills::metadata::read_metadata; use std::path::Path; use tracing::{info, warn}; @@ -22,13 +32,10 @@ struct StampContext { // Step 0: update effectiveness scores for existing skills // --------------------------------------------------------------------------- -fn update_effectiveness( - stamp: &opengoose_board::entity::stamp::Model, - existing: &[load::LoadedSkill], -) { +fn update_effectiveness(stamp: &opengoose_board::entity::stamp::Model, existing: &[LoadedSkill]) { for skill in existing { if should_update_effectiveness(skill, &stamp.dimension) - && let Err(e) = evolve::update_effectiveness_versioned( + && let Err(e) = update_effectiveness_versioned( &skill.path, stamp.score, stamp.active_skill_versions.as_deref(), @@ -47,7 +54,7 @@ fn update_effectiveness( // --------------------------------------------------------------------------- /// Build (name, description) pairs from loaded skills for dedup checking. -fn build_existing_skill_pairs(existing: &[load::LoadedSkill]) -> Vec<(String, String)> { +fn build_existing_skill_pairs(existing: &[LoadedSkill]) -> Vec<(String, String)> { existing .iter() .map(|s| (s.name.clone(), s.description.clone())) @@ -67,10 +74,10 @@ pub(crate) fn build_evolve_prompt_pure( work_item_title: &str, work_item_id: i64, log_summary: &str, - skills: &[load::LoadedSkill], + skills: &[LoadedSkill], ) -> PreparedPrompt { let existing_skill_pairs = build_existing_skill_pairs(skills); - let prompt = evolve::build_evolve_prompt( + let prompt = build_evolve_prompt( dimension, score, comment, @@ -94,18 +101,18 @@ pub(crate) enum ParsedAction { /// Returns `Err` only if the response is completely unparseable (currently /// `parse_evolve_response` always succeeds, so this is future-proofing). pub(crate) fn validate_and_parse_response(raw: &str) -> anyhow::Result { - match evolve::parse_evolve_response(raw) { - evolve::EvolveAction::Skip => Ok(ParsedAction::Skip), - evolve::EvolveAction::Update(name) => Ok(ParsedAction::Update(name)), - evolve::EvolveAction::Create(content) => Ok(ParsedAction::Create(content)), + match parse_evolve_response(raw) { + EvolveAction::Skip => Ok(ParsedAction::Skip), + EvolveAction::Update(name) => Ok(ParsedAction::Update(name)), + EvolveAction::Create(content) => Ok(ParsedAction::Create(content)), } } /// Check whether a learned skill's dimension matches the stamp's dimension, /// indicating its effectiveness score should be updated. -fn should_update_effectiveness(skill: &load::LoadedSkill, stamp_dimension: &str) -> bool { - skill.scope == load::SkillScope::Learned - && load::read_metadata(&skill.path) +fn should_update_effectiveness(skill: &LoadedSkill, stamp_dimension: &str) -> bool { + skill.scope == SkillScope::Learned + && read_metadata(&skill.path) .is_some_and(|meta| meta.generated_from.dimension == stamp_dimension) } @@ -116,7 +123,7 @@ fn should_update_effectiveness(skill: &load::LoadedSkill, stamp_dimension: &str) async fn prepare_context( board: &Board, stamp: &opengoose_board::entity::stamp::Model, - existing: &[load::LoadedSkill], + existing: &[LoadedSkill], ) -> anyhow::Result { let evolver_rig = RigId::new("evolver"); @@ -147,7 +154,7 @@ async fn prepare_context( board.claim(evolver_item.id, &evolver_rig).await?; // 4. Read conversation log - let log_summary = evolve::read_conversation_log(stamp.work_item_id); + let log_summary = crate::read_conversation_log(stamp.work_item_id); // 5-6. Build prompt (pure computation) let prepared = build_evolve_prompt_pure( @@ -183,7 +190,7 @@ enum CreateOutcome { /// Validate skill content from a Create action. /// Returns Valid if the content passes validation, Invalid with error message otherwise. fn validate_create_content(content: &str) -> CreateOutcome { - match evolve::validate_skill_output(content) { + match validate_skill_output(content) { Ok(()) => CreateOutcome::Valid, Err(e) => CreateOutcome::Invalid(e.to_string()), } @@ -199,7 +206,7 @@ async fn execute_action( caller: &dyn AgentCaller, stamp: &opengoose_board::entity::stamp::Model, ctx: &StampContext, - existing: &[load::LoadedSkill], + existing: &[LoadedSkill], ) -> anyhow::Result<()> { let evolver_rig = RigId::new("evolver"); let target_rig = &stamp.target_rig; @@ -218,7 +225,7 @@ async fn execute_action( let skill = existing.iter().find(|s| s.name == name); match skill { Some(skill) => { - let update_prompt = evolve::build_update_prompt(&evolve::UpdatePromptParams { + let update_prompt = build_update_prompt(&UpdatePromptParams { skill_name: &name, existing_content: &skill.content, dimension: &stamp.dimension, @@ -232,8 +239,8 @@ async fn execute_action( let update_action = validate_and_parse_response(&update_response)?; match update_action { ParsedAction::Create(new_content) => { - evolve::validate_skill_output(&new_content)?; - evolve::update_existing_skill( + validate_skill_output(&new_content)?; + update_existing_skill( &skill.path, &new_content, stamp.id, @@ -258,11 +265,11 @@ async fn execute_action( // Use pure validation to determine outcome match validate_create_content(&content) { CreateOutcome::Valid => { - let skill_name = evolve::write_skill_to_rig_scope( + let skill_name = write_skill_to_rig_scope( base_dir, target_rig, &content, - evolve::WriteSkillParams { + WriteSkillParams { stamp_id: stamp.id, work_item_id: stamp.work_item_id, dimension: &stamp.dimension, @@ -287,12 +294,12 @@ async fn execute_action( let retry_action = validate_and_parse_response(&retry_response)?; match retry_action { ParsedAction::Create(retry_content) => { - evolve::validate_skill_output(&retry_content)?; - let skill_name = evolve::write_skill_to_rig_scope( + validate_skill_output(&retry_content)?; + let skill_name = write_skill_to_rig_scope( base_dir, target_rig, &retry_content, - evolve::WriteSkillParams { + WriteSkillParams { stamp_id: stamp.id, work_item_id: stamp.work_item_id, dimension: &stamp.dimension, @@ -326,13 +333,13 @@ async fn execute_action( // process_stamp — orchestrates the 3 focused functions // --------------------------------------------------------------------------- -pub(super) async fn process_stamp( +pub(crate) async fn process_stamp( board: &Board, caller: &dyn AgentCaller, stamp: &opengoose_board::entity::stamp::Model, ) -> anyhow::Result<()> { let base_dir = crate::home_dir(); - let existing = load::load_skills_for(Some(&stamp.target_rig), None); + let existing = load_skills(&base_dir, Some(&stamp.target_rig), None); update_effectiveness(stamp, &existing); let ctx = prepare_context(board, stamp, &existing).await?; @@ -361,7 +368,7 @@ pub(super) async fn process_stamp( mod tests { #![allow(clippy::await_holding_lock)] use super::*; - use crate::skills::test_env_lock; + use crate::test_env_lock; use async_trait::async_trait; use chrono::Utc; use opengoose_board::Board; @@ -396,7 +403,7 @@ mod tests { } #[async_trait] - impl super::super::AgentCaller for MockAgentCaller { + impl crate::AgentCaller for MockAgentCaller { async fn call(&self, prompt: &str, _work_id: i64) -> anyhow::Result { let raw = if prompt.contains("Previous output had format errors") { self.reply @@ -445,7 +452,7 @@ mod tests { .expect("should add stamp"); let mut stamps = board - .unprocessed_low_stamps(super::super::LOW_STAMP_THRESHOLD) + .unprocessed_low_stamps(crate::LOW_STAMP_THRESHOLD) .await .expect("should query unprocessed low stamps"); stamps @@ -633,7 +640,7 @@ description: Use when a task has a weak quality signal and repeats. let stamp = seeded_stamp(&board, "error-rig").await; // process_stamp swallows execute_action errors: calls abandon (which fails - // because Claimed→Abandoned is not a valid transition) and returns Ok(()). + // because Claimed->Abandoned is not a valid transition) and returns Ok(()). process_stamp(&board, &caller, &stamp) .await .expect("process_stamp should succeed"); @@ -648,18 +655,18 @@ description: Use when a task has a weak quality signal and repeats. .await .expect("should get work item") .expect("work item should exist"); - // abandon fails silently (Claimed→Abandoned invalid), so item stays Claimed + // abandon fails silently (Claimed->Abandoned invalid), so item stays Claimed assert_eq!(fetched.status, Status::Claimed); } - /// process_stamp with UPDATE where skill IS found but update response is not Create → warn only. + /// process_stamp with UPDATE where skill IS found but update response is not Create -> warn only. #[tokio::test] async fn process_stamp_update_skill_found_update_response_not_create() { let guard = test_env_lock().lock().unwrap_or_else(|e| e.into_inner()); let home = tempdir().expect("should create temp dir"); let prev_home = set_env_var("HOME", home.path().to_str()); - // Both calls return "UPDATE:existing-skill" → second call also returns Update → _ arm (warn only) + // Both calls return "UPDATE:existing-skill" -> second call also returns Update -> _ arm (warn only) let caller = MockAgentCaller { reply: "UPDATE:existing-skill".into(), }; @@ -678,7 +685,7 @@ description: Use when a task has a weak quality signal and repeats. "---\nname: existing-skill\ndescription: Use when original\n---\n# Original\n", ) .expect("should write SKILL.md"); - let meta = crate::skills::evolve::SkillMetadata { + let meta = opengoose_skills::metadata::SkillMetadata { generated_from: opengoose_skills::metadata::GeneratedFrom { stamp_id: 1, work_item_id: 1, @@ -714,8 +721,8 @@ description: Use when a task has a weak quality signal and repeats. drop(guard); } - /// process_stamp: first output invalid → retry prompt has "Previous output had format errors" - /// → caller returns second split (valid skill) → skill written. + /// process_stamp: first output invalid -> retry prompt has "Previous output had format errors" + /// -> caller returns second split (valid skill) -> skill written. #[tokio::test] async fn process_stamp_retry_succeeds_when_second_output_valid() { let guard = test_env_lock().lock().unwrap_or_else(|e| e.into_inner()); @@ -767,14 +774,14 @@ description: Use when a task has a weak quality signal and repeats. reply: "first-part||second-part".into(), }; - // Normal prompt → first split + // Normal prompt -> first split let normal = caller .call("normal prompt", 0) .await .expect("should succeed for normal prompt"); assert_eq!(normal, "first-part"); - // Retry prompt → second split + // Retry prompt -> second split let retry = caller .call( "some context\n\nPrevious output had format errors: missing name", @@ -876,12 +883,12 @@ description: Use when a task has a weak quality signal and repeats. timestamp: Utc::now(), }; - let skills = vec![load::LoadedSkill { + let skills = vec![LoadedSkill { name: "test-skill".into(), description: "test".into(), path: skill_dir.clone(), content: sample_skill().into(), - scope: load::SkillScope::Installed, + scope: SkillScope::Installed, }]; update_effectiveness(&stamp, &skills); @@ -928,12 +935,12 @@ description: Use when a task has a weak quality signal and repeats. timestamp: Utc::now(), }; - let skills = vec![load::LoadedSkill { + let skills = vec![LoadedSkill { name: "test-skill".into(), description: "test".into(), path: skill_dir.clone(), content: sample_skill().into(), - scope: load::SkillScope::Learned, + scope: SkillScope::Learned, }]; update_effectiveness(&stamp, &skills); @@ -983,12 +990,12 @@ description: Use when a task has a weak quality signal and repeats. timestamp: Utc::now(), }; - let skills = vec![load::LoadedSkill { + let skills = vec![LoadedSkill { name: "test-skill".into(), description: "test".into(), path: skill_dir.clone(), content: sample_skill().into(), - scope: load::SkillScope::Learned, + scope: SkillScope::Learned, }]; update_effectiveness(&stamp, &skills); @@ -1029,12 +1036,12 @@ description: Use when a task has a weak quality signal and repeats. timestamp: Utc::now(), }; - let skills = vec![load::LoadedSkill { + let skills = vec![LoadedSkill { name: "test-skill".into(), description: "test".into(), path: skill_dir, content: sample_skill().into(), - scope: load::SkillScope::Learned, + scope: SkillScope::Learned, }]; update_effectiveness(&stamp, &skills); @@ -1048,7 +1055,7 @@ description: Use when a task has a weak quality signal and repeats. let home = tempdir().expect("should create temp dir"); let prev_home = set_env_var("HOME", home.path().to_str()); - // First call: "invalid" → validate fails → retry. + // First call: "invalid" -> validate fails -> retry. // Second call (retry, prompt contains "Previous output had format errors"): valid SKILL.md. let valid_skill = "\ ---\nname: retry-skill\ndescription: Use when retry test needed.\n---\n\n# Retry\n"; @@ -1097,19 +1104,19 @@ description: Use when a task has a weak quality signal and repeats. #[test] fn build_existing_skill_pairs_maps_name_description() { let skills = vec![ - load::LoadedSkill { + LoadedSkill { name: "alpha".into(), description: "Alpha skill".into(), path: std::path::PathBuf::from("/tmp/alpha"), content: "# Alpha".into(), - scope: load::SkillScope::Learned, + scope: SkillScope::Learned, }, - load::LoadedSkill { + LoadedSkill { name: "beta".into(), description: "Beta skill".into(), path: std::path::PathBuf::from("/tmp/beta"), content: "# Beta".into(), - scope: load::SkillScope::Installed, + scope: SkillScope::Installed, }, ]; let pairs = build_existing_skill_pairs(&skills); @@ -1145,12 +1152,12 @@ description: Use when a task has a weak quality signal and repeats. std::fs::write(skill_dir.join("metadata.json"), meta.to_string()) .expect("should write metadata.json"); - let skill = load::LoadedSkill { + let skill = LoadedSkill { name: "test-skill".into(), description: "test".into(), path: skill_dir, content: sample_skill().into(), - scope: load::SkillScope::Learned, + scope: SkillScope::Learned, }; assert!(should_update_effectiveness(&skill, "Quality")); } @@ -1162,24 +1169,24 @@ description: Use when a task has a weak quality signal and repeats. std::fs::create_dir_all(&skill_dir).expect("should create skill dir"); // No metadata.json - let skill = load::LoadedSkill { + let skill = LoadedSkill { name: "no-meta".into(), description: "test".into(), path: skill_dir, content: "# test".into(), - scope: load::SkillScope::Learned, + scope: SkillScope::Learned, }; assert!(!should_update_effectiveness(&skill, "Quality")); } #[test] fn should_update_effectiveness_installed_scope_returns_false() { - let skill = load::LoadedSkill { + let skill = LoadedSkill { name: "installed".into(), description: "test".into(), path: std::path::PathBuf::from("/tmp/installed"), content: "# test".into(), - scope: load::SkillScope::Installed, + scope: SkillScope::Installed, }; assert!(!should_update_effectiveness(&skill, "Quality")); } @@ -1209,19 +1216,19 @@ description: Use when a task has a weak quality signal and repeats. #[test] fn build_evolve_prompt_pure_with_skills_present() { let skills = vec![ - load::LoadedSkill { + LoadedSkill { name: "alpha-skill".into(), description: "Alpha description".into(), path: std::path::PathBuf::from("/tmp/alpha"), content: "# Alpha".into(), - scope: load::SkillScope::Learned, + scope: SkillScope::Learned, }, - load::LoadedSkill { + LoadedSkill { name: "beta-skill".into(), description: "Beta description".into(), path: std::path::PathBuf::from("/tmp/beta"), content: "# Beta".into(), - scope: load::SkillScope::Installed, + scope: SkillScope::Installed, }, ]; let result = build_evolve_prompt_pure( @@ -1310,12 +1317,12 @@ description: Use when a task has a weak quality signal and repeats. std::fs::write(skill_dir.join("metadata.json"), meta.to_string()) .expect("should write metadata.json"); - let skill = load::LoadedSkill { + let skill = LoadedSkill { name: "diff-dim".into(), description: "test".into(), path: skill_dir, content: "# test".into(), - scope: load::SkillScope::Learned, + scope: SkillScope::Learned, }; assert!(!should_update_effectiveness(&skill, "Quality")); } diff --git a/crates/opengoose/src/evolver/sweep.rs b/crates/opengoose-evolver/src/sweep.rs similarity index 88% rename from crates/opengoose/src/evolver/sweep.rs rename to crates/opengoose-evolver/src/sweep.rs index 18dfbd62..8c46348a 100644 --- a/crates/opengoose/src/evolver/sweep.rs +++ b/crates/opengoose-evolver/src/sweep.rs @@ -1,8 +1,15 @@ // Sweep logic — offline re-evaluation of dormant/archived skills. -use super::{AgentCaller, LOW_STAMP_THRESHOLD}; -use crate::skills::{evolve, load}; +use crate::{AgentCaller, LOW_STAMP_THRESHOLD}; use opengoose_board::Board; +use opengoose_skills::evolution::parser::SweepDecision; +use opengoose_skills::evolution::prompts::{build_sweep_prompt, summarize_for_prompt}; +use opengoose_skills::evolution::validator::validate_skill_output; +use opengoose_skills::evolution::writer::refine_skill; +use opengoose_skills::loader::{ + LoadedSkill, extract_body, load_dormant_and_archived, update_inclusion_tracking, +}; +use opengoose_skills::metadata::{SkillMetadata, is_effective, read_metadata}; use tracing::info; // --------------------------------------------------------------------------- @@ -10,10 +17,7 @@ use tracing::info; // --------------------------------------------------------------------------- /// Find a dormant skill by name. -fn find_dormant_skill<'a>( - dormant: &'a [load::LoadedSkill], - name: &str, -) -> Option<&'a load::LoadedSkill> { +fn find_dormant_skill<'a>(dormant: &'a [LoadedSkill], name: &str) -> Option<&'a LoadedSkill> { dormant.iter().find(|s| s.name == name) } @@ -23,33 +27,30 @@ fn find_dormant_skill<'a>( /// Apply a single sweep decision to a dormant skill. /// Returns Ok(true) if the decision was applied, Ok(false) if skipped (e.g., skill not found). -fn apply_decision( - decision: &evolve::SweepDecision, - dormant: &[load::LoadedSkill], -) -> anyhow::Result { +fn apply_decision(decision: &SweepDecision, dormant: &[LoadedSkill]) -> anyhow::Result { match decision { - evolve::SweepDecision::Restore(name) => { + SweepDecision::Restore(name) => { if let Some(skill) = find_dormant_skill(dormant, name) { - load::update_inclusion_tracking(&skill.path); + update_inclusion_tracking(&skill.path); info!("sweep: restored '{name}' to active"); Ok(true) } else { Ok(false) } } - evolve::SweepDecision::Refine(name, content) => { + SweepDecision::Refine(name, content) => { if let Some(skill) = find_dormant_skill(dormant, name) - && evolve::validate_skill_output(content).is_ok() + && validate_skill_output(content).is_ok() { - evolve::refine_skill(&skill.path, content)?; - load::update_inclusion_tracking(&skill.path); + refine_skill(&skill.path, content)?; + update_inclusion_tracking(&skill.path); info!("sweep: refined and restored '{name}'"); Ok(true) } else { Ok(false) } } - evolve::SweepDecision::Delete(name) => { + SweepDecision::Delete(name) => { if let Some(skill) = find_dormant_skill(dormant, name) { std::fs::remove_dir_all(&skill.path)?; info!("sweep: deleted obsolete skill '{name}'"); @@ -58,7 +59,7 @@ fn apply_decision( Ok(false) } } - evolve::SweepDecision::Keep(name) => { + SweepDecision::Keep(name) => { info!("sweep: keeping '{name}' dormant"); Ok(true) } @@ -66,14 +67,14 @@ fn apply_decision( } /// Build effectiveness summary string for a skill's metadata. -fn build_effectiveness_summary(meta: &opengoose_skills::metadata::SkillMetadata) -> String { +fn build_effectiveness_summary(meta: &SkillMetadata) -> String { let scores = &meta.effectiveness.subsequent_scores; let avg = if scores.is_empty() { 0.0 } else { scores.iter().sum::() / scores.len() as f32 }; - let verdict = match load::is_effective(meta) { + let verdict = match is_effective(meta) { Some(true) => "effective", Some(false) => "ineffective", None => "insufficient data", @@ -85,13 +86,13 @@ fn build_effectiveness_summary(meta: &opengoose_skills::metadata::SkillMetadata) } /// Idle-time sweep: re-evaluate dormant/archived skills against recent failures. -pub(super) async fn run_sweep(board: &Board, caller: &dyn AgentCaller) -> anyhow::Result<()> { +pub(crate) async fn run_sweep(board: &Board, caller: &dyn AgentCaller) -> anyhow::Result<()> { let home = crate::home_dir(); let global_dir = home.join(".opengoose/skills"); let rigs_base = home.join(".opengoose/rigs"); // 1. Load dormant/archived skills - let dormant = load::load_dormant_and_archived(&global_dir, None, &rigs_base); + let dormant = load_dormant_and_archived(&global_dir, None, &rigs_base); if dormant.is_empty() { return Ok(()); } @@ -118,21 +119,21 @@ pub(super) async fn run_sweep(board: &Board, caller: &dyn AgentCaller) -> anyhow let skill_summaries: Vec<(String, String, String, Option)> = dormant .iter() .map(|s| { - let body = load::extract_body(&s.content) - .map(|b| evolve::summarize_for_prompt(b, 300)) + let body = extract_body(&s.content) + .map(|b| summarize_for_prompt(b, 300)) .unwrap_or_default(); let effectiveness = - load::read_metadata(&s.path).map(|meta| build_effectiveness_summary(&meta)); + read_metadata(&s.path).map(|meta| build_effectiveness_summary(&meta)); (s.name.clone(), s.description.clone(), body, effectiveness) }) .collect(); // 3. Build and send sweep prompt - let prompt = evolve::build_sweep_prompt(&skill_summaries, &failure_summaries); + let prompt = build_sweep_prompt(&skill_summaries, &failure_summaries); let response = caller.call(&prompt, 0).await?; // 4. Parse and apply decisions - let decisions = evolve::parse_sweep_response(&response); + let decisions = opengoose_skills::evolution::parser::parse_sweep_response(&response); for decision in &decisions { if let Err(e) = apply_decision(decision, &dormant) { tracing::warn!("sweep apply_decision failed for {decision:?}: {e}"); @@ -146,7 +147,7 @@ pub(super) async fn run_sweep(board: &Board, caller: &dyn AgentCaller) -> anyhow mod tests { #![allow(clippy::await_holding_lock)] use super::*; - use crate::skills::test_env_lock; + use crate::test_env_lock; use async_trait::async_trait; use chrono::{Duration, Utc}; use opengoose_board::Board; @@ -180,7 +181,7 @@ mod tests { } #[async_trait] - impl super::super::AgentCaller for MockAgentCaller { + impl crate::AgentCaller for MockAgentCaller { async fn call(&self, _prompt: &str, _work_id: i64) -> anyhow::Result { Ok(self.reply.clone()) } @@ -206,7 +207,7 @@ mod tests { ) .expect("write SKILL.md"); - let meta = crate::skills::evolve::SkillMetadata { + let meta = SkillMetadata { generated_from: opengoose_skills::metadata::GeneratedFrom { stamp_id: 1, work_item_id: 2, @@ -279,7 +280,7 @@ mod tests { format!("---\nname: {name}\ndescription: Use when testing\n---\n# {name}\n"), ) .expect("write custom SKILL.md"); - let meta = crate::skills::evolve::SkillMetadata { + let meta = SkillMetadata { generated_from: opengoose_skills::metadata::GeneratedFrom { stamp_id: 1, work_item_id: 1, @@ -416,7 +417,7 @@ mod tests { .expect("run_sweep should succeed"); assert!(path.exists()); - let metadata: crate::skills::evolve::SkillMetadata = serde_json::from_str( + let metadata: SkillMetadata = serde_json::from_str( &std::fs::read_to_string(path.join("metadata.json")).expect("read metadata.json"), ) .expect("deserialize metadata"); @@ -463,7 +464,7 @@ mod tests { .expect("board operation"); let path = dormant_skill(home.path(), "r-restore", "dormant-restore", 60); - let metadata_before: crate::skills::evolve::SkillMetadata = serde_json::from_str( + let metadata_before: SkillMetadata = serde_json::from_str( &std::fs::read_to_string(path.join("metadata.json")).expect("read metadata.json"), ) .expect("deserialize metadata"); @@ -473,7 +474,7 @@ mod tests { .await .expect("run_sweep should succeed"); - let metadata_after: crate::skills::evolve::SkillMetadata = serde_json::from_str( + let metadata_after: SkillMetadata = serde_json::from_str( &std::fs::read_to_string(path.join("metadata.json")).expect("read metadata.json"), ) .expect("deserialize metadata"); @@ -520,7 +521,7 @@ mod tests { .expect("board operation"); let path = dormant_skill(home.path(), "r-refine", "dormant-refine", 60); - let metadata_before: crate::skills::evolve::SkillMetadata = serde_json::from_str( + let metadata_before: SkillMetadata = serde_json::from_str( &std::fs::read_to_string(path.join("metadata.json")).expect("read metadata.json"), ) .expect("deserialize metadata"); @@ -532,7 +533,7 @@ mod tests { let content = std::fs::read_to_string(path.join("SKILL.md")).expect("read SKILL.md"); assert!(content.contains("# refind")); - let metadata_after: crate::skills::evolve::SkillMetadata = serde_json::from_str( + let metadata_after: SkillMetadata = serde_json::from_str( &std::fs::read_to_string(path.join("metadata.json")).expect("read metadata.json"), ) .expect("deserialize metadata"); @@ -554,7 +555,7 @@ mod tests { let path = dormant_skill(home.path(), "r-nostamp", "dormant-nostamp", 60); assert!(path.is_dir()); - // Board has no stamps → recent_low_stamps returns empty → early return. + // Board has no stamps -> recent_low_stamps returns empty -> early return. let caller = MockAgentCaller { reply: String::new(), }; @@ -611,7 +612,7 @@ mod tests { let rigs_dir = home.path().join(".opengoose/rigs/eff-rig/skills/learned"); - // Skill A: empty subsequent_scores → scores.is_empty() = true + // Skill A: empty subsequent_scores -> scores.is_empty() = true let skill_a = rigs_dir.join("skill-empty"); std::fs::create_dir_all(&skill_a).expect("create skill_a dir"); std::fs::write( @@ -619,7 +620,7 @@ mod tests { "---\nname: skill-empty\ndescription: Use when testing empty scores\n---\n# Empty\n", ) .expect("write skill_a SKILL.md"); - let meta_a = crate::skills::evolve::SkillMetadata { + let meta_a = SkillMetadata { generated_from: opengoose_skills::metadata::GeneratedFrom { stamp_id: 1, work_item_id: 1, @@ -641,7 +642,7 @@ mod tests { ) .expect("write skill_a metadata"); - // Skill B: 2 scores → is_effective returns None → "insufficient data" + // Skill B: 2 scores -> is_effective returns None -> "insufficient data" let skill_b = rigs_dir.join("skill-insufficient"); std::fs::create_dir_all(&skill_b).expect("create skill_b dir"); std::fs::write( @@ -649,7 +650,7 @@ mod tests { "---\nname: skill-insufficient\ndescription: Use when testing insufficient data\n---\n# Insufficient\n", ) .expect("write skill_b SKILL.md"); - let meta_b = crate::skills::evolve::SkillMetadata { + let meta_b = SkillMetadata { generated_from: opengoose_skills::metadata::GeneratedFrom { stamp_id: 2, work_item_id: 2, @@ -671,7 +672,7 @@ mod tests { ) .expect("write skill_b metadata"); - // Skill C: 3 high scores + low initial → is_effective = Some(true) → "effective" + // Skill C: 3 high scores + low initial -> is_effective = Some(true) -> "effective" let skill_c = rigs_dir.join("skill-effective"); std::fs::create_dir_all(&skill_c).expect("create skill_c dir"); std::fs::write( @@ -679,7 +680,7 @@ mod tests { "---\nname: skill-effective\ndescription: Use when testing effective verdict\n---\n# Effective\n", ) .expect("write skill_c SKILL.md"); - let meta_c = crate::skills::evolve::SkillMetadata { + let meta_c = SkillMetadata { generated_from: opengoose_skills::metadata::GeneratedFrom { stamp_id: 3, work_item_id: 3, @@ -709,7 +710,7 @@ mod tests { drop(guard); } - /// Covers evolver — RESTORE for a skill not in the dormant list → skips. + /// Covers evolver — RESTORE for a skill not in the dormant list -> skips. #[tokio::test] async fn run_sweep_restore_nonexistent_skill_skips_gracefully() { let guard = test_env_lock().lock().unwrap_or_else(|e| e.into_inner()); @@ -731,7 +732,7 @@ mod tests { drop(guard); } - /// Covers evolver — REFINE for a skill not in the dormant list → skips. + /// Covers evolver — REFINE for a skill not in the dormant list -> skips. #[tokio::test] async fn run_sweep_refine_nonexistent_skill_skips() { let guard = test_env_lock().lock().unwrap_or_else(|e| e.into_inner()); @@ -752,14 +753,14 @@ mod tests { drop(guard); } - /// Covers evolver — REFINE with invalid content (validation fails) → skips. + /// Covers evolver — REFINE with invalid content (validation fails) -> skips. #[tokio::test] async fn run_sweep_refine_invalid_content_skips() { let guard = test_env_lock().lock().unwrap_or_else(|e| e.into_inner()); let home = tempdir().expect("create temp dir"); let prev_home = set_env_var("HOME", home.path().to_str()); - // Content without frontmatter → validate_skill_output fails + // Content without frontmatter -> validate_skill_output fails let caller = MockAgentCaller { reply: "REFINE:refine-target\njust some content without frontmatter".into(), }; @@ -779,7 +780,7 @@ mod tests { drop(guard); } - /// Covers evolver — DELETE for a skill not in the dormant list → skips. + /// Covers evolver — DELETE for a skill not in the dormant list -> skips. #[tokio::test] async fn run_sweep_delete_nonexistent_skill_skips() { let guard = test_env_lock().lock().unwrap_or_else(|e| e.into_inner()); @@ -804,12 +805,12 @@ mod tests { #[test] fn find_dormant_skill_by_name() { - let skills = vec![load::LoadedSkill { + let skills = vec![LoadedSkill { name: "target-skill".into(), description: "test skill".into(), path: std::path::PathBuf::from("/tmp/skill"), content: String::new(), - scope: load::SkillScope::Learned, + scope: opengoose_skills::loader::SkillScope::Learned, }]; let found = find_dormant_skill(&skills, "target-skill"); assert!(found.is_some(), "should find skill by exact name"); @@ -821,12 +822,12 @@ mod tests { #[test] fn find_dormant_skill_missing() { - let skills = vec![load::LoadedSkill { + let skills = vec![LoadedSkill { name: "other-skill".into(), description: "test skill".into(), path: std::path::PathBuf::from("/tmp/skill"), content: String::new(), - scope: load::SkillScope::Learned, + scope: opengoose_skills::loader::SkillScope::Learned, }]; let found = find_dormant_skill(&skills, "nonexistent"); assert!(found.is_none(), "should return None for missing skill"); @@ -836,28 +837,28 @@ mod tests { #[test] fn apply_decision_keep_returns_true() { - let decision = evolve::SweepDecision::Keep("some-skill".into()); + let decision = SweepDecision::Keep("some-skill".into()); let result = apply_decision(&decision, &[]); assert!(result.expect("Keep decision should not error")); } #[test] fn apply_decision_restore_nonexistent_returns_false() { - let decision = evolve::SweepDecision::Restore("missing".into()); + let decision = SweepDecision::Restore("missing".into()); let result = apply_decision(&decision, &[]); assert!(!result.expect("Restore with empty dormant should not error")); } #[test] fn apply_decision_delete_nonexistent_returns_false() { - let decision = evolve::SweepDecision::Delete("missing".into()); + let decision = SweepDecision::Delete("missing".into()); let result = apply_decision(&decision, &[]); assert!(!result.expect("Delete with empty dormant should not error")); } #[test] fn apply_decision_refine_nonexistent_returns_false() { - let decision = evolve::SweepDecision::Refine("missing".into(), "content".into()); + let decision = SweepDecision::Refine("missing".into(), "content".into()); let result = apply_decision(&decision, &[]); assert!(!result.expect("Refine with empty dormant should not error")); } @@ -866,7 +867,7 @@ mod tests { #[test] fn build_effectiveness_summary_empty_metadata() { - let meta = opengoose_skills::metadata::SkillMetadata { + let meta = SkillMetadata { generated_from: opengoose_skills::metadata::GeneratedFrom { stamp_id: 1, work_item_id: 1, @@ -893,13 +894,13 @@ mod tests { ); assert!( summary.contains("insufficient data"), - "empty scores → insufficient data" + "empty scores -> insufficient data" ); } #[test] fn build_effectiveness_summary_with_scores() { - let meta = opengoose_skills::metadata::SkillMetadata { + let meta = SkillMetadata { generated_from: opengoose_skills::metadata::GeneratedFrom { stamp_id: 1, work_item_id: 1, @@ -928,7 +929,7 @@ mod tests { #[test] fn build_effectiveness_summary_formats_correctly() { - let meta = opengoose_skills::metadata::SkillMetadata { + let meta = SkillMetadata { generated_from: opengoose_skills::metadata::GeneratedFrom { stamp_id: 1, work_item_id: 1, @@ -968,23 +969,20 @@ mod tests { ) } - fn arb_skill_metadata() -> impl Strategy - { + fn arb_skill_metadata() -> impl Strategy { ("\\PC*", 0.0f64..=1.0, arb_effectiveness(), 1u32..100).prop_map( - |(dimension, score, effectiveness, skill_version)| { - opengoose_skills::metadata::SkillMetadata { - generated_from: opengoose_skills::metadata::GeneratedFrom { - stamp_id: 1, - work_item_id: 1, - dimension, - score: score as f32, - }, - generated_at: String::new(), - evolver_work_item_id: None, - last_included_at: None, - effectiveness, - skill_version, - } + |(dimension, score, effectiveness, skill_version)| SkillMetadata { + generated_from: opengoose_skills::metadata::GeneratedFrom { + stamp_id: 1, + work_item_id: 1, + dimension, + score: score as f32, + }, + generated_at: String::new(), + evolver_work_item_id: None, + last_included_at: None, + effectiveness, + skill_version, }, ) } diff --git a/crates/opengoose-rig/src/agent_factory.rs b/crates/opengoose-rig/src/agent_factory.rs new file mode 100644 index 00000000..dc99de17 --- /dev/null +++ b/crates/opengoose-rig/src/agent_factory.rs @@ -0,0 +1,72 @@ +// Agent creation — standalone factory for Goose Agent instances. + +use anyhow::{Context, Result}; +use goose::agents::Agent; +use goose::config::goose_mode::GooseMode; +use goose::model::ModelConfig; +use goose::session::session_manager::SessionType; +use tracing::info; + +pub struct AgentConfig { + pub session_id: String, + pub system_prompt: Option, +} + +/// Create a Goose Agent with the given config. +/// Reads GOOSE_PROVIDER and GOOSE_MODEL from the environment. +pub async fn create_agent(config: AgentConfig) -> Result { + let provider_name = std::env::var("GOOSE_PROVIDER").unwrap_or_else(|_| "anthropic".to_string()); + + let agent = Agent::new(); + + let cwd = std::env::current_dir().unwrap_or_else(|_| ".".into()); + let session = agent + .config + .session_manager + .create_session( + cwd, + config.session_id.clone(), + SessionType::User, + GooseMode::Auto, + ) + .await + .context("failed to create session")?; + + let provider = match std::env::var("GOOSE_MODEL") { + Ok(model_name) => { + info!( + provider = %provider_name, + model = %model_name, + session = %config.session_id, + "creating agent" + ); + let model_config = ModelConfig::new(&model_name) + .context("invalid model config")? + .with_canonical_limits(&provider_name); + goose::providers::create(&provider_name, model_config, vec![]).await + } + Err(_) => { + info!( + provider = %provider_name, + model = "default", + session = %config.session_id, + "creating agent" + ); + goose::providers::create_with_default_model(&provider_name, vec![]).await + } + } + .context("failed to create provider")?; + + agent + .update_provider(provider, &session.id) + .await + .context("failed to set provider")?; + + if let Some(prompt) = config.system_prompt { + agent + .extend_system_prompt(config.session_id.clone(), prompt) + .await; + } + + Ok(agent) +} diff --git a/crates/opengoose-rig/src/conversation_log/io.rs b/crates/opengoose-rig/src/conversation_log/io.rs index b6dba408..73cc9e58 100644 --- a/crates/opengoose-rig/src/conversation_log/io.rs +++ b/crates/opengoose-rig/src/conversation_log/io.rs @@ -10,7 +10,7 @@ pub(crate) fn opengoose_home_dir() -> PathBuf { if let Some(home) = std::env::var_os("OPENGOOSE_HOME") { PathBuf::from(home) } else { - dirs::home_dir().unwrap_or_else(|| ".".into()) + crate::home_dir() } } diff --git a/crates/opengoose-rig/src/lib.rs b/crates/opengoose-rig/src/lib.rs index 71712ed4..3011a976 100644 --- a/crates/opengoose-rig/src/lib.rs +++ b/crates/opengoose-rig/src/lib.rs @@ -3,6 +3,9 @@ // Goose Agent::reply()를 감싸는 최소 래퍼. // 메시지 라우팅, 플랫폼 관리, 데이터 저장은 하지 않는다. +use std::path::PathBuf; + +pub mod agent_factory; pub mod error; pub use error::*; @@ -14,5 +17,15 @@ pub mod rig; pub mod work_mode; pub mod worktree; +/// Return the user's home directory, preferring $HOME (for test isolation) +/// and falling back to `dirs::home_dir()`. +pub fn home_dir() -> PathBuf { + if let Ok(h) = std::env::var("HOME") { + PathBuf::from(h) + } else { + dirs::home_dir().unwrap_or_else(|| ".".into()) + } +} + #[cfg(test)] pub(crate) mod test_fixtures; diff --git a/crates/opengoose-rig/src/work_mode.rs b/crates/opengoose-rig/src/work_mode.rs index 10500d02..1782bd18 100644 --- a/crates/opengoose-rig/src/work_mode.rs +++ b/crates/opengoose-rig/src/work_mode.rs @@ -51,6 +51,16 @@ pub fn evolve_session_id(id: i64) -> String { /// /// ChatMode: 영속 세션 → prompt cache 보장. /// TaskMode: 작업당 세션 → 대화 캐시 오염 방지. +/// +/// # Examples +/// +/// ```no_run +/// use opengoose_rig::work_mode::{TaskMode, WorkMode, WorkInput}; +/// +/// let input = WorkInput::task("implement feature X", 42); +/// let session_id = TaskMode.session_for(&input); +/// assert_eq!(session_id, "task-42"); +/// ``` pub trait WorkMode: Send + Sync { /// 이 입력에 사용할 Goose 세션 ID. fn session_for(&self, input: &WorkInput) -> String; diff --git a/crates/opengoose-rig/tests/worker_integration.rs b/crates/opengoose-rig/tests/worker_integration.rs new file mode 100644 index 00000000..e3cb2965 --- /dev/null +++ b/crates/opengoose-rig/tests/worker_integration.rs @@ -0,0 +1,142 @@ +//! Board → Worker integration tests. +//! Tests claim/submit/retry logic at the Board API level. +//! No LLM calls — pure state transitions. + +use opengoose_board::Board; +use opengoose_board::work_item::{PostWorkItem, Priority, RigId, Status}; +use std::sync::Arc; + +fn post_req(title: &str) -> PostWorkItem { + PostWorkItem { + title: title.to_string(), + description: format!("Description for {title}"), + created_by: RigId::new("human"), + priority: Priority::P1, + tags: vec![], + } +} + +#[tokio::test] +async fn post_claim_submit_lifecycle() { + let board = Board::in_memory().await.expect("board init should succeed"); + let worker_id = RigId::new("worker-1"); + board + .register_rig("worker-1", "ai", None, None) + .await + .expect("register_rig should succeed"); + + let item = board + .post(post_req("test task")) + .await + .expect("post should succeed"); + assert_eq!(item.status, Status::Open); + + let claimed = board + .claim(item.id, &worker_id) + .await + .expect("claim should succeed"); + assert_eq!(claimed.status, Status::Claimed); + assert_eq!(claimed.claimed_by.as_ref(), Some(&worker_id)); + + board + .submit(item.id, &worker_id) + .await + .expect("submit should succeed"); + let done = board + .get(item.id) + .await + .expect("get should succeed") + .expect("item should exist"); + assert_eq!(done.status, Status::Done); +} + +#[tokio::test] +async fn worker_skips_blocked_items() { + let board = Board::in_memory().await.expect("board init should succeed"); + + let blocker = board + .post(post_req("blocker")) + .await + .expect("post should succeed"); + let blocked = board + .post(post_req("blocked")) + .await + .expect("post should succeed"); + board + .add_dependency(blocker.id, blocked.id) + .await + .expect("add_dependency should succeed"); + + let ready = board.ready().await.expect("ready should succeed"); + let ready_ids: Vec = ready.iter().map(|i| i.id).collect(); + + assert!(ready_ids.contains(&blocker.id), "blocker should be ready"); + assert!( + !ready_ids.contains(&blocked.id), + "blocked item should NOT be ready" + ); +} + +#[tokio::test] +async fn claim_then_mark_stuck() { + let board = Board::in_memory().await.expect("board init should succeed"); + let worker_id = RigId::new("worker-1"); + board + .register_rig("worker-1", "ai", None, None) + .await + .expect("register_rig should succeed"); + + let item = board + .post(post_req("failing task")) + .await + .expect("post should succeed"); + board + .claim(item.id, &worker_id) + .await + .expect("claim should succeed"); + + board + .mark_stuck(item.id, &worker_id) + .await + .expect("mark_stuck should succeed"); + + let stuck = board + .get(item.id) + .await + .expect("get should succeed") + .expect("item should exist"); + assert_eq!(stuck.status, Status::Stuck); +} + +#[tokio::test] +async fn concurrent_workers_no_double_claim() { + let board = Arc::new(Board::in_memory().await.expect("board init should succeed")); + let item = board + .post(post_req("contested task")) + .await + .expect("post should succeed"); + + for i in 0..2 { + board + .register_rig(&format!("w-{i}"), "ai", None, None) + .await + .expect("register_rig should succeed"); + } + + let board1 = Arc::clone(&board); + let board2 = Arc::clone(&board); + + let h1 = tokio::spawn(async move { board1.claim(item.id, &RigId::new("w-0")).await }); + let h2 = tokio::spawn(async move { board2.claim(item.id, &RigId::new("w-1")).await }); + + let (r1, r2) = tokio::join!(h1, h2); + let r1 = r1.expect("task should not panic"); + let r2 = r2.expect("task should not panic"); + + let successes = [r1.is_ok(), r2.is_ok()]; + assert_eq!( + successes.iter().filter(|&&s| s).count(), + 1, + "exactly one worker should claim successfully" + ); +} diff --git a/crates/opengoose-skills/src/metadata.rs b/crates/opengoose-skills/src/metadata.rs index 104dded7..b6421e7f 100644 --- a/crates/opengoose-skills/src/metadata.rs +++ b/crates/opengoose-skills/src/metadata.rs @@ -26,6 +26,24 @@ fn default_version() -> u32 { 1 } +/// Skill metadata stored alongside SKILL.md files. +/// +/// # Examples +/// +/// ``` +/// use opengoose_skills::metadata::SkillMetadata; +/// +/// let json = r#"{ +/// "generated_from": {"stamp_id": 1, "work_item_id": 1, "dimension": "Quality", "score": 0.2}, +/// "generated_at": "2026-03-20T00:00:00Z", +/// "evolver_work_item_id": null, +/// "last_included_at": null, +/// "effectiveness": {"injected_count": 0, "subsequent_scores": []}, +/// "skill_version": 1 +/// }"#; +/// let meta: SkillMetadata = serde_json::from_str(json).unwrap(); +/// assert_eq!(meta.skill_version, 1); +/// ``` #[derive(Debug, Serialize, Deserialize)] pub struct SkillMetadata { pub generated_from: GeneratedFrom, diff --git a/crates/opengoose/Cargo.toml b/crates/opengoose/Cargo.toml index 3dd973ec..aa66a8c7 100644 --- a/crates/opengoose/Cargo.toml +++ b/crates/opengoose/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" opengoose-board = { workspace = true } opengoose-rig = { workspace = true } opengoose-skills = { workspace = true } +opengoose-evolver = { workspace = true } goose = { workspace = true } tokio = { workspace = true } async-trait = { workspace = true } diff --git a/crates/opengoose/src/cli/commands.rs b/crates/opengoose/src/cli/commands.rs index a5cf561f..2f871659 100644 --- a/crates/opengoose/src/cli/commands.rs +++ b/crates/opengoose/src/cli/commands.rs @@ -26,7 +26,9 @@ pub async fn dispatch(cli: Cli, log_rx: Option>) -> Result<() Some(Commands::Run { task }) => { let rt = crate::runtime::init_runtime(cli.port).await?; let result = crate::headless::run_headless(&rt.board, &task).await; - rt.worker.cancel(); + if let Some(ref worker) = rt.worker { + worker.cancel(); + } result } None => { @@ -39,7 +41,9 @@ pub async fn dispatch(cli: Cli, log_rx: Option>) -> Result<() &session_id, )); let result = crate::tui::run_tui(rt.board, operator, log_rx).await; - rt.worker.cancel(); + if let Some(ref worker) = rt.worker { + worker.cancel(); + } result } } diff --git a/crates/opengoose/src/cli/setup.rs b/crates/opengoose/src/cli/setup.rs index 0c29bf8c..8830cfbc 100644 --- a/crates/opengoose/src/cli/setup.rs +++ b/crates/opengoose/src/cli/setup.rs @@ -1,16 +1,6 @@ // Initialization logic — home directory, database URL, environment lock for tests -use std::path::PathBuf; - -/// Return the user's home directory, preferring $HOME (for test isolation) -/// and falling back to `dirs::home_dir()`. -pub(crate) fn home_dir() -> PathBuf { - if let Ok(h) = std::env::var("HOME") { - PathBuf::from(h) - } else { - dirs::home_dir().unwrap_or_else(|| ".".into()) - } -} +pub(crate) use opengoose_rig::home_dir; pub(crate) fn db_url() -> String { let home = home_dir(); @@ -30,6 +20,7 @@ pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); mod tests { use super::*; use crate::skills::test_env_lock; + use std::path::PathBuf; #[test] fn db_url_points_to_board_db() { diff --git a/crates/opengoose/src/commands/board.rs b/crates/opengoose/src/commands/board.rs index 7a9a5f37..321e5517 100644 --- a/crates/opengoose/src/commands/board.rs +++ b/crates/opengoose/src/commands/board.rs @@ -68,11 +68,8 @@ pub async fn run_board_command(board: &Board, action: BoardAction) -> Result<()> .get(id) .await? .ok_or_else(|| anyhow::anyhow!("item not found"))?; - let target = item - .claimed_by - .as_ref() - .map(|r| r.0.as_str()) - .unwrap_or(&item.created_by.0); + let target_rig = item.claimed_by.as_ref().unwrap_or(&item.created_by); + let target = target_rig.as_ref(); let comment_ref = comment.as_deref(); for (dim, score) in [ @@ -94,8 +91,8 @@ pub async fn run_board_command(board: &Board, action: BoardAction) -> Result<()> .await?; } - let trust = board.trust_level(target).await?; - let pts = board.weighted_score(target).await?; + let trust = board.trust_level(target_rig).await?; + let pts = board.weighted_score(target_rig).await?; println!( "Stamped #{id} (target: {target}): q:{quality} r:{reliability} h:{helpfulness} {severity}" ); diff --git a/crates/opengoose/src/commands/rigs.rs b/crates/opengoose/src/commands/rigs.rs index db668d4a..469e2707 100644 --- a/crates/opengoose/src/commands/rigs.rs +++ b/crates/opengoose/src/commands/rigs.rs @@ -36,8 +36,9 @@ pub async fn run_rigs_command(board: &Board, action: Option) -> Resu println!("Removed {id}"); } Some(RigsAction::Trust { id }) => { - let pts = board.weighted_score(&id).await?; - let level = board.trust_level(&id).await?; + let rig_id = opengoose_board::RigId::new(&id); + let pts = board.weighted_score(&rig_id).await?; + let level = board.trust_level(&rig_id).await?; println!("{id}: {level} ({pts:.1}pts)"); } } diff --git a/crates/opengoose/src/main.rs b/crates/opengoose/src/main.rs index d5e322b9..a1bd312c 100644 --- a/crates/opengoose/src/main.rs +++ b/crates/opengoose/src/main.rs @@ -5,7 +5,6 @@ mod cli; mod commands; -mod evolver; mod headless; mod logs; mod runtime; diff --git a/crates/opengoose/src/runtime.rs b/crates/opengoose/src/runtime.rs index 1351977f..2b6b5838 100644 --- a/crates/opengoose/src/runtime.rs +++ b/crates/opengoose/src/runtime.rs @@ -1,21 +1,18 @@ // Runtime init + agent creation — Board, web server, Evolver, Worker wiring -use anyhow::{Context, Result}; +use anyhow::Result; use goose::agents::Agent; -use goose::model::ModelConfig; -use goose::session::session_manager::SessionType; use opengoose_board::Board; use opengoose_board::work_item::RigId; use opengoose_rig::pipeline::{ContextHydrator, ValidationGate}; use std::sync::Arc; -use tracing::info; -use crate::{evolver, web}; +use crate::web; /// Encapsulates the Board + Worker handles created during runtime init. pub struct Runtime { pub board: Arc, - pub worker: Arc, + pub worker: Option>, } /// Stand up the full runtime: Board, web dashboard, Evolver, and Worker. @@ -25,92 +22,38 @@ pub async fn init_runtime(port: u16) -> Result { // Evolver let stamp_notify = board.stamp_notify_handle(); - tokio::spawn(evolver::run(Arc::clone(&board), stamp_notify)); + tokio::spawn(opengoose_evolver::run(Arc::clone(&board), stamp_notify)); // Worker - let (worker_agent, _) = create_worker_agent().await?; - let worker = Arc::new(opengoose_rig::rig::Worker::new( - RigId::new("worker"), - Arc::clone(&board), - worker_agent, - opengoose_rig::work_mode::TaskMode, - vec![ - Arc::new(ContextHydrator { - skill_catalog: String::new(), - }), - Arc::new(ValidationGate), - ], - )); - let worker_handle = Arc::clone(&worker); - tokio::spawn(async move { worker_handle.run().await }); - - Ok(Runtime { board, worker }) -} - -pub struct AgentConfig { - pub session_id: String, - pub system_prompt: Option, -} - -/// Create a Goose Agent with the given config. -/// Reads GOOSE_PROVIDER and GOOSE_MODEL from the environment. -pub async fn create_agent(config: AgentConfig) -> Result { - let provider_name = std::env::var("GOOSE_PROVIDER").unwrap_or_else(|_| "anthropic".to_string()); - - let agent = Agent::new(); - - let cwd = std::env::current_dir().unwrap_or_else(|_| ".".into()); - let session = agent - .config - .session_manager - .create_session( - cwd, - config.session_id.clone(), - SessionType::User, - goose::config::goose_mode::GooseMode::Auto, - ) - .await - .context("failed to create session")?; - - let provider = match std::env::var("GOOSE_MODEL") { - Ok(model_name) => { - info!( - provider = %provider_name, - model = %model_name, - session = %config.session_id, - "creating agent" - ); - let model_config = ModelConfig::new(&model_name) - .context("invalid model config")? - .with_canonical_limits(&provider_name); - goose::providers::create(&provider_name, model_config, vec![]).await + let worker = match create_worker_agent().await { + Ok((worker_agent, _)) => { + let worker = Arc::new(opengoose_rig::rig::Worker::new( + RigId::new("worker"), + Arc::clone(&board), + worker_agent, + opengoose_rig::work_mode::TaskMode, + vec![ + Arc::new(ContextHydrator { + skill_catalog: String::new(), + }), + Arc::new(ValidationGate), + ], + )); + let worker_handle = Arc::clone(&worker); + tokio::spawn(async move { worker_handle.run().await }); + Some(worker) } - Err(_) => { - info!( - provider = %provider_name, - model = "default", - session = %config.session_id, - "creating agent" - ); - goose::providers::create_with_default_model(&provider_name, vec![]).await + Err(e) => { + tracing::warn!(error = %e, "worker agent creation failed, running without worker"); + None } - } - .context("failed to create provider")?; - - agent - .update_provider(provider, &session.id) - .await - .context("failed to set provider")?; + }; - if let Some(prompt) = config.system_prompt { - agent - .extend_system_prompt(config.session_id.clone(), prompt) - .await; - } - - Ok(agent) + Ok(Runtime { board, worker }) } +pub use opengoose_rig::agent_factory::{AgentConfig, create_agent}; + /// Create an Operator agent (interactive conversation). pub async fn create_operator_agent() -> Result<(Agent, String)> { let session_name = "opengoose".to_string(); diff --git a/crates/opengoose/src/skills/evolve.rs b/crates/opengoose/src/skills/evolve.rs index c179438e..2b92c6f5 100644 --- a/crates/opengoose/src/skills/evolve.rs +++ b/crates/opengoose/src/skills/evolve.rs @@ -1,31 +1,25 @@ // Skill Evolution — thin re-export layer. // All logic lives in opengoose-skills::evolution. -// read_conversation_log() stays here because it depends on opengoose_rig. +// read_conversation_log() moved to opengoose-evolver crate. +pub use opengoose_skills::metadata::SkillMetadata; + +#[cfg(test)] pub use opengoose_skills::evolution::parser::{ EvolveAction, SweepDecision, parse_evolve_response, parse_sweep_response, }; +#[cfg(test)] pub use opengoose_skills::evolution::prompts::{ UpdatePromptParams, build_evolve_prompt, build_sweep_prompt, build_update_prompt, summarize_for_prompt, }; +#[cfg(test)] pub use opengoose_skills::evolution::validator::validate_skill_output; +#[cfg(test)] pub use opengoose_skills::evolution::writer::{ WriteSkillParams, refine_skill, update_effectiveness_versioned, update_existing_skill, write_skill_to_rig_scope, }; -pub use opengoose_skills::metadata::SkillMetadata; - -// --------------------------------------------------------------------------- -// read_conversation_log — depends on opengoose_rig (stays in binary crate) -// --------------------------------------------------------------------------- - -pub fn read_conversation_log(work_item_id: i64) -> String { - let session_id = format!("task-{work_item_id}"); - opengoose_rig::conversation_log::read_log(&session_id) - .map(|content| summarize_for_prompt(&content, 4000)) - .unwrap_or_default() -} // --------------------------------------------------------------------------- // Tests @@ -601,13 +595,6 @@ mod tests { assert_eq!(meta.skill_version, 1); } - #[test] - fn read_conversation_log_returns_empty_when_missing() { - // work_item_id with no corresponding log file → returns empty string - let result = read_conversation_log(99999); - assert!(result.is_empty()); - } - #[test] fn build_active_versions_json_skips_installed_skills() { use crate::skills::load::{LoadedSkill, SkillScope}; diff --git a/crates/opengoose/src/skills/load.rs b/crates/opengoose/src/skills/load.rs index 47271414..c04501a8 100644 --- a/crates/opengoose/src/skills/load.rs +++ b/crates/opengoose/src/skills/load.rs @@ -3,11 +3,13 @@ // All logic lives in opengoose-skills::{loader, lifecycle, catalog, metadata}. pub use opengoose_skills::lifecycle::{Lifecycle, determine_lifecycle}; +pub use opengoose_skills::loader::{LoadedSkill, SkillScope, load_skills}; +pub use opengoose_skills::metadata::{is_effective, read_metadata}; + +#[cfg(test)] pub use opengoose_skills::loader::{ - LoadedSkill, SkillScope, extract_body, load_dormant_and_archived, load_skills, - update_inclusion_tracking, + extract_body, load_dormant_and_archived, update_inclusion_tracking, }; -pub use opengoose_skills::metadata::{is_effective, read_metadata}; /// Backward-compat wrapper: load skills using home_dir as base_dir. /// Callers in the binary crate pass (rig_id, project_dir) without a base_dir. diff --git a/crates/opengoose/src/tui/event/rigs.rs b/crates/opengoose/src/tui/event/rigs.rs index 5582d414..c1ca21bf 100644 --- a/crates/opengoose/src/tui/event/rigs.rs +++ b/crates/opengoose/src/tui/event/rigs.rs @@ -60,7 +60,8 @@ pub async fn load_rigs(board: &Board, app: &mut App) { if let Ok(rigs) = board.list_rigs().await { let mut infos = Vec::new(); for rig in &rigs { - let trust = board.trust_level(&rig.id).await.unwrap_or("L1"); + let rig_id = opengoose_board::RigId::new(&rig.id); + let trust = board.trust_level(&rig_id).await.unwrap_or("L1"); let is_working = app.board.items.iter().any(|i| { i.status == Status::Claimed && i.claimed_by.as_ref().is_some_and(|r| r.0 == rig.id) }); diff --git a/crates/opengoose/src/tui/log_entry.rs b/crates/opengoose/src/tui/log_entry.rs index 8f4ddd86..9221c384 100644 --- a/crates/opengoose/src/tui/log_entry.rs +++ b/crates/opengoose/src/tui/log_entry.rs @@ -21,10 +21,7 @@ impl LogEntry { /// Creates `~/.opengoose/logs/opengoose-{timestamp}.log` and returns the file handle. pub fn create_session_log_file() -> anyhow::Result { - let log_dir = dirs::home_dir() - .ok_or_else(|| anyhow::anyhow!("could not determine home directory"))? - .join(".opengoose") - .join("logs"); + let log_dir = crate::home_dir().join(".opengoose").join("logs"); std::fs::create_dir_all(&log_dir)?; @@ -38,10 +35,7 @@ pub fn create_session_log_file() -> anyhow::Result { /// Deletes oldest session log files under `~/.opengoose/logs`, keeping only `keep` most recent. pub fn cleanup_old_logs(keep: usize) -> anyhow::Result<()> { - let log_dir = dirs::home_dir() - .ok_or_else(|| anyhow::anyhow!("could not determine home directory"))? - .join(".opengoose") - .join("logs"); + let log_dir = crate::home_dir().join(".opengoose").join("logs"); cleanup_old_logs_in(&log_dir, keep) } diff --git a/crates/opengoose/src/web/api/rigs.rs b/crates/opengoose/src/web/api/rigs.rs index e254d272..f5c8f9d4 100644 --- a/crates/opengoose/src/web/api/rigs.rs +++ b/crates/opengoose/src/web/api/rigs.rs @@ -84,9 +84,10 @@ pub async fn rig_detail( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; + let rig_id = opengoose_board::RigId::new(&id); let (stamps, dimensions, trust_score) = state .board - .stamps_with_scores(&id) + .stamps_with_scores(&rig_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -108,7 +109,7 @@ pub async fn rig_detail( let completed_items = state .board - .completed_by_rig(&id) + .completed_by(&rig_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; diff --git a/docs/superpowers/plans/2026-03-26-eval-quality-fixes.md b/docs/superpowers/plans/2026-03-26-eval-quality-fixes.md new file mode 100644 index 00000000..a00230f3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-eval-quality-fixes.md @@ -0,0 +1,912 @@ +# 코드 품질 평가 개선 — 구현 계획 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 코드 품질 평가에서 도출된 7개 개선 항목 해결 — evolver 크레이트 추출, ARCHITECTURE.md 동기화, 통합 테스트, doc-tests, 런타임 graceful degradation + +**Architecture:** evolver 모듈(2,490 LOC)을 `opengoose-evolver` 크레이트로 추출하고, `AgentConfig`/`create_agent`를 `opengoose-rig`로 이동하여 순환 의존 없이 분리. Board→Worker 통합 테스트를 `opengoose-rig/tests/`에 추가. 핵심 공개 API에 doc-test 추가. Runtime의 Worker 실패를 graceful하게 처리. + +**Tech Stack:** Rust, sea-orm, tokio, goose, ratatui + +--- + +### Task 1: `AgentConfig` + `create_agent`를 `opengoose-rig`로 이동 + +evolver 추출의 전제 조건. 현재 `crates/opengoose/src/runtime.rs`에 있는 범용 Agent 생성 유틸을 rig 크레이트로 이동. + +**Files:** +- Create: `crates/opengoose-rig/src/agent_factory.rs` +- Modify: `crates/opengoose-rig/src/lib.rs` +- Modify: `crates/opengoose-rig/Cargo.toml` +- Modify: `crates/opengoose/src/runtime.rs` + +- [ ] **Step 1: `crates/opengoose-rig/src/agent_factory.rs` 생성** + +`runtime.rs:50-112`의 `AgentConfig` struct와 `create_agent` 함수를 복사. `crate::` 참조를 제거하고 독립 모듈로 만든다. + +```rust +// Agent creation utilities — shared by Operator, Worker, Evolver. + +use anyhow::{Context, Result}; +use goose::agents::Agent; +use goose::model::ModelConfig; +use goose::session::session_manager::SessionType; +use tracing::info; + +pub struct AgentConfig { + pub session_id: String, + pub system_prompt: Option, +} + +/// Create a Goose Agent with the given config. +/// Reads GOOSE_PROVIDER and GOOSE_MODEL from the environment. +pub async fn create_agent(config: AgentConfig) -> Result { + let provider_name = std::env::var("GOOSE_PROVIDER").unwrap_or_else(|_| "anthropic".to_string()); + + let agent = Agent::new(); + + let cwd = std::env::current_dir().unwrap_or_else(|_| ".".into()); + let session = agent + .config + .session_manager + .create_session( + cwd, + config.session_id.clone(), + SessionType::User, + goose::config::goose_mode::GooseMode::Auto, + ) + .await + .context("failed to create session")?; + + let provider = match std::env::var("GOOSE_MODEL") { + Ok(model_name) => { + info!( + provider = %provider_name, + model = %model_name, + session = %config.session_id, + "creating agent" + ); + let model_config = ModelConfig::new(&model_name) + .context("invalid model config")? + .with_canonical_limits(&provider_name); + goose::providers::create(&provider_name, model_config, vec![]).await + } + Err(_) => { + info!( + provider = %provider_name, + model = "default", + session = %config.session_id, + "creating agent" + ); + goose::providers::create_with_default_model(&provider_name, vec![]).await + } + } + .context("failed to create provider")?; + + agent + .update_provider(provider, &session.id) + .await + .context("failed to set provider")?; + + if let Some(prompt) = config.system_prompt { + agent + .extend_system_prompt(config.session_id.clone(), prompt) + .await; + } + + Ok(agent) +} +``` + +- [ ] **Step 2: `opengoose-rig/src/lib.rs`에 모듈 등록** + +```rust +pub mod agent_factory; +``` + +- [ ] **Step 3: `opengoose-rig/Cargo.toml`에 goose 의존성 확인** + +goose는 이미 의존하고 있으므로 추가 불필요. 확인만. + +- [ ] **Step 4: `opengoose/src/runtime.rs` 업데이트** + +`AgentConfig`와 `create_agent` 함수 본문을 삭제하고 re-export으로 교체: + +```rust +pub use opengoose_rig::agent_factory::{AgentConfig, create_agent}; +``` + +`create_operator_agent`와 `create_worker_agent`는 바이너리 크레이트 고유이므로 그대로 유지 (시스템 프롬프트가 다름). + +- [ ] **Step 5: 빌드 확인** + +Run: `cargo check 2>&1 | tail -5` +Expected: `Finished` 성공 + +- [ ] **Step 6: 테스트 실행** + +Run: `cargo nextest run 2>&1 | tail -5` +Expected: 전체 통과 + +- [ ] **Step 7: 커밋** + +```bash +git add crates/opengoose-rig/src/agent_factory.rs crates/opengoose-rig/src/lib.rs crates/opengoose/src/runtime.rs +git commit -m "refactor: move AgentConfig + create_agent to opengoose-rig" +``` + +--- + +### Task 2: `opengoose-evolver` 크레이트 생성 + +evolver 모듈을 새 크레이트로 추출. + +**Files:** +- Create: `crates/opengoose-evolver/Cargo.toml` +- Create: `crates/opengoose-evolver/src/lib.rs` +- Create: `crates/opengoose-evolver/src/loop_driver.rs` +- Create: `crates/opengoose-evolver/src/pipeline.rs` +- Create: `crates/opengoose-evolver/src/sweep.rs` +- Modify: `Cargo.toml` (workspace members) +- Modify: `crates/opengoose/Cargo.toml` (add opengoose-evolver dep) +- Delete: `crates/opengoose/src/evolver/` (entire directory) +- Modify: `crates/opengoose/src/runtime.rs` + +- [ ] **Step 1: `crates/opengoose-evolver/Cargo.toml` 생성** + +```toml +[package] +name = "opengoose-evolver" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +opengoose-board = { path = "../opengoose-board" } +opengoose-rig = { path = "../opengoose-rig" } +opengoose-skills = { path = "../opengoose-skills" } +goose = { workspace = true } +async-trait = { workspace = true } +anyhow = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +futures = "0.3" +serde_json = { workspace = true } +chrono = { workspace = true } +``` + +- [ ] **Step 2: workspace `Cargo.toml` 업데이트** + +`members` 배열에 `"crates/opengoose-evolver"` 추가. +`[workspace.dependencies]`에 `opengoose-evolver = { path = "crates/opengoose-evolver" }` 추가. + +- [ ] **Step 3: evolver 소스 파일 이동** + +`crates/opengoose/src/evolver/` 4개 파일을 `crates/opengoose-evolver/src/`로 이동. +`mod.rs` → `lib.rs`로 리네임. + +내부 참조 수정: +- `crate::runtime::{AgentConfig, create_agent}` → `opengoose_rig::agent_factory::{AgentConfig, create_agent}` +- `crate::skills::{evolve, load}` → `opengoose_skills` 직접 import로 교체 +- `crate::skills::test_env_lock` → 새 크레이트 내부 `test_env_lock` 정의 +- `super::` 참조 → `crate::` 참조로 변경 + +`lib.rs` 변경: + +```rust +// Evolver — stamp_notify listener with lazy Agent init. +// Queries unprocessed low stamps, creates work items, analyzes with LLM. + +mod loop_driver; +mod pipeline; +mod sweep; + +use async_trait::async_trait; +use futures::StreamExt; +use goose::agents::{Agent, AgentEvent, SessionConfig}; +use goose::conversation::message::Message; +use opengoose_rig::work_mode::evolve_session_id; + +pub use loop_driver::run; + +pub(crate) const EVOLVER_SYSTEM_PROMPT: &str = "You are a skill analyst for OpenGoose.\n\ + Analyze failed tasks and extract concrete, actionable lessons as SKILL.md files.\n\n\ + Rules:\n\ + - description MUST start with 'Use when...' (triggering conditions only)\n\ + - description must NOT summarize the skill's workflow\n\ + - Every lesson must be specific to THIS failure, not generic advice\n\ + - Include a 'Common Mistakes' table with specific rationalizations\n\ + - Include a 'Red Flags' list for self-checking\n\ + - If the lesson is something any competent agent already knows, output SKIP\n\ + - If an existing skill covers the same lesson, output UPDATE:{skill-name}\n\n\ + Output format: raw SKILL.md content with YAML frontmatter, OR 'SKIP', OR 'UPDATE:{name}'."; + +pub(crate) const LOW_STAMP_THRESHOLD: f32 = 0.3; +const FALLBACK_SWEEP_SECS: u64 = 300; + +#[async_trait] +pub(crate) trait AgentCaller: Send + Sync { + async fn call(&self, prompt: &str, work_id: i64) -> anyhow::Result; +} + +struct RealAgentCaller<'a> { + agent: &'a Agent, +} + +#[async_trait] +impl AgentCaller for RealAgentCaller<'_> { + async fn call(&self, prompt: &str, work_id: i64) -> anyhow::Result { + let message = Message::user().with_text(prompt); + let session_config = SessionConfig { + id: evolve_session_id(work_id), + schedule_id: None, + max_turns: None, + retry_config: None, + }; + + let stream = self.agent.reply(message, session_config, None).await?; + tokio::pin!(stream); + + let mut response_text = String::new(); + while let Some(event) = stream.next().await { + match event { + Ok(AgentEvent::Message(msg)) => { + use goose::conversation::message::MessageContent; + for content in &msg.content { + if let MessageContent::Text(t) = content { + response_text.push_str(&t.text); + } + } + } + Err(e) => return Err(e), + _ => {} + } + } + + Ok(response_text) + } +} + +#[cfg(test)] +pub(crate) fn test_env_lock() -> &'static std::sync::Mutex<()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + &LOCK +} +``` + +- [ ] **Step 4: `loop_driver.rs` 내부 참조 수정** + +```rust +// 변경 전 +use crate::runtime::{AgentConfig, create_agent}; +// 변경 후 +use opengoose_rig::agent_factory::{AgentConfig, create_agent}; +``` + +`super::` 참조는 `crate::`로: +```rust +// 변경 전 +use super::{EVOLVER_SYSTEM_PROMPT, FALLBACK_SWEEP_SECS, LOW_STAMP_THRESHOLD, RealAgentCaller}; +// 변경 후 +use crate::{EVOLVER_SYSTEM_PROMPT, FALLBACK_SWEEP_SECS, LOW_STAMP_THRESHOLD, RealAgentCaller}; +``` + +- [ ] **Step 5: `pipeline.rs` 내부 참조 수정** + +```rust +// 변경 전 +use crate::skills::{evolve, load}; +// 변경 후 (직접 import) +use opengoose_skills::evolution::parser::{EvolveAction, parse_evolve_response}; +use opengoose_skills::evolution::prompts::{build_evolve_prompt, build_update_prompt, UpdatePromptParams, summarize_for_prompt}; +use opengoose_skills::evolution::validator::validate_skill_output; +use opengoose_skills::evolution::writer::{WriteSkillParams, update_existing_skill, write_skill_to_rig_scope}; +use opengoose_skills::loader::{LoadedSkill, load_skills}; +use opengoose_skills::metadata::{is_effective, read_metadata}; +``` + +`read_conversation_log` 함수를 이 크레이트 내에 정의: +```rust +fn read_conversation_log(work_item_id: i64) -> String { + let session_id = format!("task-{work_item_id}"); + opengoose_rig::conversation_log::read_log(&session_id) + .map(|content| summarize_for_prompt(&content, 4000)) + .unwrap_or_default() +} +``` + +테스트 내 `crate::skills::test_env_lock` → `crate::test_env_lock`. +테스트 내 `crate::skills::evolve::SkillMetadata` → `opengoose_skills::metadata::SkillMetadata`. + +- [ ] **Step 6: `sweep.rs` 내부 참조 수정** + +pipeline.rs와 동일한 패턴으로 `crate::skills::{evolve, load}` → 직접 import. +`crate::skills::test_env_lock` → `crate::test_env_lock`. + +- [ ] **Step 7: 바이너리 크레이트 업데이트** + +`crates/opengoose/Cargo.toml`에 `opengoose-evolver = { workspace = true }` 추가 (먼저 workspace deps에도 추가). + +`crates/opengoose/src/runtime.rs`에서: +```rust +// 변경 전 +use crate::evolver; +// ... +tokio::spawn(evolver::run(Arc::clone(&board), stamp_notify)); + +// 변경 후 +tokio::spawn(opengoose_evolver::run(Arc::clone(&board), stamp_notify)); +``` + +`crates/opengoose/src/evolver/` 디렉토리 전체 삭제. + +`crates/opengoose/src/main.rs` (또는 lib.rs)에서 `mod evolver;` 제거. + +- [ ] **Step 8: `crates/opengoose/src/skills/evolve.rs` 정리** + +evolver가 사용하던 re-export들 중 바이너리 크레이트 내에서 더 이상 사용되지 않는 항목 제거. `read_conversation_log`은 evolver로 이동했으므로 삭제. 테스트에서만 사용하는 항목은 테스트 모듈 내부 import으로 변경. + +- [ ] **Step 9: 빌드 확인** + +Run: `cargo check 2>&1 | tail -5` +Expected: `Finished` 성공 + +- [ ] **Step 10: 테스트 실행** + +Run: `cargo nextest run 2>&1 | tail -10` +Expected: 전체 통과 (evolver 테스트가 새 크레이트에서 실행됨) + +- [ ] **Step 11: 커밋** + +```bash +git add -A crates/opengoose-evolver/ Cargo.toml crates/opengoose/ +git commit -m "refactor: extract opengoose-evolver crate (2.5k LOC)" +``` + +--- + +### Task 3: ARCHITECTURE.md 문서 동기화 + +**Files:** +- Modify: `docs/v0.2/ARCHITECTURE.md` + +- [ ] **Step 1: 크레이트 수 수정 (18번 줄)** + +``` +// 변경 전 +3. **4개 크레이트** — `opengoose`, `opengoose-board`, `opengoose-rig`, `opengoose-skills`. +// 변경 후 +3. **6개 크레이트** — `opengoose`, `opengoose-board`, `opengoose-rig`, `opengoose-skills`, `opengoose-evolver`, `opengoose-sandbox` (실험적). +``` + +- [ ] **Step 2: 크레이트 구조 트리 (§3)에 evolver + sandbox 추가** + +opengoose-skills 뒤에 추가: + +``` +│ ├── opengoose-evolver/ # Evolver — stamp 기반 스킬 자동 진화 +│ │ └── src/ +│ │ ├── lib.rs # AgentCaller trait, run() 진입점 +│ │ ├── loop_driver.rs # stamp_notify 대기 + lazy Agent init +│ │ ├── pipeline.rs # stamp → LLM 분석 → 스킬 생성 +│ │ └── sweep.rs # 주기적 미처리 stamp 스캔 +│ │ +│ └── opengoose-sandbox/ # 실험적 — microVM 샌드박스 (macOS HVF) +│ └── src/ +│ ├── hypervisor/ # HVF (Apple Hypervisor.framework) +│ ├── boot.rs # VM 부팅 시퀀스 +│ ├── machine.rs # VM 머신 설정 +│ ├── pool.rs # VM 풀 관리 +│ ├── snapshot.rs # CoW 스냅샷 +│ ├── vm.rs # VM 라이프사이클 +│ ├── uart.rs # 시리얼 콘솔 +│ ├── virtio.rs # VirtIO 장치 +│ └── initramfs.rs # initramfs 빌더 +``` + +- [ ] **Step 3: 의존성 그래프 (§3.1) 업데이트** + +``` +opengoose-board (OpenGoose 의존성 없음. sea-orm, chrono, serde, tokio) + ↑ +opengoose-rig (의존: board, goose) + ↑ +opengoose-evolver (의존: board, rig, skills, goose) + ↑ +opengoose (의존: board, rig, skills, evolver — 바이너리) + +opengoose-skills (독립. board, rig, goose 의존 없음) +opengoose-sandbox (독립. macOS 전용, HVF 의존) +``` + +- [ ] **Step 4: "하지 않는 것" 테이블 (§3.2)에 evolver + sandbox 행 추가** + +| 크레이트 | 하지 않는 것 | +|----------|-------------| +| **evolver** | Board CRUD, 세션 관리, CLI/TUI, 직접 스킬 파일 I/O (opengoose-skills에 위임) | +| **sandbox** | LLM 호출, Board 접근, 네트워크, 플랫폼 추상화 (macOS HVF 전용) | + +- [ ] **Step 5: "추가된 것" 테이블 (§12) 업데이트** + +크레이트 수: `4개 (opengoose-skills 추가)` → `6개 (opengoose-evolver, opengoose-sandbox 추가)` + +- [ ] **Step 6: 설계 결정 기록 섹션 추가 (문서 끝, §13 전)** + +```markdown +## 13. 설계 결정 기록 + +### ADR-1: Board의 SQLite + CowStore 단일 소유 + +Board struct는 SQLite(영속성)와 CowStore(인메모리 브랜치/머지) 두 저장소를 소유한다. +`merge()` 메서드에서 staged clone → merge → persist → swap 4단계가 하나의 Mutex lock 안에서 실행되어 원자성을 보장한다. +persist 실패 시 swap이 안 일어남 → CowStore와 SQLite 일관성 자동 보장. +분리하면 이 원자성을 외부 호출자가 보장해야 하므로 동기화 버그 표면적이 증가한다. +재검토 시점: board.rs가 500줄을 넘거나, 저장소 백엔드를 교체할 필요가 생길 때. +``` + +기존 "열린 질문" 섹션 번호를 §14로 변경. + +- [ ] **Step 7: 커밋** + +```bash +git add docs/v0.2/ARCHITECTURE.md +git commit -m "docs: sync ARCHITECTURE.md — 6 crates, evolver + sandbox, ADR-1" +``` + +--- + +### Task 4: Board→Worker 통합 테스트 + +**Files:** +- Create: `crates/opengoose-rig/tests/worker_integration.rs` + +- [ ] **Step 1: 테스트 파일 생성 — `post_claim_submit_lifecycle`** + +```rust +//! Board → Worker integration tests. +//! Tests claim/submit/retry logic at the Board API level. +//! No LLM calls — pure state transitions. + +use opengoose_board::Board; +use opengoose_board::work_item::{PostWorkItem, Priority, RigId, Status}; +use std::sync::Arc; + +fn post_req(title: &str) -> PostWorkItem { + PostWorkItem { + title: title.to_string(), + description: format!("Description for {title}"), + created_by: RigId::new("human"), + priority: Priority::P1, + tags: vec![], + } +} + +#[tokio::test] +async fn post_claim_submit_lifecycle() { + let board = Board::in_memory().await.expect("board init should succeed"); + let worker_id = RigId::new("worker-1"); + board + .register_rig("worker-1", "ai", None, None) + .await + .expect("register_rig should succeed"); + + // Post + let item = board.post(post_req("test task")).await.expect("post should succeed"); + assert_eq!(item.status, Status::Open); + + // Claim + let claimed = board.claim(item.id, &worker_id).await.expect("claim should succeed"); + assert_eq!(claimed.status, Status::Claimed); + assert_eq!(claimed.claimed_by.as_ref(), Some(&worker_id)); + + // Submit + board.submit(item.id, &worker_id).await.expect("submit should succeed"); + let done = board.get(item.id).await.expect("get should succeed").expect("item should exist"); + assert_eq!(done.status, Status::Done); +} +``` + +- [ ] **Step 2: `worker_skips_blocked_items` 테스트 추가** + +```rust +#[tokio::test] +async fn worker_skips_blocked_items() { + let board = Board::in_memory().await.expect("board init should succeed"); + + let blocker = board.post(post_req("blocker")).await.expect("post should succeed"); + let blocked = board.post(post_req("blocked")).await.expect("post should succeed"); + board + .add_dependency(blocker.id, blocked.id) + .await + .expect("add_dependency should succeed"); + + let ready = board.ready().await.expect("ready should succeed"); + let ready_ids: Vec = ready.iter().map(|i| i.id).collect(); + + assert!(ready_ids.contains(&blocker.id), "blocker should be ready"); + assert!(!ready_ids.contains(&blocked.id), "blocked item should NOT be ready"); +} +``` + +- [ ] **Step 3: `worker_retries_then_stuck` 테스트 추가** + +```rust +#[tokio::test] +async fn claim_then_mark_stuck() { + let board = Board::in_memory().await.expect("board init should succeed"); + let worker_id = RigId::new("worker-1"); + board + .register_rig("worker-1", "ai", None, None) + .await + .expect("register_rig should succeed"); + + let item = board.post(post_req("failing task")).await.expect("post should succeed"); + board.claim(item.id, &worker_id).await.expect("claim should succeed"); + + // Simulate bounded retry exhaustion → mark stuck + board + .mark_stuck(item.id, &worker_id) + .await + .expect("mark_stuck should succeed"); + + let stuck = board.get(item.id).await.expect("get should succeed").expect("item should exist"); + assert_eq!(stuck.status, Status::Stuck); +} +``` + +- [ ] **Step 4: `concurrent_workers_no_double_claim` 테스트 추가** + +```rust +#[tokio::test] +async fn concurrent_workers_no_double_claim() { + let board = Arc::new(Board::in_memory().await.expect("board init should succeed")); + let item = board.post(post_req("contested task")).await.expect("post should succeed"); + + for i in 0..2 { + board + .register_rig(&format!("w-{i}"), "ai", None, None) + .await + .expect("register_rig should succeed"); + } + + let board1 = Arc::clone(&board); + let board2 = Arc::clone(&board); + + let h1 = tokio::spawn(async move { + board1.claim(item.id, &RigId::new("w-0")).await + }); + let h2 = tokio::spawn(async move { + board2.claim(item.id, &RigId::new("w-1")).await + }); + + let (r1, r2) = tokio::join!(h1, h2); + let r1 = r1.expect("task should not panic"); + let r2 = r2.expect("task should not panic"); + + // Exactly one should succeed, the other should fail with AlreadyClaimed + let successes = [r1.is_ok(), r2.is_ok()]; + assert_eq!( + successes.iter().filter(|&&s| s).count(), + 1, + "exactly one worker should claim successfully" + ); +} +``` + +- [ ] **Step 5: 테스트 실행** + +Run: `cargo nextest run -p opengoose-rig --test worker_integration 2>&1 | tail -10` +Expected: 4 tests passed + +- [ ] **Step 6: 커밋** + +```bash +git add crates/opengoose-rig/tests/worker_integration.rs +git commit -m "test: add Board→Worker integration tests (4 scenarios)" +``` + +--- + +### Task 5: Doc-tests — opengoose-board + +**Files:** +- Modify: `crates/opengoose-board/src/board.rs` +- Modify: `crates/opengoose-board/src/work_item.rs` +- Modify: `crates/opengoose-board/src/beads.rs` + +- [ ] **Step 1: `Board::in_memory` doc-test** + +`board.rs`의 `pub async fn in_memory()` 위에: + +```rust +/// Create an in-memory Board for testing. +/// +/// # Examples +/// +/// ``` +/// # tokio::runtime::Runtime::new().unwrap().block_on(async { +/// let board = opengoose_board::Board::in_memory().await.unwrap(); +/// let item = board.post(opengoose_board::work_item::PostWorkItem { +/// title: "Test".into(), +/// description: String::new(), +/// created_by: opengoose_board::work_item::RigId::new("human"), +/// priority: opengoose_board::work_item::Priority::P1, +/// tags: vec![], +/// }).await.unwrap(); +/// assert_eq!(item.title, "Test"); +/// # }); +/// ``` +``` + +- [ ] **Step 2: `RigId::new` + `RigId::try_new` doc-test** + +`work_item.rs`의 `RigId::new` 위에: + +```rust +/// Create a RigId without validation (panics on empty string via convention). +/// +/// # Examples +/// +/// ``` +/// let id = opengoose_board::work_item::RigId::new("worker-1"); +/// assert_eq!(id.to_string(), "worker-1"); +/// ``` +``` + +`RigId::try_new` 위에: + +```rust +/// Create a RigId with validation. +/// +/// # Examples +/// +/// ``` +/// use opengoose_board::work_item::RigId; +/// +/// assert!(RigId::try_new("valid-id").is_ok()); +/// assert!(RigId::try_new("").is_err()); +/// assert!(RigId::try_new("has/slash").is_err()); +/// assert!(RigId::try_new("has..dots").is_err()); +/// ``` +``` + +- [ ] **Step 3: `Status::precedence` doc-test** + +`work_item.rs`의 `Status::precedence` 위에: + +```rust +/// Merge precedence: higher value wins. +/// +/// # Examples +/// +/// ``` +/// use opengoose_board::work_item::Status; +/// +/// assert!(Status::Done.precedence() > Status::Open.precedence()); +/// assert!(Status::Claimed.precedence() > Status::Open.precedence()); +/// ``` +``` + +- [ ] **Step 4: `filter_ready` doc-test** + +`beads.rs`의 `filter_ready` 위에: + +```rust +/// Filter open, unblocked items sorted by priority. +/// +/// # Examples +/// +/// ``` +/// use opengoose_board::beads::filter_ready; +/// use opengoose_board::work_item::*; +/// use std::collections::HashSet; +/// +/// let items = vec![WorkItem { +/// id: 1, +/// title: "task".into(), +/// description: String::new(), +/// created_by: RigId::new("human"), +/// created_at: chrono::Utc::now(), +/// status: Status::Open, +/// priority: Priority::P1, +/// tags: vec![], +/// claimed_by: None, +/// updated_at: chrono::Utc::now(), +/// }]; +/// let ready = filter_ready(items.into_iter(), &HashSet::new()); +/// assert_eq!(ready.len(), 1); +/// ``` +``` + +- [ ] **Step 5: `prime_summary` doc-test** + +`beads.rs`의 `prime_summary` 위에: + +```rust +/// Build a compact summary of board state for agent context injection. +/// +/// # Examples +/// +/// ``` +/// use opengoose_board::beads::prime_summary; +/// use opengoose_board::work_item::*; +/// +/// let summary = prime_summary(&[], &RigId::new("worker")); +/// assert!(summary.contains("0 open")); +/// ``` +``` + +- [ ] **Step 6: doc-test 실행** + +Run: `cargo test --doc -p opengoose-board 2>&1 | tail -10` +Expected: doc-tests 통과 + +- [ ] **Step 7: 커밋** + +```bash +git add crates/opengoose-board/src/board.rs crates/opengoose-board/src/work_item.rs crates/opengoose-board/src/beads.rs +git commit -m "docs: add doc-tests to opengoose-board core APIs" +``` + +--- + +### Task 6: Doc-tests — opengoose-rig + opengoose-skills + +**Files:** +- Modify: `crates/opengoose-rig/src/work_mode.rs` +- Modify: `crates/opengoose-skills/src/catalog.rs` +- Modify: `crates/opengoose-skills/src/metadata.rs` + +- [ ] **Step 1: `WorkMode` trait doc-test** + +`work_mode.rs`의 `WorkMode` trait 위에 (실행 불가 — goose Agent 필요): + +```rust +/// Strategy pattern for Rig session management. +/// +/// # Examples +/// +/// ```no_run +/// use opengoose_rig::work_mode::{TaskMode, WorkMode, WorkInput}; +/// +/// let input = WorkInput::task("implement feature X", 42); +/// let config = TaskMode.session_config(&input); +/// assert!(config.id.contains("task-42")); +/// ``` +``` + +- [ ] **Step 2: skills 크레이트 doc-test** + +실제 공개 API를 확인하여 `SkillMetadata`, `SkillCatalog` 등에 적절한 doc-test 추가. 실행 가능한 것만 (파일시스템 의존이 있으면 `no_run`). + +- [ ] **Step 3: doc-test 실행** + +Run: `cargo test --doc -p opengoose-rig -p opengoose-skills 2>&1 | tail -10` +Expected: doc-tests 통과 + +- [ ] **Step 4: 커밋** + +```bash +git add crates/opengoose-rig/ crates/opengoose-skills/ +git commit -m "docs: add doc-tests to opengoose-rig and opengoose-skills" +``` + +--- + +### Task 7: 런타임 Graceful Degradation + +**Files:** +- Modify: `crates/opengoose/src/runtime.rs` +- Modify: `crates/opengoose/src/cli/commands.rs` + +- [ ] **Step 1: `Runtime.worker`를 `Option`으로 변경** + +`runtime.rs`: + +```rust +pub struct Runtime { + pub board: Arc, + pub worker: Option>, +} +``` + +- [ ] **Step 2: `init_runtime`에서 Worker 실패를 graceful하게 처리** + +```rust +pub async fn init_runtime(port: u16) -> Result { + let board = Arc::new(Board::connect(&crate::db_url()).await?); + web::spawn_server(Arc::clone(&board), port).await?; + + let stamp_notify = board.stamp_notify_handle(); + tokio::spawn(opengoose_evolver::run(Arc::clone(&board), stamp_notify)); + + // Worker — graceful degradation on failure + let worker = match create_worker_agent().await { + Ok((worker_agent, _)) => { + let worker = Arc::new(opengoose_rig::rig::Worker::new( + RigId::new("worker"), + Arc::clone(&board), + worker_agent, + opengoose_rig::work_mode::TaskMode, + vec![ + Arc::new(ContextHydrator { + skill_catalog: String::new(), + }), + Arc::new(ValidationGate), + ], + )); + let worker_handle = Arc::clone(&worker); + tokio::spawn(async move { worker_handle.run().await }); + Some(worker) + } + Err(e) => { + tracing::warn!(error = %e, "worker agent creation failed, running without worker"); + None + } + }; + + Ok(Runtime { board, worker }) +} +``` + +- [ ] **Step 3: `commands.rs`에서 `Option` 처리** + +```rust +// 변경 전 (2곳) +rt.worker.cancel(); + +// 변경 후 +if let Some(ref worker) = rt.worker { + worker.cancel(); +} +``` + +- [ ] **Step 4: 빌드 확인** + +Run: `cargo check 2>&1 | tail -5` +Expected: `Finished` 성공 + +- [ ] **Step 5: 테스트 실행** + +Run: `cargo nextest run 2>&1 | tail -5` +Expected: 전체 통과 + +- [ ] **Step 6: 커밋** + +```bash +git add crates/opengoose/src/runtime.rs crates/opengoose/src/cli/commands.rs +git commit -m "fix: graceful degradation when worker agent creation fails" +``` + +--- + +### Task 8: 최종 검증 + +- [ ] **Step 1: 전체 빌드** + +Run: `cargo check 2>&1 | tail -5` +Expected: `Finished` 성공 + +- [ ] **Step 2: Clippy** + +Run: `cargo clippy --all-targets 2>&1 | tail -10` +Expected: 경고 0 + +- [ ] **Step 3: 전체 테스트** + +Run: `cargo nextest run 2>&1 | tail -10` +Expected: 전체 통과 + +- [ ] **Step 4: Doc-tests** + +Run: `cargo test --doc 2>&1 | tail -10` +Expected: doc-tests 통과 + +- [ ] **Step 5: LOC 확인** + +Run: `for crate in opengoose opengoose-board opengoose-rig opengoose-skills opengoose-evolver opengoose-sandbox; do echo "=== $crate ==="; find crates/$crate/src -name '*.rs' | xargs wc -l 2>/dev/null | tail -1; done` +Expected: opengoose ~8.9k (was 11.4k), opengoose-evolver ~2.5k + +- [ ] **Step 6: 최종 커밋 (필요시)** + +변경 사항이 있으면 커밋. diff --git a/docs/superpowers/specs/2026-03-26-eval-quality-fixes-design.md b/docs/superpowers/specs/2026-03-26-eval-quality-fixes-design.md new file mode 100644 index 00000000..713ee4f9 --- /dev/null +++ b/docs/superpowers/specs/2026-03-26-eval-quality-fixes-design.md @@ -0,0 +1,244 @@ +# 코드 품질 평가 개선 — 설계 문서 + +> **날짜:** 2026-03-26 +> **목표:** 코드 품질 및 아키텍처 평가에서 도출된 7개 개선 항목 해결 +> **접근법:** 균형 (Approach B) + evolver 크레이트 추출 + +--- + +## 1. 배경 + +외부 평가에서 7개 개선 포인트가 도출됨: + +| # | 이슈 | 심각도 | +|---|------|--------| +| 1 | opengoose 크레이트 비대 (11.4k LOC, 38%) | 낮음 | +| 2 | sandbox 크레이트 고아 — ARCHITECTURE.md 미언급 | 낮음 | +| 3 | 크레이트 수 불일치 — 문서 "4개", 실제 5개 | 낮음 | +| 4 | 통합 테스트 부족 — Board→Worker flow 없음 | 중간 | +| 5 | Doc-tests 0개 — 공개 API에 예제 없음 | 낮음 | +| 6 | Board 이중 책임 — SQLite + CowStore 한 struct | 낮음 | +| 7 | 런타임 에러 핸들링 — Worker 실패 시 전체 실패 | 낮음 | + +**결정:** +- 항목 1: evolver를 별도 크레이트로 추출 (11.4k → ~8.9k LOC) +- 항목 6: Board 분리하지 않음 — merge→persist 원자성을 위해 단일 소유자 유지 (ADR-1) +- 나머지: 문서화 + 테스트 + 에러 핸들링 + +--- + +## 2. Evolver 크레이트 추출 (항목 1) + +### 2.1 동기 + +`crates/opengoose/src/evolver/` (2,490 LOC)는 와이어링이 아닌 독립된 서브시스템: +- stamp 분석 → LLM 호출 → 스킬 생성/업데이트 파이프라인 +- `loop_driver.rs`, `pipeline.rs`, `sweep.rs` — 자체 비즈니스 로직 +- Board + Skills + Goose에 의존 — opengoose-rig와 동일한 의존 방향 + +### 2.2 의존성 해결 + +| 현재 의존 | 처리 | +|-----------|------| +| `opengoose_board::Board` | 그대로 유지 | +| `opengoose_rig::work_mode::evolve_session_id` | 그대로 유지 | +| `goose::agents::Agent` | 그대로 유지 | +| `crate::runtime::{AgentConfig, create_agent}` | `opengoose-rig`로 이동 (범용 Agent 생성 유틸) | +| `crate::skills::{evolve, load}` | `opengoose-skills` 직접 의존으로 교체 | +| `crate::skills::test_env_lock` | 새 크레이트 내부 test util로 이동 | + +### 2.3 추출 후 의존성 그래프 + +``` +opengoose-board (독립) + ↑ +opengoose-rig (board, goose) + ↑ +opengoose-evolver [NEW] (board, rig, skills, goose) + ↑ +opengoose (board, rig, skills, evolver) + +opengoose-skills (독립) +opengoose-sandbox (독립, 실험적) +``` + +순환 없음. 바이너리 크레이트에서 `opengoose_evolver::run(board, stamp_notify)` 한 줄 호출. + +### 2.4 새 크레이트 구조 + +``` +crates/opengoose-evolver/ +├── Cargo.toml +└── src/ + ├── lib.rs # pub use loop_driver::run; + AgentCaller trait + ├── loop_driver.rs # stamp_notify 대기 + lazy Agent init + ├── pipeline.rs # stamp → LLM 분석 → 스킬 생성 + └── sweep.rs # 주기적 미처리 stamp 스캔 +``` + +### 2.5 바이너리 크레이트 변경 + +- `crates/opengoose/src/evolver/` 디렉토리 삭제 +- `runtime.rs`에서 `opengoose_evolver::run(board, stamp_notify)` 호출 +- `crates/opengoose/src/skills/` — evolver가 사용하던 re-export 정리 + +--- + +## 3. ARCHITECTURE.md 문서 수정 (항목 2, 3, 6) + +### 3.1 크레이트 수 수정 + +`docs/v0.2/ARCHITECTURE.md` 18번 줄: + +``` +변경 전: 3. **4개 크레이트** — `opengoose`, `opengoose-board`, `opengoose-rig`, `opengoose-skills`. +변경 후: 3. **6개 크레이트** — `opengoose`, `opengoose-board`, `opengoose-rig`, `opengoose-skills`, `opengoose-evolver`, `opengoose-sandbox` (실험적). +``` + +### 3.2 크레이트 구조 섹션 (§3) — evolver + sandbox 추가 + +기존 트리에 추가: + +``` +│ ├── opengoose-evolver/ # Evolver — stamp 기반 스킬 자동 진화 +│ │ └── src/ +│ │ ├── lib.rs # AgentCaller trait, run() 진입점 +│ │ ├── loop_driver.rs # stamp_notify 대기 + lazy Agent init +│ │ ├── pipeline.rs # stamp → LLM 분석 → 스킬 생성 +│ │ └── sweep.rs # 주기적 미처리 stamp 스캔 +│ │ +│ └── opengoose-sandbox/ # 실험적 — microVM 샌드박스 +│ └── src/ +│ ├── hypervisor/ # HVF (Apple Hypervisor.framework) +│ ├── boot.rs # VM 부팅 시퀀스 +│ ├── machine.rs # VM 머신 설정 +│ ├── pool.rs # VM 풀 관리 +│ ├── snapshot.rs # CoW 스냅샷 +│ ├── vm.rs # VM 라이프사이클 +│ ├── uart.rs # 시리얼 콘솔 +│ ├── virtio.rs # VirtIO 장치 +│ └── initramfs.rs # initramfs 빌더 +``` + +### 3.3 의존성 그래프 (§3.1) — evolver + sandbox 추가 + +``` +opengoose-board (OpenGoose 의존성 없음) + ↑ +opengoose-rig (의존: board, goose) + ↑ +opengoose-evolver (의존: board, rig, skills, goose) + ↑ +opengoose (의존: board, rig, skills, evolver — 바이너리) + +opengoose-skills (독립. board, rig, goose 의존 없음) +opengoose-sandbox (독립. macOS 전용, HVF 의존) +``` + +### 3.4 "하지 않는 것" 테이블 (§3.2) — evolver + sandbox 행 추가 + +| 크레이트 | 하지 않는 것 | +|----------|-------------| +| **evolver** | Board CRUD, 세션 관리, CLI/TUI, 직접 스킬 파일 I/O (opengoose-skills에 위임) | +| **sandbox** | LLM 호출, Board 접근, 네트워크, 플랫폼 추상화 (macOS HVF 전용) | + +### 3.5 설계 결정 기록 (새 섹션) + +**ADR-1: 왜 Board가 SQLite + CowStore를 함께 관리하는가** + +Board struct는 SQLite(영속성)와 CowStore(인메모리 브랜치/머지) 두 저장소를 소유한다. +분리하지 않는 이유: +- `merge()` 메서드에서 staged clone → merge → persist → swap 4단계가 하나의 Mutex lock 안에서 실행 +- persist 실패 시 swap이 안 일어남 → CowStore와 SQLite 일관성 자동 보장 +- 분리하면 이 원자성을 외부 호출자가 보장해야 함 → 동기화 버그 표면적 증가 +- 현재 board.rs는 200줄, 실제 복잡도는 모듈(work_items/, store/, stamp_ops 등)에 분산 +- 재검토 시점: board.rs가 500줄을 넘거나, 저장소 백엔드를 교체할 필요가 생길 때 + +--- + +## 4. Board→Worker 통합 테스트 (항목 4) + +### 4.1 위치 + +`crates/opengoose-rig/tests/worker_integration.rs` + +opengoose-rig가 Board에 의존하므로 여기에 배치. 바이너리 크레이트(opengoose)는 Goose 런타임 의존이 무거워서 부적합. + +### 4.2 테스트 시나리오 + +| 테스트 | 검증 내용 | +|--------|----------| +| `post_claim_submit_lifecycle` | Board.post → claim → Status::Claimed → submit → Status::Done | +| `worker_skips_blocked_items` | 블로킹 의존성이 있는 항목은 claim 대상에서 제외 | +| `worker_retries_then_stuck` | 실행 실패 → 재시도 2회 → Status::Stuck 마킹 | +| `concurrent_workers_no_double_claim` | 2개 Worker가 동시에 pull해도 같은 항목을 중복 claim하지 않음 | + +### 4.3 접근 + +- `Board::in_memory()` 사용 — 외부 의존 없음 +- Goose `Agent::reply()`를 모킹하지 않음 (Goose-native 원칙) +- Worker의 claim/submit/retry 로직을 Board API 레벨에서 검증 +- 실제 LLM 호출 제외 — 순수 Board 상태 전이 + 경쟁 조건에 집중 +- 동시성 테스트는 `tokio::spawn` + `Arc` 사용 + +--- + +## 5. Doc-tests (항목 5) + +### 5.1 범위 + +핵심 공개 API에만 `/// # Examples` 추가. 내부(`pub(crate)`) 함수 제외. + +### 5.2 대상 + +| 크레이트 | 대상 | +|----------|------| +| **board** | `Board::connect`, `Board::in_memory`, `Board::branch`, `Board::merge` | +| **board** | `WorkItem`, `Status`, `Priority`, `RigId` | +| **board** | `filter_ready`, `prime_summary`, `find_compactable` (beads) | +| **rig** | `Worker::new`, `Operator::new` | +| **rig** | `WorkMode` trait | +| **skills** | `SkillCatalog::load`, `SkillMetadata` | + +### 5.3 규칙 + +- `Board::in_memory().await` 사용하여 외부 의존 없이 실행 가능한 예제 +- async 함수는 `# tokio::main` 래퍼 사용 +- 실행 불가능한 경우 `no_run` 표시 +- 대략 15~20개 doc-test 추가 + +--- + +## 6. 런타임 에러 핸들링 (항목 7) + +### 6.1 변경 + +`crates/opengoose/src/runtime.rs`: + +``` +현재: Board → Web → Evolver → Worker → Ok(Runtime) + ↑ 실패하면 전체 실패 + +변경: Board → Web → Evolver → Worker 시도 + ├─ 성공 → Runtime { worker: Some(worker) } + └─ 실패 → tracing::warn! 로깅 + Runtime { worker: None } +``` + +### 6.2 구체적 변경 + +- `Runtime.worker` 타입: `Arc` → `Option>` +- Worker 생성(`create_worker_agent`) 실패 시 `warn!` 로깅 후 `None`으로 계속 +- TUI Board 탭에서 worker가 None이면 "Worker offline" 상태 표시 +- `create_agent`의 `unwrap_or_else(|_| ".".into())` — cwd 실패 폴백은 합리적이므로 유지 + +### 6.3 영향 범위 + +Runtime.worker를 사용하는 모든 곳에서 `Option` 처리 필요. 현재 runtime.rs 외에 worker를 직접 참조하는 곳을 확인하여 수정. + +--- + +## 7. 범위 밖 + +- Board struct 분리 — ADR-1 참조 +- Federation, 멀티 Worker UX — ARCHITECTURE.md 열린 질문으로 유지 diff --git a/docs/v0.2/ARCHITECTURE.md b/docs/v0.2/ARCHITECTURE.md index be02571c..e2c7c1af 100644 --- a/docs/v0.2/ARCHITECTURE.md +++ b/docs/v0.2/ARCHITECTURE.md @@ -15,7 +15,7 @@ v0.2의 설계 제약: 1. **Goose-native** — `Agent::reply()`가 유일한 LLM 인터페이스. 래퍼 없음, 재구현 없음. 2. **Pull-only** — 모든 작업이 Wanted Board를 통과. 오케스트레이터 push 없음. -3. **4개 크레이트** — `opengoose`, `opengoose-board`, `opengoose-rig`, `opengoose-skills`. +3. **6개 크레이트** — `opengoose`, `opengoose-board`, `opengoose-rig`, `opengoose-skills`, `opengoose-evolver`, `opengoose-sandbox` (실험적). 4. **CLI-first** — TUI 대화형 + 헤드리스 `run` + 웹 대시보드. 플랫폼 게이트웨이 없음. --- @@ -180,19 +180,38 @@ opengoose/ │ │ ├── worktree.rs # WorktreeGuard (RAII), sweep_orphaned_worktrees │ │ └── conversation_log/ # 세션별 대화 로그 (파일 기반) │ │ -│ └── opengoose-skills/ # 스킬 로딩, 진화, 관리 +│ ├── opengoose-skills/ # 스킬 로딩, 진화, 관리 +│ │ └── src/ +│ │ ├── catalog.rs # 스킬 카탈로그 (로드된 스킬 목록) +│ │ ├── loader.rs # 파일시스템에서 스킬 로드 +│ │ ├── metadata.rs # 스킬 프론트매터 파싱 +│ │ ├── lifecycle.rs # 스킬 수명주기 (active, deprecated 등) +│ │ ├── source.rs # 스킬 소스 (local, bundled) +│ │ ├── manage/ # add, remove, list, update, promote, discover, lock +│ │ └── evolution/ # stamp 기반 스킬 자동 생성/개선 +│ │ ├── parser.rs # LLM 응답 파싱 +│ │ ├── prompts.rs # Evolver 프롬프트 빌더 +│ │ ├── validator.rs # 생성된 스킬 검증 +│ │ └── writer/ # 스킬 파일 쓰기 (effectiveness, refine) +│ │ +│ ├── opengoose-evolver/ # Evolver — stamp 기반 스킬 자동 진화 +│ │ └── src/ +│ │ ├── lib.rs # AgentCaller trait, run() 진입점 +│ │ ├── loop_driver.rs # stamp_notify 대기 + lazy Agent init +│ │ ├── pipeline.rs # stamp → LLM 분석 → 스킬 생성 +│ │ └── sweep.rs # 주기적 미처리 stamp 스캔 +│ │ +│ └── opengoose-sandbox/ # 실험적 — microVM 샌드박스 (macOS HVF) │ └── src/ -│ ├── catalog.rs # 스킬 카탈로그 (로드된 스킬 목록) -│ ├── loader.rs # 파일시스템에서 스킬 로드 -│ ├── metadata.rs # 스킬 프론트매터 파싱 -│ ├── lifecycle.rs # 스킬 수명주기 (active, deprecated 등) -│ ├── source.rs # 스킬 소스 (local, bundled) -│ ├── manage/ # add, remove, list, update, promote, discover, lock -│ └── evolution/ # stamp 기반 스킬 자동 생성/개선 -│ ├── parser.rs # LLM 응답 파싱 -│ ├── prompts.rs # Evolver 프롬프트 빌더 -│ ├── validator.rs # 생성된 스킬 검증 -│ └── writer/ # 스킬 파일 쓰기 (effectiveness, refine) +│ ├── hypervisor/ # HVF (Apple Hypervisor.framework) +│ ├── boot.rs # VM 부팅 시퀀스 +│ ├── machine.rs # VM 머신 설정 +│ ├── pool.rs # VM 풀 관리 +│ ├── snapshot.rs # CoW 스냅샷 +│ ├── vm.rs # VM 라이프사이클 +│ ├── uart.rs # 시리얼 콘솔 +│ ├── virtio.rs # VirtIO 장치 +│ └── initramfs.rs # initramfs 빌더 ``` ### 3.1 의존성 그래프 @@ -202,11 +221,12 @@ opengoose-board (OpenGoose 의존성 없음. sea-orm, chrono, serde, t ↑ opengoose-rig (의존: board, goose) ↑ -opengoose (의존: board, rig, skills — 바이너리) +opengoose-evolver (의존: board, rig, skills, goose) + ↑ +opengoose (의존: board, rig, skills, evolver — 바이너리) opengoose-skills (독립. board, rig, goose 의존 없음) - ↑ -opengoose (의존: skills) +opengoose-sandbox (독립. macOS 전용, HVF 의존) ``` ### 3.2 각 크레이트가 하지 않는 것 @@ -217,6 +237,8 @@ opengoose (의존: skills) | **rig** | 메시지 라우팅, 플랫폼 관리, 데이터 저장, 텍스트 프로토콜 파싱 | | **skills** | LLM 호출, Board 접근, Goose 의존 | | **opengoose** | 비즈니스 로직 포함 (CLI + TUI + Web + 와이어링만) | +| **evolver** | Board CRUD, 세션 관리, CLI/TUI, 직접 스킬 파일 I/O (opengoose-skills에 위임) | +| **sandbox** | LLM 호출, Board 접근, 네트워크, 플랫폼 추상화 (macOS HVF 전용) | --- @@ -699,7 +721,7 @@ pub async fn init_runtime(port: u16) -> Result { | 측면 | 원래 설계 | 현재 | |------|----------|------| -| 크레이트 수 | 3개 | 4개 (opengoose-skills 추가) | +| 크레이트 수 | 3개 | 6개 (opengoose-evolver, opengoose-sandbox 추가) | | WorkMode | ChatMode, TaskMode | + EvolveMode (Evolver용) | | Rig 타입 | Operator, Worker | + Evolver | | 데이터 레이어 | 인메모리만 (Phase 1) | SQLite + CowStore 듀얼 | @@ -711,7 +733,19 @@ pub async fn init_runtime(port: u16) -> Result { --- -## 13. 열린 질문 +## 13. 설계 결정 기록 + +### ADR-1: Board의 SQLite + CowStore 단일 소유 + +Board struct는 SQLite(영속성)와 CowStore(인메모리 브랜치/머지) 두 저장소를 소유한다. +`merge()` 메서드에서 staged clone → merge → persist → swap 4단계가 하나의 Mutex lock 안에서 실행되어 원자성을 보장한다. +persist 실패 시 swap이 안 일어남 → CowStore와 SQLite 일관성 자동 보장. +분리하면 이 원자성을 외부 호출자가 보장해야 하므로 동기화 버그 표면적이 증가한다. +재검토 시점: board.rs가 500줄을 넘거나, 저장소 백엔드를 교체할 필요가 생길 때. + +--- + +## 14. 열린 질문 1. ~~**대화가 보드를 우회해야 하는가?**~~ **해결됨 (§ 2.3).** Operator가 직접 처리. 2. **Federation 범위?** 전면 연기. v0.2 = 단일 인스턴스.