1mod bundle;
6mod commit;
7mod convergence;
8mod init;
9mod planning;
10mod repair;
11mod solo;
12mod verification;
13
14use crate::agent::{ActuatorAgent, Agent, ArchitectAgent, SpeculatorAgent, VerifierAgent};
15use crate::context_retriever::ContextRetriever;
16use crate::lsp::LspClient;
17use crate::test_runner::{self, PythonTestRunner, TestResults};
18use crate::tools::{AgentTools, ToolCall};
19use crate::types::{AgentContext, EnergyComponents, ModelTier, NodeState, SRBNNode, TaskPlan};
20use anyhow::{Context, Result};
21use perspt_core::types::{
22 EscalationCategory, EscalationReport, NodeClass, ProvisionalBranch, ProvisionalBranchState,
23 RewriteAction, RewriteRecord, SheafValidationResult, SheafValidatorClass, WorkspaceState,
24};
25use petgraph::graph::{DiGraph, NodeIndex};
26use petgraph::visit::{EdgeRef, Topo, Walker};
27use std::collections::HashMap;
28use std::path::PathBuf;
29use std::sync::atomic::{AtomicBool, Ordering};
30use std::sync::Arc;
31use std::time::Instant;
32
33#[derive(Debug, Clone)]
35pub struct Dependency {
36 pub kind: String,
38}
39
40#[derive(Debug, Clone)]
42pub enum ApprovalResult {
43 Approved,
45 ApprovedWithEdit(String),
47 Rejected,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum NodeOutcome {
54 Completed,
56 Escalated,
58}
59
60pub struct SRBNOrchestrator {
62 pub graph: DiGraph<SRBNNode, Dependency>,
64 node_indices: HashMap<String, NodeIndex>,
66 pub context: AgentContext,
68 pub auto_approve: bool,
70 lsp_clients: HashMap<String, LspClient>,
72 agents: Vec<Box<dyn Agent>>,
74 tools: AgentTools,
76 last_written_file: Option<PathBuf>,
78 file_version: i32,
80 provider: std::sync::Arc<perspt_core::llm_provider::GenAIProvider>,
82 architect_model: String,
84 actuator_model: String,
86 verifier_model: String,
88 speculator_model: String,
90 architect_fallback_model: Option<String>,
92 actuator_fallback_model: Option<String>,
94 verifier_fallback_model: Option<String>,
96 speculator_fallback_model: Option<String>,
98 event_sender: Option<perspt_core::events::channel::EventSender>,
100 action_receiver: Option<perspt_core::events::channel::ActionReceiver>,
102 pub ledger: crate::ledger::MerkleLedger,
104 pub last_tool_failure: Option<String>,
106 last_context_provenance: Option<perspt_core::types::ContextProvenance>,
108 last_formatted_context: String,
110 last_verification_result: Option<perspt_core::types::VerificationResult>,
112 last_applied_bundle: Option<perspt_core::types::ArtifactBundle>,
114 last_repair_footprint: Option<perspt_core::RepairFootprint>,
116 blocked_dependencies: Vec<perspt_core::types::BlockedDependency>,
118 budget: perspt_core::types::BudgetEnvelope,
120 pub planning_policy: perspt_core::PlanningPolicy,
122 pub stability_epsilon: f32,
124 pub energy_alpha: f32,
126 pub energy_beta: f32,
128 pub energy_gamma: f32,
130 abort_requested: Arc<AtomicBool>,
132}
133
134fn epoch_seconds() -> i64 {
136 use std::time::{SystemTime, UNIX_EPOCH};
137 SystemTime::now()
138 .duration_since(UNIX_EPOCH)
139 .unwrap()
140 .as_secs() as i64
141}
142
143fn detect_stub_content(path: &std::path::Path, plugin_hint: &str) -> Option<String> {
152 let content = std::fs::read_to_string(path).ok()?;
153
154 let lang = if !plugin_hint.is_empty() && plugin_hint != "unknown" {
156 plugin_hint.to_ascii_lowercase()
157 } else {
158 path.extension()
159 .and_then(|e| e.to_str())
160 .map(|e| match e {
161 "rs" => "rust",
162 "py" => "python",
163 "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" => "javascript",
164 _ => "",
165 })
166 .unwrap_or("")
167 .to_string()
168 };
169
170 let universal_patterns = [
172 "// stub",
173 "# stub",
174 "// placeholder",
175 "# placeholder",
176 "// will be replaced",
177 "# will be replaced",
178 "/* todo */",
179 ];
180
181 let lang_patterns: &[&str] = match lang.as_str() {
183 "rust" => &["todo!()", "unimplemented!()"],
184 "python" => &["raise NotImplementedError", "raise NotImplementedError()"],
185 "javascript" | "typescript" => &[
186 "throw new Error(\"not implemented\")",
187 "throw new Error('not implemented')",
188 "throw new Error(\"TODO\")",
189 "throw new Error('TODO')",
190 ],
191 _ => &[],
192 };
193
194 let content_lower = content.to_ascii_lowercase();
195
196 let mut matched_pattern = None;
198 for pat in &universal_patterns {
199 if content_lower.contains(pat) {
200 matched_pattern = Some(*pat);
201 break;
202 }
203 }
204 if matched_pattern.is_none() {
205 for pat in lang_patterns {
206 if content.contains(pat) {
207 matched_pattern = Some(*pat);
208 break;
209 }
210 }
211 }
212
213 if matched_pattern.is_none() && lang == "python" {
215 let trimmed_lines: Vec<&str> = content
216 .lines()
217 .map(|l| l.trim())
218 .filter(|l| !l.is_empty() && !l.starts_with('#'))
219 .collect();
220 let body_only: Vec<&&str> = trimmed_lines
221 .iter()
222 .filter(|l| {
223 !l.starts_with("def ")
224 && !l.starts_with("class ")
225 && !l.starts_with("import ")
226 && !l.starts_with("from ")
227 })
228 .collect();
229 if body_only.len() <= 2 && body_only.iter().all(|l| **l == "pass" || **l == "...") {
230 matched_pattern = Some("only pass/... body");
231 }
232 }
233
234 let pattern = matched_pattern?;
235
236 let real_lines = count_real_code_lines(&content, &lang);
238 if real_lines >= 5 {
239 return None;
242 }
243
244 Some(format!(
245 "found '{}' with only {} line(s) of real code",
246 pattern, real_lines
247 ))
248}
249
250fn count_real_code_lines(content: &str, lang: &str) -> usize {
252 content
253 .lines()
254 .filter(|line| {
255 let trimmed = line.trim();
256 if trimmed.is_empty() {
257 return false;
258 }
259 match lang {
261 "rust" => {
262 if trimmed.starts_with("//")
263 || trimmed.starts_with("/*")
264 || trimmed.starts_with('*')
265 {
266 return false;
267 }
268 if trimmed.starts_with("use ")
270 || trimmed.starts_with("extern ")
271 || trimmed.starts_with("mod ")
272 {
273 return false;
274 }
275 }
276 "python" => {
277 if trimmed.starts_with('#')
278 || trimmed.starts_with("\"\"\"")
279 || trimmed.starts_with("'''")
280 {
281 return false;
282 }
283 if trimmed.starts_with("import ") || trimmed.starts_with("from ") {
284 return false;
285 }
286 }
287 "javascript" | "typescript" => {
288 if trimmed.starts_with("//")
289 || trimmed.starts_with("/*")
290 || trimmed.starts_with('*')
291 {
292 return false;
293 }
294 if trimmed.starts_with("import ")
295 || trimmed.starts_with("require(")
296 || trimmed.starts_with("const ") && trimmed.contains("require(")
297 {
298 return false;
299 }
300 }
301 _ => {
302 if trimmed.starts_with("//")
303 || trimmed.starts_with('#')
304 || trimmed.starts_with("/*")
305 {
306 return false;
307 }
308 }
309 }
310 true
311 })
312 .count()
313}
314
315impl SRBNOrchestrator {
316 pub fn new(working_dir: PathBuf, auto_approve: bool) -> Self {
318 Self::new_with_models(
319 working_dir,
320 auto_approve,
321 None,
322 None,
323 None,
324 None,
325 None,
326 None,
327 None,
328 None,
329 )
330 }
331
332 #[allow(clippy::too_many_arguments)]
334 pub fn new_with_models(
335 working_dir: PathBuf,
336 auto_approve: bool,
337 architect_model: Option<String>,
338 actuator_model: Option<String>,
339 verifier_model: Option<String>,
340 speculator_model: Option<String>,
341 architect_fallback_model: Option<String>,
342 actuator_fallback_model: Option<String>,
343 verifier_fallback_model: Option<String>,
344 speculator_fallback_model: Option<String>,
345 ) -> Self {
346 let provider = std::sync::Arc::new(
348 perspt_core::llm_provider::GenAIProvider::new().unwrap_or_else(|e| {
349 log::warn!("Failed to create GenAIProvider: {}, using default", e);
350 perspt_core::llm_provider::GenAIProvider::new().expect("GenAI must initialize")
351 }),
352 );
353
354 Self::new_with_models_and_provider(
355 working_dir,
356 auto_approve,
357 provider,
358 architect_model,
359 actuator_model,
360 verifier_model,
361 speculator_model,
362 architect_fallback_model,
363 actuator_fallback_model,
364 verifier_fallback_model,
365 speculator_fallback_model,
366 )
367 }
368
369 #[allow(clippy::too_many_arguments)]
376 pub fn new_with_models_and_provider(
377 working_dir: PathBuf,
378 auto_approve: bool,
379 provider: std::sync::Arc<perspt_core::llm_provider::GenAIProvider>,
380 architect_model: Option<String>,
381 actuator_model: Option<String>,
382 verifier_model: Option<String>,
383 speculator_model: Option<String>,
384 architect_fallback_model: Option<String>,
385 actuator_fallback_model: Option<String>,
386 verifier_fallback_model: Option<String>,
387 speculator_fallback_model: Option<String>,
388 ) -> Self {
389 let context = AgentContext {
390 working_dir: working_dir.clone(),
391 auto_approve,
392 ..Default::default()
393 };
394
395 let tools = AgentTools::new(working_dir.clone(), !auto_approve);
397
398 let stored_architect_model = architect_model
400 .clone()
401 .unwrap_or_else(|| ModelTier::Architect.default_model().to_string());
402 let stored_actuator_model = actuator_model
403 .clone()
404 .unwrap_or_else(|| ModelTier::Actuator.default_model().to_string());
405 let stored_verifier_model = verifier_model
406 .clone()
407 .unwrap_or_else(|| ModelTier::Verifier.default_model().to_string());
408 let stored_speculator_model = speculator_model
409 .clone()
410 .unwrap_or_else(|| ModelTier::Speculator.default_model().to_string());
411
412 Self {
413 graph: DiGraph::new(),
414 node_indices: HashMap::new(),
415 context,
416 auto_approve,
417 lsp_clients: HashMap::new(),
418 agents: vec![
419 Box::new(ArchitectAgent::new(provider.clone(), architect_model)),
420 Box::new(ActuatorAgent::new(provider.clone(), actuator_model)),
421 Box::new(VerifierAgent::new(provider.clone(), verifier_model)),
422 Box::new(SpeculatorAgent::new(provider.clone(), speculator_model)),
423 ],
424 tools,
425 last_written_file: None,
426 file_version: 0,
427 provider,
428 architect_model: stored_architect_model,
429 actuator_model: stored_actuator_model,
430 verifier_model: stored_verifier_model,
431 speculator_model: stored_speculator_model,
432 architect_fallback_model,
433 actuator_fallback_model,
434 verifier_fallback_model,
435 speculator_fallback_model,
436 event_sender: None,
437 action_receiver: None,
438 #[cfg(test)]
439 ledger: crate::ledger::MerkleLedger::in_memory().expect("Failed to create test ledger"),
440 #[cfg(not(test))]
441 ledger: crate::ledger::MerkleLedger::new().expect("Failed to create ledger"),
442 last_tool_failure: None,
443 last_context_provenance: None,
444 last_formatted_context: String::new(),
445 last_verification_result: None,
446 last_applied_bundle: None,
447 last_repair_footprint: None,
448 blocked_dependencies: Vec::new(),
449 budget: perspt_core::types::BudgetEnvelope::new("pending"),
450 planning_policy: perspt_core::PlanningPolicy::default(),
451 stability_epsilon: 0.1,
452 energy_alpha: 1.0,
453 energy_beta: 0.5,
454 energy_gamma: 2.0,
455 abort_requested: Arc::new(AtomicBool::new(false)),
456 }
457 }
458
459 #[cfg(test)]
461 pub fn new_for_testing(working_dir: PathBuf) -> Self {
462 let context = AgentContext {
463 working_dir: working_dir.clone(),
464 auto_approve: true,
465 ..Default::default()
466 };
467
468 let provider = std::sync::Arc::new(
469 perspt_core::llm_provider::GenAIProvider::new().unwrap_or_else(|e| {
470 log::warn!("Failed to create GenAIProvider: {}, using default", e);
471 perspt_core::llm_provider::GenAIProvider::new().expect("GenAI must initialize")
472 }),
473 );
474
475 let tools = AgentTools::new(working_dir.clone(), false);
476
477 Self {
478 graph: DiGraph::new(),
479 node_indices: HashMap::new(),
480 context,
481 auto_approve: true,
482 lsp_clients: HashMap::new(),
483 agents: vec![
484 Box::new(ArchitectAgent::new(provider.clone(), None)),
485 Box::new(ActuatorAgent::new(provider.clone(), None)),
486 Box::new(VerifierAgent::new(provider.clone(), None)),
487 Box::new(SpeculatorAgent::new(provider.clone(), None)),
488 ],
489 tools,
490 last_written_file: None,
491 file_version: 0,
492 provider,
493 architect_model: ModelTier::Architect.default_model().to_string(),
494 actuator_model: ModelTier::Actuator.default_model().to_string(),
495 verifier_model: ModelTier::Verifier.default_model().to_string(),
496 speculator_model: ModelTier::Speculator.default_model().to_string(),
497 architect_fallback_model: None,
498 actuator_fallback_model: None,
499 verifier_fallback_model: None,
500 speculator_fallback_model: None,
501 event_sender: None,
502 action_receiver: None,
503 ledger: crate::ledger::MerkleLedger::in_memory().expect("Failed to create test ledger"),
504 last_tool_failure: None,
505 last_context_provenance: None,
506 last_formatted_context: String::new(),
507 last_verification_result: None,
508 last_applied_bundle: None,
509 last_repair_footprint: None,
510 blocked_dependencies: Vec::new(),
511 budget: perspt_core::types::BudgetEnvelope::new("test"),
512 planning_policy: perspt_core::PlanningPolicy::default(),
513 stability_epsilon: 0.1,
514 energy_alpha: 1.0,
515 energy_beta: 0.5,
516 energy_gamma: 2.0,
517 abort_requested: Arc::new(AtomicBool::new(false)),
518 }
519 }
520
521 pub fn add_node(&mut self, node: SRBNNode) -> NodeIndex {
523 let node_id = node.node_id.clone();
524 let idx = self.graph.add_node(node);
525 self.node_indices.insert(node_id, idx);
526 idx
527 }
528
529 pub fn connect_tui(
531 &mut self,
532 event_sender: perspt_core::events::channel::EventSender,
533 action_receiver: perspt_core::events::channel::ActionReceiver,
534 ) {
535 self.tools.set_event_sender(event_sender.clone());
536 self.event_sender = Some(event_sender);
537 self.action_receiver = Some(action_receiver);
538 }
539
540 pub fn abort_flag(&self) -> Arc<AtomicBool> {
542 self.abort_requested.clone()
543 }
544
545 fn is_abort_requested(&self) -> bool {
547 self.abort_requested.load(Ordering::Relaxed)
548 }
549
550 fn finalize_session(&mut self, result: &Result<perspt_core::SessionOutcome>) {
552 let status = if self.is_abort_requested() {
553 "ABORTED"
554 } else {
555 match result {
556 Ok(perspt_core::SessionOutcome::Success) => "COMPLETED",
557 Ok(perspt_core::SessionOutcome::PartialSuccess) => "PARTIAL",
558 Ok(perspt_core::SessionOutcome::Failed) | Err(_) => "FAILED",
559 }
560 };
561 if let Err(e) = self.ledger.end_session(status) {
562 log::error!("Failed to finalize session as {}: {}", status, e);
563 }
564 }
565
566 pub fn set_budget(
571 &mut self,
572 max_steps: Option<u32>,
573 max_revisions: Option<u32>,
574 max_cost_usd: Option<f64>,
575 ) {
576 self.budget.max_steps = max_steps;
577 self.budget.max_revisions = max_revisions;
578 self.budget.max_cost_usd = max_cost_usd;
579 }
580
581 pub fn rehydrate_session(
596 &mut self,
597 session_id: &str,
598 ) -> Result<crate::ledger::SessionSnapshot> {
599 self.context.session_id = session_id.to_string();
601 self.ledger.current_session = Some(crate::ledger::SessionRecordLegacy {
602 session_id: session_id.to_string(),
603 task: String::new(),
604 started_at: epoch_seconds(),
605 ended_at: None,
606 status: "RESUMING".to_string(),
607 total_nodes: 0,
608 completed_nodes: 0,
609 });
610
611 let snapshot = self.ledger.load_session_snapshot()?;
612
613 if let Ok(Some(row)) = self.ledger.get_budget_envelope() {
616 self.budget = perspt_core::types::BudgetEnvelope {
617 session_id: row.session_id,
618 max_steps: row.max_steps.map(|v| v as u32),
619 steps_used: row.steps_used as u32,
620 max_revisions: row.max_revisions.map(|v| v as u32),
621 revisions_used: row.revisions_used as u32,
622 max_cost_usd: row.max_cost_usd,
623 cost_used_usd: row.cost_used_usd,
624 };
625 log::info!(
626 "Restored budget envelope: steps {}/{:?}, revisions {}/{:?}, cost ${:.2}/{:?}",
627 self.budget.steps_used,
628 self.budget.max_steps,
629 self.budget.revisions_used,
630 self.budget.max_revisions,
631 self.budget.cost_used_usd,
632 self.budget.max_cost_usd,
633 );
634 }
635
636 if snapshot.node_details.is_empty() {
638 anyhow::bail!(
639 "Session {} has no persisted nodes — cannot resume",
640 session_id
641 );
642 }
643
644 let node_ids: std::collections::HashSet<&str> = snapshot
646 .node_details
647 .iter()
648 .map(|d| d.record.node_id.as_str())
649 .collect();
650 let orphaned_edges = snapshot
651 .graph_edges
652 .iter()
653 .filter(|e| {
654 !node_ids.contains(e.parent_node_id.as_str())
655 || !node_ids.contains(e.child_node_id.as_str())
656 })
657 .count();
658 if orphaned_edges > 0 {
659 log::warn!(
660 "Session {} has {} orphaned edge(s) referencing unknown nodes — \
661 edges will be dropped during resume",
662 session_id,
663 orphaned_edges
664 );
665 self.emit_log(format!(
666 "⚠️ Resume: dropping {} orphaned graph edge(s)",
667 orphaned_edges
668 ));
669 }
670
671 let mut node_map: HashMap<String, NodeIndex> = HashMap::new();
673
674 for detail in &snapshot.node_details {
675 let rec = &detail.record;
676
677 let state = parse_node_state(&rec.state);
678 let node_class = rec
679 .node_class
680 .as_deref()
681 .map(parse_node_class)
682 .unwrap_or_default();
683
684 let mut node = SRBNNode::new(
685 rec.node_id.clone(),
686 rec.goal.clone().unwrap_or_default(),
687 ModelTier::Actuator,
688 );
689 node.state = state;
690 node.node_class = node_class;
691 node.owner_plugin = rec.owner_plugin.clone().unwrap_or_default();
692 node.parent_id = rec.parent_id.clone();
693 node.children = rec
694 .children
695 .as_deref()
696 .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
697 .unwrap_or_default();
698 node.monitor.attempt_count = rec.attempt_count as usize;
699
700 if let Some(last_energy) = detail.energy_history.last() {
702 node.monitor.energy_history.push(last_energy.v_total);
703 }
704
705 if let Some(seal) = detail.interface_seals.last() {
707 if seal.seal_hash.len() == 32 {
708 let mut hash = [0u8; 32];
709 hash.copy_from_slice(&seal.seal_hash);
710 node.interface_seal_hash = Some(hash);
711 }
712 }
713
714 let idx = self.add_node(node);
715 node_map.insert(rec.node_id.clone(), idx);
716 }
717
718 for edge in &snapshot.graph_edges {
720 if let (Some(&from_idx), Some(&to_idx)) = (
721 node_map.get(&edge.parent_node_id),
722 node_map.get(&edge.child_node_id),
723 ) {
724 self.graph.add_edge(
725 from_idx,
726 to_idx,
727 Dependency {
728 kind: edge.edge_type.clone(),
729 },
730 );
731 }
732 }
733
734 for (child_id, &child_idx) in &node_map {
736 let parents: Vec<NodeIndex> = self
737 .graph
738 .neighbors_directed(child_idx, petgraph::Direction::Incoming)
739 .collect();
740
741 for parent_idx in parents {
742 let parent = &self.graph[parent_idx];
743 if parent.node_class == NodeClass::Interface
744 && parent.interface_seal_hash.is_none()
745 && !parent.state.is_terminal()
746 {
747 self.blocked_dependencies
748 .push(perspt_core::types::BlockedDependency {
749 child_node_id: child_id.clone(),
750 parent_node_id: parent.node_id.clone(),
751 required_seal_paths: Vec::new(),
752 blocked_at: epoch_seconds(),
753 });
754 }
755 }
756 }
757
758 let terminal = snapshot
759 .node_details
760 .iter()
761 .filter(|d| {
762 let s = parse_node_state(&d.record.state);
763 s.is_terminal()
764 })
765 .count();
766 let resumable = snapshot.node_details.len() - terminal;
767
768 log::info!(
769 "Rehydrated session {}: {} nodes ({} terminal, {} resumable), {} edges",
770 session_id,
771 snapshot.node_details.len(),
772 terminal,
773 resumable,
774 snapshot.graph_edges.len()
775 );
776
777 if let Some(ref mut sess) = self.ledger.current_session {
779 sess.total_nodes = snapshot.node_details.len();
780 sess.completed_nodes = terminal;
781 sess.status = "RUNNING".to_string();
782 }
783
784 for detail in &snapshot.node_details {
788 let state = parse_node_state(&detail.record.state);
789 if state.is_terminal() {
790 continue;
791 }
792
793 if let Some(ref prov) = detail.context_provenance {
794 let retriever = ContextRetriever::new(self.context.working_dir.clone());
795 let drift = retriever.validate_provenance_record(prov);
796 if !drift.is_empty() {
797 log::warn!(
798 "Provenance drift for node '{}': {} file(s) missing: {}",
799 detail.record.node_id,
800 drift.len(),
801 drift.join(", ")
802 );
803 self.emit_log(format!(
804 "⚠️ Provenance drift: node '{}' has {} missing file(s)",
805 detail.record.node_id,
806 drift.len()
807 ));
808 self.emit_event(perspt_core::AgentEvent::ProvenanceDrift {
809 node_id: detail.record.node_id.clone(),
810 missing_files: drift,
811 reason: "Files referenced in persisted context no longer exist".to_string(),
812 });
813 }
814 }
815 }
816
817 Ok(snapshot)
818 }
819
820 pub async fn run_resumed(&mut self) -> Result<()> {
827 let result = self.run_resumed_inner().await;
828 self.finalize_session(&result);
829 result.map(|_| ())
830 }
831
832 async fn run_resumed_inner(&mut self) -> Result<perspt_core::SessionOutcome> {
834 let topo = Topo::new(&self.graph);
835 let indices: Vec<_> = topo.iter(&self.graph).collect();
836 let total_nodes = indices.len();
837 let mut executed = 0;
838 let mut escalated: usize = 0;
839
840 let terminal_count = indices
842 .iter()
843 .filter(|i| self.graph[**i].state.is_terminal())
844 .count();
845 let blocked_count = indices
846 .iter()
847 .filter(|i| !self.graph[**i].state.is_terminal() && self.check_seal_prerequisites(**i))
848 .count();
849 let resumable_count = total_nodes - terminal_count - blocked_count;
850 self.emit_log(format!(
851 "📊 Differential resume: {} total, {} skipped (terminal), {} blocked (seal), {} to execute",
852 total_nodes, terminal_count, blocked_count, resumable_count
853 ));
854
855 for (i, idx) in indices.iter().enumerate() {
856 if self.is_abort_requested() {
858 self.emit_log("⚠️ Session aborted — stopping resumed execution".to_string());
859 break;
860 }
861
862 if self.budget.any_exhausted() {
864 let node_id = self.graph[*idx].node_id.clone();
865 self.emit_log(format!(
866 "⛔ Budget exhausted — skipping node '{}' and remaining nodes",
867 node_id
868 ));
869 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
870 node_id,
871 status: perspt_core::NodeStatus::Escalated,
872 });
873 break;
874 }
875
876 let node = &self.graph[*idx];
877
878 if node.state.is_terminal() {
880 log::debug!("Skipping terminal node {} ({:?})", node.node_id, node.state);
881 continue;
882 }
883
884 if self.check_seal_prerequisites(*idx) {
886 log::warn!(
887 "Node {} blocked on seal prerequisite — skipping",
888 self.graph[*idx].node_id
889 );
890 continue;
891 }
892
893 let node = &self.graph[*idx];
894 self.emit_log(format!(
895 "📝 [resume {}/{}] {}",
896 i + 1,
897 total_nodes,
898 node.goal
899 ));
900 self.emit_event(perspt_core::AgentEvent::NodeSelected {
901 node_id: node.node_id.clone(),
902 goal: node.goal.clone(),
903 node_class: node.node_class.to_string(),
904 });
905 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
906 node_id: node.node_id.clone(),
907 status: perspt_core::NodeStatus::Running,
908 });
909
910 match self.execute_node(*idx).await {
911 Ok(NodeOutcome::Completed) => {
912 executed += 1;
913 self.budget.record_step();
914
915 if let Err(e) = self.ledger.upsert_budget_envelope(&self.budget) {
917 log::warn!("Failed to persist budget envelope: {}", e);
918 }
919
920 if let Some(node) = self.graph.node_weight(*idx) {
921 self.emit_event(perspt_core::AgentEvent::NodeCompleted {
922 node_id: node.node_id.clone(),
923 goal: node.goal.clone(),
924 });
925 }
926 }
927 Ok(NodeOutcome::Escalated) => {
928 escalated += 1;
929 self.budget.record_step();
930 continue;
931 }
932 Err(e) => {
933 escalated += 1;
934 let node_id = self.graph[*idx].node_id.clone();
935 log::error!("Node {} failed on resume: {}", node_id, e);
936 self.emit_log(format!("❌ Node {} failed: {}", node_id, e));
937 self.graph[*idx].state = NodeState::Escalated;
938 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
939 node_id,
940 status: perspt_core::NodeStatus::Escalated,
941 });
942 continue;
943 }
944 }
945 }
946
947 log::info!(
948 "Resumed execution completed: {} of {} nodes executed",
949 executed,
950 total_nodes
951 );
952
953 let outcome = if escalated == 0 && executed + terminal_count >= total_nodes {
956 perspt_core::SessionOutcome::Success
957 } else if executed > 0 {
958 perspt_core::SessionOutcome::PartialSuccess
959 } else {
960 perspt_core::SessionOutcome::Failed
961 };
962 self.emit_event(perspt_core::AgentEvent::Complete {
963 success: outcome == perspt_core::SessionOutcome::Success,
964 message: format!(
965 "Resumed: {}/{} completed, {} escalated",
966 executed, total_nodes, escalated
967 ),
968 });
969 Ok(outcome)
970 }
971
972 fn emit_event(&self, event: perspt_core::AgentEvent) {
974 if let Some(ref sender) = self.event_sender {
975 let _ = sender.send(event);
976 }
977 }
978
979 fn emit_log(&self, msg: impl Into<String>) {
981 self.emit_event(perspt_core::AgentEvent::Log(msg.into()));
982 }
983
984 fn record_step_quietly(
986 &self,
987 node_id: &str,
988 step: &str,
989 outcome: &str,
990 energy: Option<&perspt_core::types::EnergyComponents>,
991 attempt_count: i32,
992 duration_ms: i32,
993 ) {
994 let record = perspt_store::SrbnStepRecord {
995 session_id: self.context.session_id.clone(),
996 node_id: node_id.to_string(),
997 step: step.to_string(),
998 outcome: outcome.to_string(),
999 energy_json: energy.and_then(|e| serde_json::to_string(e).ok()),
1000 parse_state: None,
1001 retry_classification: None,
1002 attempt_count,
1003 duration_ms,
1004 };
1005 if let Err(e) = self.ledger.record_step(&record) {
1006 log::warn!("Failed to record step '{}' for {}: {}", step, node_id, e);
1007 }
1008 }
1009
1010 async fn await_approval(
1014 &mut self,
1015 action_type: perspt_core::ActionType,
1016 description: String,
1017 diff: Option<String>,
1018 ) -> ApprovalResult {
1019 self.await_approval_for_node(action_type, description, diff, None)
1020 .await
1021 }
1022
1023 async fn await_approval_for_node(
1025 &mut self,
1026 action_type: perspt_core::ActionType,
1027 description: String,
1028 diff: Option<String>,
1029 review_node_id: Option<&str>,
1030 ) -> ApprovalResult {
1031 if self.auto_approve {
1033 if let Some(nid) = review_node_id {
1034 self.persist_review_decision(nid, "auto_approved", None);
1035 }
1036 return ApprovalResult::Approved;
1037 }
1038
1039 if self.action_receiver.is_none() {
1041 if let Some(nid) = review_node_id {
1042 self.persist_review_decision(nid, "auto_approved", None);
1043 }
1044 return ApprovalResult::Approved;
1045 }
1046
1047 let request_id = uuid::Uuid::new_v4().to_string();
1049
1050 self.emit_event(perspt_core::AgentEvent::ApprovalRequest {
1052 request_id: request_id.clone(),
1053 node_id: review_node_id.unwrap_or("current").to_string(),
1054 action_type,
1055 description,
1056 diff,
1057 });
1058
1059 if let Some(ref mut receiver) = self.action_receiver {
1061 while let Some(action) = receiver.recv().await {
1062 match action {
1063 perspt_core::AgentAction::Approve { request_id: rid } if rid == request_id => {
1064 self.emit_log("✓ Approved by user");
1065 if let Some(nid) = review_node_id {
1066 self.persist_review_decision(nid, "approved", None);
1067 }
1068 return ApprovalResult::Approved;
1069 }
1070 perspt_core::AgentAction::ApproveWithEdit {
1071 request_id: rid,
1072 edited_value,
1073 } if rid == request_id => {
1074 self.emit_log(format!("✓ Approved with edit: {}", edited_value));
1075 if let Some(nid) = review_node_id {
1076 self.persist_review_decision(nid, "approved_with_edit", None);
1077 }
1078 return ApprovalResult::ApprovedWithEdit(edited_value);
1079 }
1080 perspt_core::AgentAction::Reject {
1081 request_id: rid,
1082 reason,
1083 } if rid == request_id => {
1084 let msg = reason.unwrap_or_else(|| "User rejected".to_string());
1085 self.emit_log(format!("✗ Rejected: {}", msg));
1086 if let Some(nid) = review_node_id {
1087 self.persist_review_decision(nid, "rejected", Some(&msg));
1088 }
1089 return ApprovalResult::Rejected;
1090 }
1091 perspt_core::AgentAction::RequestCorrection {
1092 request_id: rid,
1093 feedback,
1094 } if rid == request_id => {
1095 self.emit_log(format!("🔄 Correction requested: {}", feedback));
1096 if let Some(nid) = review_node_id {
1097 self.persist_review_decision(
1098 nid,
1099 "correction_requested",
1100 Some(&feedback),
1101 );
1102 }
1103 return ApprovalResult::Rejected;
1104 }
1105 perspt_core::AgentAction::Abort => {
1106 self.emit_log("⚠️ Session aborted by user");
1107 self.abort_requested.store(true, Ordering::Relaxed);
1108 if let Some(nid) = review_node_id {
1109 self.persist_review_decision(nid, "aborted", None);
1110 }
1111 return ApprovalResult::Rejected;
1112 }
1113 _ => {
1114 continue;
1116 }
1117 }
1118 }
1119 }
1120
1121 ApprovalResult::Rejected }
1123
1124 fn persist_review_decision(&self, node_id: &str, outcome: &str, note: Option<&str>) {
1126 let degraded = self.last_verification_result.as_ref().map(|vr| vr.degraded);
1127 if let Err(e) = self
1128 .ledger
1129 .record_review_outcome(node_id, outcome, note, None, degraded, None)
1130 {
1131 log::warn!("Failed to persist review decision for {}: {}", node_id, e);
1132 }
1133 }
1134
1135 pub fn add_dependency(&mut self, from_id: &str, to_id: &str, kind: &str) -> Result<()> {
1137 let from_idx = self
1138 .node_indices
1139 .get(from_id)
1140 .context(format!("Node not found: {}", from_id))?;
1141 let to_idx = self
1142 .node_indices
1143 .get(to_id)
1144 .context(format!("Node not found: {}", to_id))?;
1145
1146 self.graph.add_edge(
1147 *from_idx,
1148 *to_idx,
1149 Dependency {
1150 kind: kind.to_string(),
1151 },
1152 );
1153 Ok(())
1154 }
1155
1156 pub async fn run(&mut self, task: String) -> Result<()> {
1158 log::info!("Starting SRBN execution for task: {}", task);
1159 self.emit_log(format!("🚀 Starting task: {}", task));
1160
1161 let session_id = uuid::Uuid::new_v4().to_string();
1163 self.context.session_id = session_id.clone();
1164 self.ledger.start_session(
1165 &session_id,
1166 &task,
1167 &self.context.working_dir.to_string_lossy(),
1168 )?;
1169
1170 let result = self.run_orchestration(task).await;
1172 self.finalize_session(&result);
1173 result.map(|_| ())
1174 }
1175
1176 async fn run_orchestration(&mut self, task: String) -> Result<perspt_core::SessionOutcome> {
1178 if self.context.log_llm {
1179 self.emit_log("📝 LLM request logging enabled".to_string());
1180 }
1181
1182 let execution_mode = self.detect_execution_mode(&task);
1184 self.context.execution_mode = execution_mode;
1185 self.emit_log(format!("🎯 Execution mode: {}", execution_mode));
1186
1187 if execution_mode == perspt_core::types::ExecutionMode::Solo {
1188 log::info!("Using Solo Mode for explicit single-file task");
1190 self.emit_log("⚡ Solo Mode: Single-file execution".to_string());
1191 return self
1192 .run_solo_mode(task)
1193 .await
1194 .map(|()| perspt_core::SessionOutcome::Success);
1195 }
1196
1197 let workspace_state = self.classify_workspace(&task);
1199 self.context.workspace_state = workspace_state.clone();
1200 self.emit_log(format!("📋 Workspace: {}", workspace_state));
1201
1202 if let WorkspaceState::ExistingProject { ref plugins } = workspace_state {
1205 self.context.active_plugins = plugins.clone();
1206 self.emit_log(format!("🔌 Detected plugins: {}", plugins.join(", ")));
1207 self.emit_plugin_readiness();
1208 }
1209
1210 self.step_init_project(&task).await?;
1212
1213 if !matches!(workspace_state, WorkspaceState::ExistingProject { .. }) {
1216 self.redetect_plugins_after_init();
1217 }
1218
1219 self.check_verifier_readiness_gate();
1223
1224 {
1227 let plugin_refs: Vec<String> = self.context.active_plugins.clone();
1228 let refs: Vec<&str> = plugin_refs.iter().map(|s| s.as_str()).collect();
1229 if !refs.is_empty() {
1230 self.emit_log("🔍 Starting language servers...".to_string());
1231 if let Err(e) = self.start_lsp_for_plugins(&refs).await {
1232 log::warn!("Failed to start LSP: {}", e);
1233 self.emit_log("⚠️ Continuing without LSP".to_string());
1234 } else {
1235 self.emit_log("✅ Language servers ready".to_string());
1236 }
1237 }
1238 }
1239
1240 if self.planning_policy == perspt_core::PlanningPolicy::default() {
1244 self.planning_policy = match &self.context.workspace_state {
1245 WorkspaceState::Greenfield { .. } => perspt_core::PlanningPolicy::GreenfieldBuild,
1246 WorkspaceState::ExistingProject { .. } => {
1247 perspt_core::PlanningPolicy::FeatureIncrement
1248 }
1249 WorkspaceState::Ambiguous => perspt_core::PlanningPolicy::FeatureIncrement,
1250 };
1251 }
1252
1253 if self.ledger.get_feature_charter().ok().flatten().is_none() {
1257 let mut charter = perspt_core::FeatureCharter::new(&self.context.session_id, &task);
1258 match self.planning_policy {
1259 perspt_core::PlanningPolicy::LocalEdit => {
1260 charter.max_modules = Some(1);
1261 charter.max_files = Some(5);
1262 charter.max_revisions = Some(3);
1263 }
1264 perspt_core::PlanningPolicy::FeatureIncrement => {
1265 charter.max_modules = Some(10);
1266 charter.max_files = Some(30);
1267 charter.max_revisions = Some(5);
1268 }
1269 perspt_core::PlanningPolicy::LargeFeature
1270 | perspt_core::PlanningPolicy::GreenfieldBuild
1271 | perspt_core::PlanningPolicy::ArchitecturalRevision => {
1272 charter.max_modules = Some(25);
1273 charter.max_files = Some(80);
1274 charter.max_revisions = Some(10);
1275 }
1276 }
1277 if let Some(ref lang) = self.context.active_plugins.first() {
1278 charter.language_constraint = Some(lang.to_string());
1279 }
1280 if let Err(e) = self.ledger.record_feature_charter(&charter) {
1281 log::warn!("Failed to persist default FeatureCharter: {}", e);
1282 } else {
1283 log::info!(
1284 "Registered default FeatureCharter (max_modules={:?}, max_files={:?})",
1285 charter.max_modules,
1286 charter.max_files
1287 );
1288 }
1289 }
1290
1291 if self.planning_policy.needs_architect() {
1294 self.step_sheafify(task).await?;
1295 } else {
1296 self.emit_log("📐 LocalEdit policy — skipping architect, single-node plan".to_string());
1297 self.create_deterministic_fallback_graph(&task)?;
1298 }
1299
1300 self.emit_log(format!("📐 Planning policy: {:?}", self.planning_policy));
1302
1303 let node_count = self.graph.node_count();
1305 self.emit_event(perspt_core::AgentEvent::PlanReady {
1306 nodes: node_count,
1307 plugins: self.context.active_plugins.clone(),
1308 execution_mode: execution_mode.to_string(),
1309 });
1310
1311 for node_id in self.node_indices.keys() {
1313 if let Some(idx) = self.node_indices.get(node_id) {
1314 if let Some(node) = self.graph.node_weight(*idx) {
1315 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1316 node_id: node.node_id.clone(),
1317 status: perspt_core::NodeStatus::Pending,
1318 });
1319 }
1320 }
1321 }
1322
1323 let topo = Topo::new(&self.graph);
1325 let indices: Vec<_> = topo.iter(&self.graph).collect();
1326 let total_nodes = indices.len();
1327 let mut completed_count: usize = 0;
1328 let mut escalated_count: usize = 0;
1329
1330 for (i, idx) in indices.iter().enumerate() {
1331 if self.is_abort_requested() {
1333 self.emit_log("⚠️ Session aborted — stopping execution".to_string());
1334 break;
1335 }
1336
1337 if self.budget.any_exhausted() {
1339 let node_id = self.graph[*idx].node_id.clone();
1340 self.emit_log(format!(
1341 "⛔ Budget exhausted — skipping node '{}' and remaining nodes",
1342 node_id
1343 ));
1344 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1345 node_id,
1346 status: perspt_core::NodeStatus::Escalated,
1347 });
1348 break;
1349 }
1350
1351 if self.check_seal_prerequisites(*idx) {
1356 log::warn!(
1357 "Node {} blocked on seal prerequisite — skipping in this iteration",
1358 self.graph[*idx].node_id
1359 );
1360 continue;
1361 }
1362
1363 if let Some(node) = self.graph.node_weight(*idx) {
1365 self.emit_log(format!("📝 [{}/{}] {}", i + 1, total_nodes, node.goal));
1366 self.emit_event(perspt_core::AgentEvent::NodeSelected {
1367 node_id: node.node_id.clone(),
1368 goal: node.goal.clone(),
1369 node_class: node.node_class.to_string(),
1370 });
1371 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1372 node_id: node.node_id.clone(),
1373 status: perspt_core::NodeStatus::Running,
1374 });
1375 }
1376
1377 match self.execute_node(*idx).await {
1378 Ok(NodeOutcome::Completed) => {
1379 completed_count += 1;
1380
1381 self.budget.record_step();
1383
1384 self.emit_event(perspt_core::AgentEvent::BudgetUpdated {
1386 steps_used: self.budget.steps_used,
1387 max_steps: self.budget.max_steps,
1388 cost_used_usd: self.budget.cost_used_usd,
1389 max_cost_usd: self.budget.max_cost_usd,
1390 revisions_used: self.budget.revisions_used,
1391 max_revisions: self.budget.max_revisions,
1392 });
1393
1394 if let Err(e) = self.ledger.upsert_budget_envelope(&self.budget) {
1396 log::warn!("Failed to persist budget envelope: {}", e);
1397 }
1398
1399 if let Some(node) = self.graph.node_weight(*idx) {
1401 self.emit_event(perspt_core::AgentEvent::NodeCompleted {
1402 node_id: node.node_id.clone(),
1403 goal: node.goal.clone(),
1404 });
1405 }
1406 }
1407 Ok(NodeOutcome::Escalated) => {
1408 escalated_count += 1;
1409 self.budget.record_step();
1410
1411 if let Some(node) = self.graph.node_weight(*idx) {
1413 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1414 node_id: node.node_id.clone(),
1415 status: perspt_core::NodeStatus::Escalated,
1416 });
1417 }
1418 continue;
1419 }
1420 Err(e) => {
1421 escalated_count += 1;
1422 let node_id = self.graph[*idx].node_id.clone();
1423 eprintln!("[SRBN-DIAG] Node {} failed: {:#}", node_id, e);
1424 log::error!("Node {} failed: {}", node_id, e);
1425 self.emit_log(format!("❌ Node {} failed: {}", node_id, e));
1426
1427 if let Some(bid) = self.graph[*idx].provisional_branch_id.clone() {
1432 self.flush_provisional_branch(&bid, &node_id);
1433 }
1434 self.flush_descendant_branches(*idx);
1435
1436 self.graph[*idx].state = NodeState::Escalated;
1437 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1438 node_id: node_id.clone(),
1439 status: perspt_core::NodeStatus::Escalated,
1440 });
1441 continue;
1443 }
1444 }
1445 }
1446
1447 log::info!("SRBN execution completed");
1448
1449 if let Err(e) = crate::tools::cleanup_session_sandboxes(
1451 &self.context.working_dir,
1452 &self.context.session_id,
1453 ) {
1454 log::warn!("Failed to clean up session sandboxes: {}", e);
1455 }
1456
1457 let outcome = if escalated_count == 0 && completed_count >= total_nodes {
1461 perspt_core::SessionOutcome::Success
1462 } else if completed_count > 0 {
1463 perspt_core::SessionOutcome::PartialSuccess
1464 } else {
1465 perspt_core::SessionOutcome::Failed
1466 };
1467 self.emit_event(perspt_core::AgentEvent::Complete {
1468 success: outcome == perspt_core::SessionOutcome::Success,
1469 message: format!(
1470 "{}/{} nodes completed, {} escalated",
1471 completed_count, total_nodes, escalated_count
1472 ),
1473 });
1474 Ok(outcome)
1475 }
1476
1477 async fn execute_node(&mut self, idx: NodeIndex) -> Result<NodeOutcome> {
1479 let node = &self.graph[idx];
1480 log::info!("Executing node: {} ({})", node.node_id, node.goal);
1481
1482 let branch_id = self.maybe_create_provisional_branch(idx);
1484
1485 self.graph[idx].state = NodeState::Coding;
1487 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1488 node_id: self.graph[idx].node_id.clone(),
1489 status: perspt_core::NodeStatus::Coding,
1490 });
1491
1492 let speculate_start = std::time::Instant::now();
1494 self.step_speculate(idx).await?;
1495 self.record_step_quietly(
1496 &self.graph[idx].node_id.clone(),
1497 "speculate",
1498 "ok",
1499 None,
1500 0,
1501 speculate_start.elapsed().as_millis() as i32,
1502 );
1503
1504 let verify_start = std::time::Instant::now();
1506 let mut energy = self.step_verify(idx).await?;
1507 self.record_step_quietly(
1508 &self.graph[idx].node_id.clone(),
1509 "verify",
1510 "ok",
1511 Some(&energy),
1512 0,
1513 verify_start.elapsed().as_millis() as i32,
1514 );
1515
1516 let mut sheaf_pre_check_retries = 0u32;
1522 let mut converge_start;
1523 loop {
1524 converge_start = std::time::Instant::now();
1526 if !self.step_converge(idx, energy.clone()).await? {
1527 self.record_step_quietly(
1528 &self.graph[idx].node_id.clone(),
1529 "converge",
1530 "escalated",
1531 Some(&energy),
1532 self.graph[idx].monitor.attempt_count as i32,
1533 converge_start.elapsed().as_millis() as i32,
1534 );
1535 let category = self.classify_non_convergence(idx);
1537 let action = self.choose_repair_action(idx, &category);
1538
1539 let node = &self.graph[idx];
1541 let report = EscalationReport {
1542 node_id: node.node_id.clone(),
1543 session_id: self.context.session_id.clone(),
1544 category,
1545 action: action.clone(),
1546 energy_snapshot: EnergyComponents {
1547 v_syn: node.monitor.current_energy(),
1548 ..Default::default()
1549 },
1550 stage_outcomes: self
1551 .last_verification_result
1552 .as_ref()
1553 .map(|vr| vr.stage_outcomes.clone())
1554 .unwrap_or_default(),
1555 evidence: self.build_escalation_evidence(idx),
1556 affected_node_ids: self.affected_dependents(idx),
1557 timestamp: epoch_seconds(),
1558 };
1559
1560 if let Err(e) = self.ledger.record_escalation_report(&report) {
1561 log::warn!("Failed to persist escalation report: {}", e);
1562 }
1563
1564 if let Some(bundle) = self.last_applied_bundle.take() {
1566 if let Err(e) = self
1567 .ledger
1568 .record_artifact_bundle(&self.graph[idx].node_id, &bundle)
1569 {
1570 log::warn!(
1571 "Failed to persist artifact bundle on escalation for {}: {}",
1572 self.graph[idx].node_id,
1573 e
1574 );
1575 }
1576 }
1577
1578 self.emit_event(perspt_core::AgentEvent::EscalationClassified {
1579 node_id: report.node_id.clone(),
1580 category: report.category.to_string(),
1581 action: report.action.to_string(),
1582 });
1583
1584 let node_id_for_flush = self.graph[idx].node_id.clone();
1586 if let Some(ref bid) = branch_id {
1587 self.flush_provisional_branch(bid, &node_id_for_flush);
1588 }
1589 self.flush_descendant_branches(idx);
1590
1591 let applied = self.apply_repair_action(idx, &action).await;
1593
1594 if !applied {
1595 self.graph[idx].state = NodeState::Escalated;
1596 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1597 node_id: self.graph[idx].node_id.clone(),
1598 status: perspt_core::NodeStatus::Escalated,
1599 });
1600 log::warn!(
1601 "Node {} escalated to user: {} → {}",
1602 self.graph[idx].node_id,
1603 category,
1604 action
1605 );
1606 }
1607
1608 return Ok(NodeOutcome::Escalated);
1609 }
1610
1611 if sheaf_pre_check_retries < 1 {
1614 if let Some(evidence) = self.sheaf_pre_check(idx) {
1615 sheaf_pre_check_retries += 1;
1616 log::warn!(
1617 "Sheaf pre-check failed for {}, retrying convergence: {}",
1618 self.graph[idx].node_id,
1619 evidence
1620 );
1621 self.emit_log(format!("⚠️ Sheaf pre-check: {}", evidence));
1622 self.context.last_test_output = Some(format!(
1624 "Structural pre-check failure: {}\nEnsure all declared output files are generated correctly.",
1625 evidence
1626 ));
1627 energy = self.step_verify(idx).await?;
1629 energy.v_sheaf += 2.0;
1630 continue;
1631 }
1632 }
1633 break;
1634 } if sheaf_pre_check_retries > 0 {
1640 if let Some(evidence) = self.sheaf_pre_check(idx) {
1641 log::warn!(
1642 "Sheaf pre-check still failing for {} after retry, escalating: {}",
1643 self.graph[idx].node_id,
1644 evidence
1645 );
1646 self.emit_log(format!(
1647 "❌ Sheaf pre-check failed after retry: {}",
1648 evidence
1649 ));
1650 self.graph[idx].state = NodeState::Escalated;
1651 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1652 node_id: self.graph[idx].node_id.clone(),
1653 status: perspt_core::NodeStatus::Escalated,
1654 });
1655 let node_id_for_flush = self.graph[idx].node_id.clone();
1657 if let Some(ref bid) = branch_id {
1658 self.flush_provisional_branch(bid, &node_id_for_flush);
1659 }
1660 self.flush_descendant_branches(idx);
1661 return Ok(NodeOutcome::Escalated);
1662 }
1663 }
1664
1665 self.record_step_quietly(
1667 &self.graph[idx].node_id.clone(),
1668 "converge",
1669 "ok",
1670 Some(&energy),
1671 self.graph[idx].monitor.attempt_count as i32,
1672 converge_start.elapsed().as_millis() as i32,
1673 );
1674
1675 let sheaf_start = std::time::Instant::now();
1677 self.step_sheaf_validate(idx).await?;
1678 self.record_step_quietly(
1679 &self.graph[idx].node_id.clone(),
1680 "sheaf_validate",
1681 "ok",
1682 None,
1683 0,
1684 sheaf_start.elapsed().as_millis() as i32,
1685 );
1686
1687 let commit_start = std::time::Instant::now();
1689 self.step_commit(idx).await?;
1690 self.record_step_quietly(
1691 &self.graph[idx].node_id.clone(),
1692 "commit",
1693 "ok",
1694 None,
1695 0,
1696 commit_start.elapsed().as_millis() as i32,
1697 );
1698
1699 if let Some(ref bid) = branch_id {
1701 self.merge_provisional_branch(bid, idx);
1702 }
1703
1704 Ok(NodeOutcome::Completed)
1705 }
1706
1707 async fn step_speculate(&mut self, idx: NodeIndex) -> Result<()> {
1709 log::info!("Step 3: Speculation - Generating implementation");
1710
1711 let retriever = ContextRetriever::new(self.effective_working_dir(idx))
1715 .with_max_file_bytes(8 * 1024)
1716 .with_max_context_bytes(100 * 1024); let node = &self.graph[idx];
1719 let mut restriction_map =
1720 retriever.build_restriction_map(node, &self.context.ownership_manifest);
1721
1722 self.inject_sealed_interfaces(idx, &mut restriction_map);
1727
1728 let node = &self.graph[idx];
1729 let context_package = retriever.assemble_context_package(node, &restriction_map);
1730 let formatted_context = retriever.format_context_package(&context_package);
1731
1732 let node = &self.graph[idx];
1735 let missing_owned: Vec<String> = restriction_map
1736 .owned_files
1737 .iter()
1738 .filter(|f| {
1739 !context_package.included_files.contains_key(*f)
1741 && !node
1742 .output_targets
1743 .iter()
1744 .any(|ot| ot.to_string_lossy() == **f)
1745 })
1746 .cloned()
1747 .collect();
1748
1749 if context_package.budget_exceeded || !missing_owned.is_empty() {
1750 let reason = if context_package.budget_exceeded && !missing_owned.is_empty() {
1751 format!(
1752 "Budget exceeded and {} owned file(s) missing",
1753 missing_owned.len()
1754 )
1755 } else if context_package.budget_exceeded {
1756 "Context budget exceeded; some files replaced with structural digests".to_string()
1757 } else {
1758 format!(
1759 "{} owned file(s) could not be read: {}",
1760 missing_owned.len(),
1761 missing_owned.join(", ")
1762 )
1763 };
1764
1765 log::warn!("Context degraded for node '{}': {}", node.node_id, reason);
1766 self.emit_log(format!("⚠️ Context degraded: {}", reason));
1767 self.emit_event(perspt_core::AgentEvent::ContextDegraded {
1768 node_id: node.node_id.clone(),
1769 budget_exceeded: context_package.budget_exceeded,
1770 missing_owned_files: missing_owned.clone(),
1771 included_file_count: context_package.included_files.len(),
1772 total_bytes: context_package.total_bytes,
1773 reason: reason.clone(),
1774 });
1775
1776 if !missing_owned.is_empty() {
1779 self.emit_event(perspt_core::AgentEvent::ContextBlocked {
1780 node_id: node.node_id.clone(),
1781 missing_owned_files: missing_owned,
1782 reason: reason.clone(),
1783 });
1784 self.graph[idx].state = NodeState::Escalated;
1785 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1786 node_id: self.graph[idx].node_id.clone(),
1787 status: perspt_core::NodeStatus::Escalated,
1788 });
1789 let err_msg = format!(
1790 "Context blocked for node '{}': {}. Node escalated.",
1791 self.graph[idx].node_id, reason
1792 );
1793 eprintln!("[SRBN-DIAG] {}", err_msg);
1794 return Err(anyhow::anyhow!(err_msg));
1795 }
1796 }
1797
1798 {
1801 let node = &self.graph[idx];
1802 let prose_only_deps = self.check_structural_dependencies(node, &restriction_map);
1803 if !prose_only_deps.is_empty() {
1804 for (dep_node_id, dep_reason) in &prose_only_deps {
1805 self.emit_event(perspt_core::AgentEvent::StructuralDependencyMissing {
1806 node_id: node.node_id.clone(),
1807 dependency_node_id: dep_node_id.clone(),
1808 reason: dep_reason.clone(),
1809 });
1810 }
1811 let dep_names: Vec<&str> =
1812 prose_only_deps.iter().map(|(id, _)| id.as_str()).collect();
1813 let block_reason = format!(
1814 "Required structural dependencies lack machine-verifiable digests (only prose summaries): [{}]",
1815 dep_names.join(", ")
1816 );
1817 eprintln!(
1818 "[SRBN-DIAG] Structural dependency check failed for '{}': {}",
1819 self.graph[idx].node_id, block_reason
1820 );
1821 self.emit_log(format!("🚫 {}", block_reason));
1822 self.graph[idx].state = NodeState::Escalated;
1823 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1824 node_id: self.graph[idx].node_id.clone(),
1825 status: perspt_core::NodeStatus::Escalated,
1826 });
1827 return Err(anyhow::anyhow!(
1828 "Structural dependency check failed for node '{}': {}",
1829 self.graph[idx].node_id,
1830 block_reason
1831 ));
1832 }
1833 }
1834
1835 self.last_context_provenance = Some(context_package.provenance());
1837 self.last_formatted_context = formatted_context.clone();
1839
1840 let speculator_hints = if self.planning_policy.needs_speculator() {
1845 let node_id = self.graph[idx].node_id.clone();
1846 let node_goal = self.graph[idx].goal.clone();
1847 let child_goals: Vec<String> = self
1848 .graph
1849 .edges(idx)
1850 .filter_map(|edge| {
1851 let child = &self.graph[edge.target()];
1852 if child.state == NodeState::TaskQueued {
1853 Some(format!("- {}: {}", child.node_id, child.goal))
1854 } else {
1855 None
1856 }
1857 })
1858 .collect();
1859
1860 if !child_goals.is_empty() {
1861 let ev = perspt_core::types::PromptEvidence {
1862 node_goal: Some(node_goal.clone()),
1863 context_files: vec![node_id.clone()],
1864 output_files: child_goals.clone(),
1865 ..Default::default()
1866 };
1867 let speculator_prompt = crate::prompt_compiler::compile(
1868 perspt_core::types::PromptIntent::SpeculatorLookahead,
1869 &ev,
1870 )
1871 .text;
1872
1873 log::debug!(
1874 "Speculator lookahead for node {} using model {}",
1875 node_id,
1876 self.speculator_model
1877 );
1878 self.call_llm_with_logging(
1879 &self.speculator_model.clone(),
1880 &speculator_prompt,
1881 Some(&node_id),
1882 )
1883 .await
1884 .unwrap_or_else(|e| {
1885 log::warn!(
1886 "Speculator lookahead failed ({}), proceeding without hints",
1887 e
1888 );
1889 String::new()
1890 })
1891 } else {
1892 String::new()
1893 }
1894 } else {
1895 String::new()
1896 };
1897
1898 let actuator = &self.agents[1];
1899 let node = &self.graph[idx];
1900 let node_id = node.node_id.clone();
1901
1902 let base_prompt = actuator.build_prompt(node, &self.context);
1904 let mut prompt = if formatted_context.is_empty() {
1905 base_prompt
1906 } else {
1907 format!(
1908 "{}\n\n## Node Context (PSP-5 Restriction Map)\n\n{}",
1909 base_prompt, formatted_context
1910 )
1911 };
1912
1913 if !speculator_hints.is_empty() {
1914 prompt = format!(
1915 "{}\n\n## Speculator Lookahead Hints\n\n{}",
1916 prompt, speculator_hints
1917 );
1918 }
1919
1920 let wd = self.effective_working_dir(idx);
1923 if let Ok(tree) = crate::tools::list_sandbox_files(&wd) {
1924 if !tree.is_empty() {
1925 prompt = format!(
1926 "{}\n\n## Current Project Tree\n\n```\n{}\n```",
1927 prompt,
1928 tree.join("\n")
1929 );
1930 }
1931 }
1932
1933 let model = actuator.model().to_string();
1934
1935 let response = self
1936 .call_llm_with_logging(&model, &prompt, Some(&node_id))
1937 .await?;
1938
1939 let message = crate::types::AgentMessage::new(crate::types::ModelTier::Actuator, response);
1940 let content = &message.content;
1941
1942 if let Some(command) = self.extract_command_from_response(content) {
1944 log::info!("Extracted command: {}", command);
1945 self.emit_log(format!("🔧 Command proposed: {}", command));
1946
1947 let node_id = self.graph[idx].node_id.clone();
1949 let approval_result = self
1950 .await_approval_for_node(
1951 perspt_core::ActionType::Command {
1952 command: command.clone(),
1953 },
1954 format!("Execute shell command: {}", command),
1955 None,
1956 Some(&node_id),
1957 )
1958 .await;
1959
1960 if !matches!(
1961 approval_result,
1962 ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
1963 ) {
1964 self.emit_log("⏭️ Command skipped (not approved)");
1965 return Ok(());
1966 }
1967
1968 let mut args = HashMap::new();
1970 args.insert("command".to_string(), command.clone());
1971
1972 let call = ToolCall {
1973 name: "run_command".to_string(),
1974 arguments: args,
1975 };
1976
1977 let result = self.tools.execute(&call).await;
1978 if result.success {
1979 log::info!("✓ Command succeeded: {}", command);
1980 self.emit_log(format!("✅ Command succeeded: {}", command));
1981 self.emit_log(result.output);
1982 } else {
1983 log::warn!("Command failed: {:?}", result.error);
1984 self.emit_log(format!("❌ Command failed: {:?}", result.error));
1985 }
1986 }
1987 else {
1989 let (bundle_opt, parse_state, record_opt) =
1990 self.parse_artifact_bundle_typed(content, &node_id, 0);
1991
1992 if let Some(ref record) = record_opt {
1993 log::info!(
1994 "PSP-7 initial gen: parse_state={}, accepted={}",
1995 record.parse_state,
1996 record.accepted
1997 );
1998 }
1999
2000 match parse_state {
2001 perspt_core::types::ParseResultState::StrictJsonOk
2002 | perspt_core::types::ParseResultState::TolerantRecoveryOk => {
2003 let bundle = bundle_opt.expect("Accepted parse must yield a bundle");
2004 let affected_files: Vec<String> = bundle
2005 .affected_paths()
2006 .into_iter()
2007 .map(ToString::to_string)
2008 .collect();
2009 log::info!(
2010 "Parsed artifact bundle for node {} ({}): {} artifacts, {} commands",
2011 node_id,
2012 parse_state,
2013 bundle.artifacts.len(),
2014 bundle.commands.len()
2015 );
2016 self.emit_log(format!(
2017 "📝 Bundle proposed: {} artifact(s) across {} file(s)",
2018 bundle.artifacts.len(),
2019 affected_files.len()
2020 ));
2021
2022 let approval_result = self
2023 .await_approval_for_node(
2024 perspt_core::ActionType::BundleWrite {
2025 node_id: node_id.clone(),
2026 files: affected_files.clone(),
2027 },
2028 format!("Apply bundle touching: {}", affected_files.join(", ")),
2029 serde_json::to_string_pretty(&bundle).ok(),
2030 Some(&node_id),
2031 )
2032 .await;
2033
2034 if !matches!(
2035 approval_result,
2036 ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
2037 ) {
2038 self.emit_log("⏭️ Bundle application skipped (not approved)");
2039 return Ok(());
2040 }
2041
2042 let node_class = self.graph[idx].node_class;
2043 match self
2044 .apply_bundle_transactionally(&bundle, &node_id, node_class)
2045 .await
2046 {
2047 Ok(()) => {
2048 self.last_tool_failure = None;
2049 self.last_applied_bundle = Some(bundle.clone());
2050 }
2051 Err(e) => return Err(e),
2052 }
2053
2054 let effective_commands = self
2056 .last_applied_bundle
2057 .as_ref()
2058 .map(|b| b.commands.clone())
2059 .unwrap_or_default();
2060 if !effective_commands.is_empty() {
2061 self.emit_log(format!(
2062 "🔧 Executing {} bundle command(s)...",
2063 effective_commands.len()
2064 ));
2065 let work_dir = self.effective_working_dir(idx);
2066 let is_python = self.graph[idx].owner_plugin == "python";
2067 for raw_command in &effective_commands {
2068 let command = if is_python {
2069 Self::normalize_command_to_uv(raw_command)
2070 } else {
2071 raw_command.clone()
2072 };
2073
2074 let cmd_approval = self
2075 .await_approval_for_node(
2076 perspt_core::ActionType::Command {
2077 command: command.clone(),
2078 },
2079 format!("Execute bundle command: {}", command),
2080 None,
2081 Some(&node_id),
2082 )
2083 .await;
2084
2085 if !matches!(
2086 cmd_approval,
2087 ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
2088 ) {
2089 self.emit_log(format!(
2090 "⏭️ Bundle command skipped (not approved): {}",
2091 command
2092 ));
2093 continue;
2094 }
2095
2096 let mut args = HashMap::new();
2097 args.insert("command".to_string(), command.clone());
2098 args.insert(
2099 "working_dir".to_string(),
2100 work_dir.to_string_lossy().to_string(),
2101 );
2102
2103 let call = ToolCall {
2104 name: "run_command".to_string(),
2105 arguments: args,
2106 };
2107
2108 let result = self.tools.execute(&call).await;
2109 if result.success {
2110 log::info!("✓ Bundle command succeeded: {}", command);
2111 self.emit_log(format!("✅ {}", command));
2112 if !result.output.is_empty() {
2113 let truncated: String =
2114 result.output.chars().take(500).collect();
2115 self.emit_log(truncated);
2116 }
2117 } else {
2118 let err_msg = result.error.unwrap_or_else(|| result.output.clone());
2119 log::warn!("Bundle command failed: {} — {}", command, err_msg);
2120 self.emit_log(format!(
2121 "❌ Command failed: {} — {}",
2122 command, err_msg
2123 ));
2124 self.last_tool_failure = Some(format!(
2125 "Bundle command '{}' failed: {}",
2126 command, err_msg
2127 ));
2128 }
2129 }
2130
2131 if is_python {
2132 log::info!("Running uv sync --dev after bundle commands...");
2133 let sync_result = tokio::process::Command::new("uv")
2134 .args(["sync", "--dev"])
2135 .current_dir(&work_dir)
2136 .stdout(std::process::Stdio::piped())
2137 .stderr(std::process::Stdio::piped())
2138 .output()
2139 .await;
2140 match sync_result {
2141 Ok(output) if output.status.success() => {
2142 self.emit_log("🐍 uv sync --dev completed".to_string());
2143 }
2144 Ok(output) => {
2145 let stderr = String::from_utf8_lossy(&output.stderr);
2146 log::warn!("uv sync --dev failed: {}", stderr);
2147 }
2148 Err(e) => {
2149 log::warn!("Failed to run uv sync --dev: {}", e);
2150 }
2151 }
2152 }
2153 }
2154 }
2155
2156 perspt_core::types::ParseResultState::SemanticallyRejected => {
2157 log::warn!(
2159 "Bundle for '{}' semantically rejected, retrying with retarget prompt",
2160 node_id
2161 );
2162 self.emit_log(format!(
2163 "🔄 Bundle for '{}' targeted wrong files — retrying...",
2164 node_id
2165 ));
2166
2167 let raw_paths: Vec<String> =
2168 perspt_core::normalize::extract_file_markers(content)
2169 .iter()
2170 .filter_map(|m| m.path.clone())
2171 .collect();
2172 let expected: Vec<String> = self.graph[idx]
2173 .output_targets
2174 .iter()
2175 .map(|p| p.to_string_lossy().to_string())
2176 .collect();
2177 let ev = perspt_core::types::PromptEvidence {
2178 output_files: expected.clone(),
2179 existing_file_contents: vec![(raw_paths.join(", "), prompt.clone())],
2180 ..Default::default()
2181 };
2182 let retry_prompt = crate::prompt_compiler::compile(
2183 perspt_core::types::PromptIntent::BundleRetarget,
2184 &ev,
2185 )
2186 .text;
2187
2188 let retry_response = self
2189 .call_llm_with_logging(&model, &retry_prompt, Some(&node_id))
2190 .await?;
2191
2192 let (retry_bundle_opt, retry_state, _) =
2193 self.parse_artifact_bundle_typed(&retry_response, &node_id, 1);
2194
2195 if let Some(retry_bundle) = retry_bundle_opt {
2196 let node_class = self.graph[idx].node_class;
2197 self.apply_bundle_transactionally(&retry_bundle, &node_id, node_class)
2198 .await?;
2199 self.last_tool_failure = None;
2200 self.last_applied_bundle = Some(retry_bundle);
2201 } else {
2202 return Err(anyhow::anyhow!(
2203 "Retry for '{}' did not produce a valid bundle ({})",
2204 node_id,
2205 retry_state
2206 ));
2207 }
2208 }
2209
2210 _ => {
2211 log::debug!(
2213 "No artifact bundle found in response ({}), response length: {}",
2214 parse_state,
2215 content.len()
2216 );
2217 self.emit_log("ℹ️ No file changes detected in response".to_string());
2218 }
2219 }
2220 }
2221
2222 self.context.history.push(message);
2223 Ok(())
2224 }
2225
2226 fn extract_command_from_response(&self, content: &str) -> Option<String> {
2229 for line in content.lines() {
2230 let trimmed = line.trim();
2231 if trimmed.starts_with("[COMMAND]") {
2232 return Some(trimmed.trim_start_matches("[COMMAND]").trim().to_string());
2233 }
2234 if trimmed.starts_with("$ ") || trimmed.starts_with("➜ ") {
2236 return Some(
2237 trimmed
2238 .trim_start_matches("$ ")
2239 .trim_start_matches("➜ ")
2240 .trim()
2241 .to_string(),
2242 );
2243 }
2244 }
2245 None
2246 }
2247
2248 pub fn session_id(&self) -> &str {
2254 &self.context.session_id
2255 }
2256
2257 pub fn node_count(&self) -> usize {
2259 self.graph.node_count()
2260 }
2261
2262 pub async fn start_lsp_for_plugins(&mut self, plugin_names: &[&str]) -> Result<()> {
2267 let registry = perspt_core::plugin::PluginRegistry::new();
2268
2269 for &name in plugin_names {
2270 if self.lsp_clients.contains_key(name) {
2271 log::debug!("LSP client already running for {}", name);
2272 continue;
2273 }
2274
2275 let plugin = match registry.get(name) {
2276 Some(p) => p,
2277 None => {
2278 log::warn!("No plugin found for '{}', skipping LSP startup", name);
2279 continue;
2280 }
2281 };
2282
2283 let profile = plugin.verifier_profile();
2284 let lsp_config = match profile.lsp.effective_config() {
2285 Some(cfg) => cfg.clone(),
2286 None => {
2287 log::warn!(
2288 "No available LSP for {} (primary and fallback unavailable)",
2289 name
2290 );
2291 continue;
2292 }
2293 };
2294
2295 log::info!(
2296 "Starting LSP for {}: {} {:?}",
2297 name,
2298 lsp_config.server_binary,
2299 lsp_config.args
2300 );
2301
2302 let mut client = LspClient::from_config(&lsp_config);
2303 match client
2304 .start_with_config(&lsp_config, &self.context.working_dir)
2305 .await
2306 {
2307 Ok(()) => {
2308 log::info!("{} LSP started successfully", name);
2309 self.lsp_clients.insert(name.to_string(), client);
2310 }
2311 Err(e) => {
2312 log::warn!(
2313 "Failed to start {} LSP: {} (continuing without it)",
2314 name,
2315 e
2316 );
2317 }
2318 }
2319 }
2320
2321 Ok(())
2322 }
2323
2324 fn lsp_key_for_file(&self, path: &str) -> Option<String> {
2329 let registry = perspt_core::plugin::PluginRegistry::new();
2330
2331 for plugin in registry.all() {
2333 if plugin.owns_file(path) {
2334 let name = plugin.name().to_string();
2335 if self.lsp_clients.contains_key(&name) {
2336 return Some(name);
2337 }
2338 }
2339 }
2340
2341 self.lsp_clients.keys().next().cloned()
2343 }
2344
2345 fn sandbox_dir_for_node(&self, idx: NodeIndex) -> Option<std::path::PathBuf> {
2356 let branch_id = self.graph[idx].provisional_branch_id.as_ref()?;
2357 let sandbox_path = self
2358 .context
2359 .working_dir
2360 .join(".perspt")
2361 .join("sandboxes")
2362 .join(&self.context.session_id)
2363 .join(branch_id);
2364 if sandbox_path.exists() {
2365 Some(sandbox_path)
2366 } else {
2367 None
2368 }
2369 }
2370
2371 fn sheaf_pre_check(&self, idx: NodeIndex) -> Option<String> {
2378 let node = &self.graph[idx];
2379 if node.output_targets.is_empty() {
2380 return None;
2381 }
2382
2383 let work_dir = self.effective_working_dir(idx);
2384 let mut issues = Vec::new();
2385
2386 for path in &node.output_targets {
2387 let full = work_dir.join(path);
2388 match std::fs::metadata(&full) {
2389 Ok(m) if m.len() == 0 => {
2390 issues.push(format!("empty: {}", path.display()));
2391 }
2392 Err(_) => {
2393 issues.push(format!("missing: {}", path.display()));
2394 }
2395 Ok(_) => {
2396 if let Some(reason) = detect_stub_content(&full, &node.owner_plugin) {
2398 issues.push(format!("stub content in {}: {}", path.display(), reason));
2399 }
2400 }
2401 }
2402 }
2403
2404 if issues.is_empty() {
2405 None
2406 } else {
2407 Some(format!("Output target issues: {}", issues.join(", ")))
2408 }
2409 }
2410
2411 fn effective_working_dir(&self, idx: NodeIndex) -> std::path::PathBuf {
2414 self.sandbox_dir_for_node(idx)
2415 .unwrap_or_else(|| self.context.working_dir.clone())
2416 }
2417
2418 fn maybe_create_provisional_branch(&mut self, idx: NodeIndex) -> Option<String> {
2421 let parents: Vec<NodeIndex> = self
2423 .graph
2424 .neighbors_directed(idx, petgraph::Direction::Incoming)
2425 .collect();
2426
2427 let node = &self.graph[idx];
2428 let node_id = node.node_id.clone();
2429 let session_id = self.context.session_id.clone();
2430
2431 let parent_node_id = if parents.is_empty() {
2434 "root".to_string()
2435 } else {
2436 self.graph[parents[0]].node_id.clone()
2437 };
2438
2439 let branch_id = format!("branch_{}_{}", node_id, uuid::Uuid::new_v4());
2440 let branch = ProvisionalBranch::new(
2441 branch_id.clone(),
2442 session_id.clone(),
2443 node_id.clone(),
2444 parent_node_id.clone(),
2445 );
2446
2447 if let Err(e) = self.ledger.record_provisional_branch(&branch) {
2449 log::warn!("Failed to record provisional branch: {}", e);
2450 }
2451
2452 for pidx in &parents {
2454 let parent_id = self.graph[*pidx].node_id.clone();
2455 let depends_on_seal = self.graph[*pidx].node_class == NodeClass::Interface;
2457 let lineage = perspt_core::types::BranchLineage {
2458 lineage_id: format!("lin_{}_{}", branch_id, parent_id),
2459 parent_branch_id: parent_id,
2460 child_branch_id: branch_id.clone(),
2461 depends_on_seal,
2462 };
2463 if let Err(e) = self.ledger.record_branch_lineage(&lineage) {
2464 log::warn!("Failed to record branch lineage: {}", e);
2465 }
2466 }
2467
2468 self.graph[idx].provisional_branch_id = Some(branch_id.clone());
2470
2471 match crate::tools::create_sandbox(&self.context.working_dir, &session_id, &branch_id) {
2474 Ok(sandbox_path) => {
2475 log::debug!("Sandbox created at {}", sandbox_path.display());
2476
2477 let plugin_refs: Vec<&str> = self
2480 .context
2481 .active_plugins
2482 .iter()
2483 .map(|s| s.as_str())
2484 .collect();
2485 if let Err(e) = crate::tools::seed_sandbox_manifests(
2486 &self.context.working_dir,
2487 &sandbox_path,
2488 &plugin_refs,
2489 ) {
2490 log::warn!("Failed to seed sandbox manifests: {}", e);
2491 }
2492
2493 let node = &self.graph[idx];
2496 for target in &node.output_targets {
2497 if let Some(rel) = target.to_str() {
2498 if let Err(e) = crate::tools::copy_to_sandbox(
2499 &self.context.working_dir,
2500 &sandbox_path,
2501 rel,
2502 ) {
2503 log::debug!("Could not seed sandbox with {}: {}", rel, e);
2504 }
2505 }
2506 }
2507 let mut ancestor_queue: Vec<NodeIndex> = parents.clone();
2513 let mut visited = std::collections::HashSet::new();
2514 while let Some(ancestor_idx) = ancestor_queue.pop() {
2515 if !visited.insert(ancestor_idx) {
2516 continue;
2517 }
2518 for target in &self.graph[ancestor_idx].output_targets {
2519 if let Some(rel) = target.to_str() {
2520 if let Err(e) = crate::tools::copy_to_sandbox(
2521 &self.context.working_dir,
2522 &sandbox_path,
2523 rel,
2524 ) {
2525 log::debug!(
2526 "Could not seed sandbox with ancestor file {}: {}",
2527 rel,
2528 e
2529 );
2530 }
2531 }
2532 }
2533 for grandparent in self
2535 .graph
2536 .neighbors_directed(ancestor_idx, petgraph::Direction::Incoming)
2537 {
2538 ancestor_queue.push(grandparent);
2539 }
2540 }
2541 }
2542 Err(e) => {
2543 log::warn!("Failed to create sandbox for branch {}: {}", branch_id, e);
2544 }
2545 }
2546
2547 self.emit_event(perspt_core::AgentEvent::BranchCreated {
2548 branch_id: branch_id.clone(),
2549 node_id,
2550 parent_node_id,
2551 });
2552 log::info!("Created provisional branch {} for node", branch_id);
2553
2554 Some(branch_id)
2555 }
2556
2557 fn merge_provisional_branch(&mut self, branch_id: &str, idx: NodeIndex) {
2559 let node_id = self.graph[idx].node_id.clone();
2560 if let Err(e) = self
2561 .ledger
2562 .update_branch_state(branch_id, &ProvisionalBranchState::Merged.to_string())
2563 {
2564 log::warn!("Failed to merge branch {}: {}", branch_id, e);
2565 }
2566
2567 let sandbox_path = self
2569 .context
2570 .working_dir
2571 .join(".perspt")
2572 .join("sandboxes")
2573 .join(&self.context.session_id)
2574 .join(branch_id);
2575 if let Err(e) = crate::tools::cleanup_sandbox(&sandbox_path) {
2576 log::warn!(
2577 "Failed to cleanup sandbox for merged branch {}: {}",
2578 branch_id,
2579 e
2580 );
2581 }
2582
2583 self.emit_event(perspt_core::AgentEvent::BranchMerged {
2584 branch_id: branch_id.to_string(),
2585 node_id,
2586 });
2587 log::info!("Merged provisional branch {}", branch_id);
2588 }
2589
2590 fn flush_provisional_branch(&mut self, branch_id: &str, node_id: &str) {
2592 if let Err(e) = self
2593 .ledger
2594 .update_branch_state(branch_id, &ProvisionalBranchState::Flushed.to_string())
2595 {
2596 log::warn!("Failed to flush branch {}: {}", branch_id, e);
2597 }
2598
2599 let sandbox_path = self
2601 .context
2602 .working_dir
2603 .join(".perspt")
2604 .join("sandboxes")
2605 .join(&self.context.session_id)
2606 .join(branch_id);
2607 if let Err(e) = crate::tools::cleanup_sandbox(&sandbox_path) {
2608 log::warn!(
2609 "Failed to cleanup sandbox for flushed branch {}: {}",
2610 branch_id,
2611 e
2612 );
2613 }
2614
2615 log::info!(
2616 "Flushed provisional branch {} for node {}",
2617 branch_id,
2618 node_id
2619 );
2620 }
2621
2622 fn flush_descendant_branches(&mut self, idx: NodeIndex) {
2628 let parent_node_id = self.graph[idx].node_id.clone();
2629 let session_id = self.context.session_id.clone();
2630
2631 let descendant_indices = self.collect_descendants(idx);
2633
2634 let mut flushed_branch_ids = Vec::new();
2635 let mut requeue_node_ids = Vec::new();
2636
2637 for desc_idx in &descendant_indices {
2638 let desc_node = &self.graph[*desc_idx];
2639 if let Some(ref bid) = desc_node.provisional_branch_id {
2640 let bid_clone = bid.clone();
2642 let nid_clone = desc_node.node_id.clone();
2643 self.flush_provisional_branch(&bid_clone, &nid_clone);
2644 flushed_branch_ids.push(bid_clone);
2645 requeue_node_ids.push(nid_clone);
2646 }
2647 }
2648
2649 if flushed_branch_ids.is_empty() {
2650 return;
2651 }
2652
2653 let flush_record = perspt_core::types::BranchFlushRecord::new(
2655 &session_id,
2656 &parent_node_id,
2657 flushed_branch_ids.clone(),
2658 requeue_node_ids.clone(),
2659 format!(
2660 "Parent node {} failed verification/convergence",
2661 parent_node_id
2662 ),
2663 );
2664 if let Err(e) = self.ledger.record_branch_flush(&flush_record) {
2665 log::warn!("Failed to record branch flush: {}", e);
2666 }
2667
2668 self.emit_event(perspt_core::AgentEvent::BranchFlushed {
2669 parent_node_id: parent_node_id.clone(),
2670 flushed_branch_ids,
2671 reason: format!("Parent {} failed", parent_node_id),
2672 });
2673
2674 log::info!(
2675 "Flushed {} descendant branches for parent {}; {} nodes eligible for requeue",
2676 flush_record.flushed_branch_ids.len(),
2677 parent_node_id,
2678 requeue_node_ids.len(),
2679 );
2680 }
2681
2682 fn collect_descendants(&self, idx: NodeIndex) -> Vec<NodeIndex> {
2685 let mut descendants = Vec::new();
2686 let mut stack = vec![idx];
2687 let mut visited = std::collections::HashSet::new();
2688 visited.insert(idx);
2689
2690 while let Some(current) = stack.pop() {
2691 for child in self
2692 .graph
2693 .neighbors_directed(current, petgraph::Direction::Outgoing)
2694 {
2695 if visited.insert(child) {
2696 descendants.push(child);
2697 stack.push(child);
2698 }
2699 }
2700 }
2701 descendants
2702 }
2703
2704 fn emit_interface_seals(&mut self, idx: NodeIndex) {
2710 let node = &self.graph[idx];
2711 if node.node_class != NodeClass::Interface {
2712 return;
2713 }
2714
2715 let node_id = node.node_id.clone();
2716 let session_id = self.context.session_id.clone();
2717 let output_targets: Vec<_> = node.output_targets.clone();
2718 let mut sealed_paths = Vec::new();
2719 let mut seal_hash = [0u8; 32];
2720
2721 let retriever = ContextRetriever::new(self.context.working_dir.clone());
2722
2723 for target in &output_targets {
2724 let path_str = target.to_string_lossy().to_string();
2725 match retriever.compute_structural_digest(
2726 &path_str,
2727 perspt_core::types::ArtifactKind::InterfaceSeal,
2728 &node_id,
2729 ) {
2730 Ok(digest) => {
2731 let seal = perspt_core::types::InterfaceSealRecord::from_digest(
2732 &session_id,
2733 &node_id,
2734 &digest,
2735 );
2736 seal_hash = seal.seal_hash;
2737 sealed_paths.push(path_str);
2738
2739 if let Err(e) = self.ledger.record_interface_seal(&seal) {
2740 log::warn!("Failed to record interface seal: {}", e);
2741 }
2742 }
2743 Err(e) => {
2744 log::debug!("Skipping seal for {}: {}", path_str, e);
2745 }
2746 }
2747 }
2748
2749 if !sealed_paths.is_empty() {
2750 self.graph[idx].interface_seal_hash = Some(seal_hash);
2752
2753 self.emit_event(perspt_core::AgentEvent::InterfaceSealed {
2754 node_id: node_id.clone(),
2755 sealed_paths: sealed_paths.clone(),
2756 seal_hash: seal_hash
2757 .iter()
2758 .map(|b| format!("{:02x}", b))
2759 .collect::<String>(),
2760 });
2761 log::info!(
2762 "Sealed {} interface artifact(s) for node {}",
2763 sealed_paths.len(),
2764 node_id
2765 );
2766 }
2767 }
2768
2769 fn unblock_dependents(&mut self, idx: NodeIndex) {
2771 let node_id = self.graph[idx].node_id.clone();
2772
2773 let (unblocked, remaining): (Vec<_>, Vec<_>) = self
2775 .blocked_dependencies
2776 .drain(..)
2777 .partition(|dep| dep.parent_node_id == node_id);
2778
2779 self.blocked_dependencies = remaining;
2780
2781 for dep in unblocked {
2782 self.emit_event(perspt_core::AgentEvent::DependentUnblocked {
2783 child_node_id: dep.child_node_id.clone(),
2784 parent_node_id: node_id.clone(),
2785 });
2786 log::info!(
2787 "Unblocked dependent {} (parent {} sealed)",
2788 dep.child_node_id,
2789 node_id
2790 );
2791 }
2792 }
2793
2794 fn check_seal_prerequisites(&mut self, idx: NodeIndex) -> bool {
2797 let parents: Vec<NodeIndex> = self
2798 .graph
2799 .neighbors_directed(idx, petgraph::Direction::Incoming)
2800 .collect();
2801
2802 for pidx in parents {
2803 let parent = &self.graph[pidx];
2804 if parent.node_class == NodeClass::Interface
2805 && parent.interface_seal_hash.is_none()
2806 && parent.state != NodeState::Completed
2807 {
2808 let child_node_id = self.graph[idx].node_id.clone();
2810 let parent_node_id = parent.node_id.clone();
2811 let sealed_paths: Vec<String> = parent
2812 .output_targets
2813 .iter()
2814 .map(|p| p.to_string_lossy().to_string())
2815 .collect();
2816
2817 let dep = perspt_core::types::BlockedDependency::new(
2818 &child_node_id,
2819 &parent_node_id,
2820 sealed_paths,
2821 );
2822 self.blocked_dependencies.push(dep);
2823
2824 log::info!(
2825 "Node {} blocked: waiting on interface seal from {}",
2826 child_node_id,
2827 parent_node_id
2828 );
2829 return true;
2830 }
2831 }
2832 false
2833 }
2834
2835 fn check_structural_dependencies(
2841 &self,
2842 node: &SRBNNode,
2843 restriction_map: &perspt_core::types::RestrictionMap,
2844 ) -> Vec<(String, String)> {
2845 use perspt_core::types::{ArtifactKind, NodeClass};
2846
2847 let mut prose_only = Vec::new();
2848
2849 if node.node_class != NodeClass::Implementation {
2851 return prose_only;
2852 }
2853
2854 let idx = match self.node_indices.get(&node.node_id) {
2856 Some(i) => *i,
2857 None => return prose_only,
2858 };
2859
2860 let parents: Vec<NodeIndex> = self
2861 .graph
2862 .neighbors_directed(idx, petgraph::Direction::Incoming)
2863 .collect();
2864
2865 for pidx in parents {
2866 let parent = &self.graph[pidx];
2867 if parent.node_class != NodeClass::Interface {
2868 continue;
2869 }
2870
2871 let has_structural = restriction_map.structural_digests.iter().any(|d| {
2873 d.source_node_id == parent.node_id
2874 && matches!(
2875 d.artifact_kind,
2876 ArtifactKind::Signature
2877 | ArtifactKind::Schema
2878 | ArtifactKind::InterfaceSeal
2879 )
2880 });
2881
2882 if !has_structural {
2883 prose_only.push((
2884 parent.node_id.clone(),
2885 format!(
2886 "Interface node '{}' has no Signature/Schema/InterfaceSeal digest in the restriction map",
2887 parent.node_id
2888 ),
2889 ));
2890 }
2891 }
2892
2893 prose_only
2894 }
2895
2896 fn inject_sealed_interfaces(
2903 &self,
2904 idx: NodeIndex,
2905 restriction_map: &mut perspt_core::types::RestrictionMap,
2906 ) {
2907 let parents: Vec<NodeIndex> = self
2908 .graph
2909 .neighbors_directed(idx, petgraph::Direction::Incoming)
2910 .collect();
2911
2912 for pidx in parents {
2913 let parent = &self.graph[pidx];
2914 if parent.interface_seal_hash.is_none() {
2915 continue;
2916 }
2917
2918 let parent_node_id = &parent.node_id;
2919
2920 let seals = match self.ledger.get_interface_seals(parent_node_id) {
2922 Ok(rows) => rows,
2923 Err(e) => {
2924 log::debug!("Could not query seals for {}: {}", parent_node_id, e);
2925 continue;
2926 }
2927 };
2928
2929 for seal in seals {
2930 restriction_map
2932 .sealed_interfaces
2933 .retain(|p| *p != seal.sealed_path);
2934
2935 let mut hash = [0u8; 32];
2937 let len = seal.seal_hash.len().min(32);
2938 hash[..len].copy_from_slice(&seal.seal_hash[..len]);
2939
2940 let digest = perspt_core::types::StructuralDigest {
2942 digest_id: format!("seal_{}_{}", seal.node_id, seal.sealed_path),
2943 source_node_id: seal.node_id.clone(),
2944 source_path: seal.sealed_path.clone(),
2945 artifact_kind: perspt_core::types::ArtifactKind::InterfaceSeal,
2946 hash,
2947 version: seal.version as u32,
2948 };
2949 restriction_map.structural_digests.push(digest);
2950
2951 log::debug!(
2952 "Injected sealed digest for {} from parent {}",
2953 seal.sealed_path,
2954 parent_node_id,
2955 );
2956 }
2957 }
2958 }
2959}
2960
2961fn parse_node_state(s: &str) -> NodeState {
2963 NodeState::from_display_str(s)
2964}
2965
2966fn parse_node_class(s: &str) -> NodeClass {
2968 match s {
2969 "Interface" => NodeClass::Interface,
2970 "Implementation" => NodeClass::Implementation,
2971 "Integration" => NodeClass::Integration,
2972 _ => NodeClass::default(),
2973 }
2974}
2975
2976#[cfg(test)]
2977mod tests {
2978 use super::verification::verification_stages_for_node;
2979 use super::*;
2980 use std::path::PathBuf;
2981
2982 #[tokio::test]
2983 async fn test_orchestrator_creation() {
2984 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
2985 assert_eq!(orch.node_count(), 0);
2986 }
2987
2988 #[tokio::test]
2989 async fn test_add_nodes() {
2990 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
2991
2992 let node1 = SRBNNode::new(
2993 "node1".to_string(),
2994 "Test task 1".to_string(),
2995 ModelTier::Architect,
2996 );
2997 let node2 = SRBNNode::new(
2998 "node2".to_string(),
2999 "Test task 2".to_string(),
3000 ModelTier::Actuator,
3001 );
3002
3003 orch.add_node(node1);
3004 orch.add_node(node2);
3005 orch.add_dependency("node1", "node2", "depends_on").unwrap();
3006
3007 assert_eq!(orch.node_count(), 2);
3008 }
3009 #[tokio::test]
3010 async fn test_lsp_key_for_file_resolves_by_plugin() {
3011 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3012 orch.lsp_clients.insert(
3014 "rust".to_string(),
3015 crate::lsp::LspClient::new("rust-analyzer"),
3016 );
3017 orch.lsp_clients
3018 .insert("python".to_string(), crate::lsp::LspClient::new("pylsp"));
3019
3020 assert_eq!(
3022 orch.lsp_key_for_file("src/main.rs"),
3023 Some("rust".to_string())
3024 );
3025 assert_eq!(orch.lsp_key_for_file("app.py"), Some("python".to_string()));
3027 let key = orch.lsp_key_for_file("data.csv");
3029 assert!(key.is_some()); }
3031
3032 #[tokio::test]
3037 async fn test_split_node_creates_children() {
3038 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3039 let mut node = SRBNNode::new("parent".into(), "Do everything".into(), ModelTier::Actuator);
3040 node.output_targets = vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")];
3041 orch.add_node(node);
3042
3043 let idx = orch.node_indices["parent"];
3044 let applied = orch.split_node(idx, &["handle a.rs".into(), "handle b.rs".into()]);
3045 assert!(!applied.is_empty());
3046 assert!(!orch.node_indices.contains_key("parent"));
3048 assert!(orch.node_indices.contains_key("parent__split_0"));
3050 assert!(orch.node_indices.contains_key("parent__split_1"));
3051 }
3052
3053 #[tokio::test]
3054 async fn test_split_node_empty_children_is_noop() {
3055 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3056 let node = SRBNNode::new("n".into(), "g".into(), ModelTier::Actuator);
3057 orch.add_node(node);
3058 let idx = orch.node_indices["n"];
3059 let applied = orch.split_node(idx, &[]);
3060 assert!(applied.is_empty());
3062 }
3063
3064 #[tokio::test]
3065 async fn test_insert_interface_node() {
3066 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3067 let n1 = SRBNNode::new("a".into(), "source".into(), ModelTier::Actuator);
3068 let n2 = SRBNNode::new("b".into(), "dest".into(), ModelTier::Actuator);
3069 orch.add_node(n1);
3070 orch.add_node(n2);
3071 orch.add_dependency("a", "b", "data_flow").unwrap();
3072
3073 let idx_a = orch.node_indices["a"];
3074 let applied = orch.insert_interface_node(idx_a, "API boundary");
3075 assert!(applied.is_some());
3076 assert!(orch.node_indices.contains_key("a__iface"));
3077 assert_eq!(orch.node_count(), 3);
3079 }
3080
3081 #[tokio::test]
3082 async fn test_replan_subgraph_resets_nodes() {
3083 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3084 let mut n1 = SRBNNode::new("trigger".into(), "g1".into(), ModelTier::Actuator);
3085 n1.state = NodeState::Coding;
3086 let mut n2 = SRBNNode::new("dep".into(), "g2".into(), ModelTier::Actuator);
3087 n2.state = NodeState::Completed;
3088 orch.add_node(n1);
3089 orch.add_node(n2);
3090
3091 let trigger_idx = orch.node_indices["trigger"];
3092 let applied = orch.replan_subgraph(trigger_idx, &["dep".into()]);
3093 assert!(applied);
3094
3095 let dep_idx = orch.node_indices["dep"];
3096 assert_eq!(orch.graph[dep_idx].state, NodeState::TaskQueued);
3097 assert_eq!(orch.graph[trigger_idx].state, NodeState::Retry);
3098 }
3099
3100 #[tokio::test]
3101 async fn test_select_validators_always_includes_dependency_graph() {
3102 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3103 let node = SRBNNode::new("n".into(), "g".into(), ModelTier::Actuator);
3104 orch.add_node(node);
3105 let idx = orch.node_indices["n"];
3106
3107 let validators = orch.select_validators(idx);
3108 assert!(validators.contains(&SheafValidatorClass::DependencyGraphConsistency));
3109 }
3110
3111 #[tokio::test]
3112 async fn test_select_validators_interface_node() {
3113 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3114 let mut node = SRBNNode::new("iface".into(), "g".into(), ModelTier::Actuator);
3115 node.node_class = perspt_core::types::NodeClass::Interface;
3116 orch.add_node(node);
3117 let idx = orch.node_indices["iface"];
3118
3119 let validators = orch.select_validators(idx);
3120 assert!(validators.contains(&SheafValidatorClass::ExportImportConsistency));
3121 }
3122
3123 #[tokio::test]
3124 async fn test_run_sheaf_validator_dependency_graph_no_cycles() {
3125 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3126 let n1 = SRBNNode::new("a".into(), "g".into(), ModelTier::Actuator);
3127 let n2 = SRBNNode::new("b".into(), "g".into(), ModelTier::Actuator);
3128 orch.add_node(n1);
3129 orch.add_node(n2);
3130 orch.add_dependency("a", "b", "dep").unwrap();
3131
3132 let idx = orch.node_indices["a"];
3133 let result = orch.run_sheaf_validator(idx, SheafValidatorClass::DependencyGraphConsistency);
3134 assert!(result.passed);
3135 assert_eq!(result.v_sheaf_contribution, 0.0);
3136 }
3137
3138 #[tokio::test]
3139 async fn test_classify_non_convergence_default() {
3140 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3141 let node = SRBNNode::new("n".into(), "g".into(), ModelTier::Actuator);
3142 orch.add_node(node);
3143 let idx = orch.node_indices["n"];
3144
3145 let category = orch.classify_non_convergence(idx);
3147 assert_eq!(category, EscalationCategory::ImplementationError);
3148 }
3149
3150 #[tokio::test]
3151 async fn test_affected_dependents() {
3152 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3153 let n1 = SRBNNode::new("root".into(), "g".into(), ModelTier::Actuator);
3154 let n2 = SRBNNode::new("child1".into(), "g".into(), ModelTier::Actuator);
3155 let n3 = SRBNNode::new("child2".into(), "g".into(), ModelTier::Actuator);
3156 orch.add_node(n1);
3157 orch.add_node(n2);
3158 orch.add_node(n3);
3159 orch.add_dependency("root", "child1", "dep").unwrap();
3160 orch.add_dependency("root", "child2", "dep").unwrap();
3161
3162 let idx = orch.node_indices["root"];
3163 let deps = orch.affected_dependents(idx);
3164 assert_eq!(deps.len(), 2);
3165 assert!(deps.contains(&"child1".to_string()));
3166 assert!(deps.contains(&"child2".to_string()));
3167 }
3168
3169 #[tokio::test]
3174 async fn test_maybe_create_provisional_branch_root_node() {
3175 let temp_dir =
3176 std::env::temp_dir().join(format!("perspt_root_branch_{}", uuid::Uuid::new_v4()));
3177 std::fs::create_dir_all(&temp_dir).unwrap();
3178
3179 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
3180 orch.context.session_id = "test_session".into();
3181 let node = SRBNNode::new("root".into(), "root goal".into(), ModelTier::Actuator);
3182 orch.add_node(node);
3183
3184 let idx = orch.node_indices["root"];
3185 let branch = orch.maybe_create_provisional_branch(idx);
3187 assert!(branch.is_some());
3188 assert!(orch.graph[idx].provisional_branch_id.is_some());
3189
3190 let _ = std::fs::remove_dir_all(&temp_dir);
3191 }
3192
3193 #[tokio::test]
3194 async fn test_maybe_create_provisional_branch_child_node() {
3195 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_phase6"));
3196 orch.context.session_id = "test_session".into();
3197 let parent = SRBNNode::new("parent".into(), "parent goal".into(), ModelTier::Actuator);
3198 let child = SRBNNode::new("child".into(), "child goal".into(), ModelTier::Actuator);
3199 orch.add_node(parent);
3200 orch.add_node(child);
3201 orch.add_dependency("parent", "child", "dep").unwrap();
3202
3203 let idx = orch.node_indices["child"];
3204 let branch = orch.maybe_create_provisional_branch(idx);
3205 assert!(branch.is_some());
3206 assert!(orch.graph[idx].provisional_branch_id.is_some());
3207 }
3208
3209 #[tokio::test]
3210 async fn test_collect_descendants() {
3211 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3212 let n1 = SRBNNode::new("a".into(), "g".into(), ModelTier::Actuator);
3213 let n2 = SRBNNode::new("b".into(), "g".into(), ModelTier::Actuator);
3214 let n3 = SRBNNode::new("c".into(), "g".into(), ModelTier::Actuator);
3215 let n4 = SRBNNode::new("d".into(), "g".into(), ModelTier::Actuator);
3216 orch.add_node(n1);
3217 orch.add_node(n2);
3218 orch.add_node(n3);
3219 orch.add_node(n4);
3220 orch.add_dependency("a", "b", "dep").unwrap();
3221 orch.add_dependency("b", "c", "dep").unwrap();
3222 orch.add_dependency("a", "d", "dep").unwrap();
3223
3224 let idx_a = orch.node_indices["a"];
3225 let descendants = orch.collect_descendants(idx_a);
3226 assert_eq!(descendants.len(), 3); }
3228
3229 #[tokio::test]
3230 async fn test_check_seal_prerequisites_no_interface_parent() {
3231 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3232 let parent = SRBNNode::new("parent".into(), "g".into(), ModelTier::Actuator);
3233 let child = SRBNNode::new("child".into(), "g".into(), ModelTier::Actuator);
3234 orch.add_node(parent);
3235 orch.add_node(child);
3236 orch.add_dependency("parent", "child", "dep").unwrap();
3237
3238 let idx = orch.node_indices["child"];
3239 assert!(!orch.check_seal_prerequisites(idx));
3241 assert!(orch.blocked_dependencies.is_empty());
3242 }
3243
3244 #[tokio::test]
3245 async fn test_check_seal_prerequisites_unsealed_interface() {
3246 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3247 let mut parent = SRBNNode::new("iface".into(), "g".into(), ModelTier::Actuator);
3248 parent.node_class = perspt_core::types::NodeClass::Interface;
3249 let child = SRBNNode::new("impl".into(), "g".into(), ModelTier::Actuator);
3250 orch.add_node(parent);
3251 orch.add_node(child);
3252 orch.add_dependency("iface", "impl", "dep").unwrap();
3253
3254 let idx = orch.node_indices["impl"];
3255 assert!(orch.check_seal_prerequisites(idx));
3257 assert_eq!(orch.blocked_dependencies.len(), 1);
3258 assert_eq!(orch.blocked_dependencies[0].parent_node_id, "iface");
3259 }
3260
3261 #[tokio::test]
3262 async fn test_check_seal_prerequisites_sealed_interface() {
3263 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3264 let mut parent = SRBNNode::new("iface".into(), "g".into(), ModelTier::Actuator);
3265 parent.node_class = perspt_core::types::NodeClass::Interface;
3266 parent.interface_seal_hash = Some([1u8; 32]); let child = SRBNNode::new("impl".into(), "g".into(), ModelTier::Actuator);
3268 orch.add_node(parent);
3269 orch.add_node(child);
3270 orch.add_dependency("iface", "impl", "dep").unwrap();
3271
3272 let idx = orch.node_indices["impl"];
3273 assert!(!orch.check_seal_prerequisites(idx));
3275 assert!(orch.blocked_dependencies.is_empty());
3276 }
3277
3278 #[tokio::test]
3279 async fn test_unblock_dependents() {
3280 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3281 let parent = SRBNNode::new("parent".into(), "g".into(), ModelTier::Actuator);
3282 let child = SRBNNode::new("child".into(), "g".into(), ModelTier::Actuator);
3283 orch.add_node(parent);
3284 orch.add_node(child);
3285
3286 orch.blocked_dependencies
3288 .push(perspt_core::types::BlockedDependency::new(
3289 "child",
3290 "parent",
3291 vec!["src/api.rs".into()],
3292 ));
3293 assert_eq!(orch.blocked_dependencies.len(), 1);
3294
3295 let idx = orch.node_indices["parent"];
3296 orch.unblock_dependents(idx);
3297 assert!(orch.blocked_dependencies.is_empty());
3298 }
3299
3300 #[tokio::test]
3301 async fn test_flush_descendant_branches() {
3302 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_phase6_flush"));
3303 orch.context.session_id = "test_session".into();
3304
3305 let parent = SRBNNode::new("parent".into(), "g".into(), ModelTier::Actuator);
3306 let mut child1 = SRBNNode::new("child1".into(), "g".into(), ModelTier::Actuator);
3307 child1.provisional_branch_id = Some("branch_c1".into());
3308 let mut child2 = SRBNNode::new("child2".into(), "g".into(), ModelTier::Actuator);
3309 child2.provisional_branch_id = Some("branch_c2".into());
3310 let grandchild = SRBNNode::new("grandchild".into(), "g".into(), ModelTier::Actuator);
3311 orch.add_node(parent);
3312 orch.add_node(child1);
3313 orch.add_node(child2);
3314 orch.add_node(grandchild);
3315 orch.add_dependency("parent", "child1", "dep").unwrap();
3316 orch.add_dependency("parent", "child2", "dep").unwrap();
3317 orch.add_dependency("child1", "grandchild", "dep").unwrap();
3318
3319 let idx = orch.node_indices["parent"];
3320 orch.flush_descendant_branches(idx);
3323 }
3324
3325 #[tokio::test]
3330 async fn test_effective_working_dir_no_branch() {
3331 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/test/workspace"));
3332 let mut orch = orch;
3334 let node = SRBNNode::new("n1".into(), "goal".into(), ModelTier::Actuator);
3335 orch.add_node(node);
3336 let idx = orch.node_indices["n1"];
3337 assert_eq!(
3339 orch.effective_working_dir(idx),
3340 PathBuf::from("/test/workspace")
3341 );
3342 }
3343
3344 #[tokio::test]
3345 async fn test_sandbox_dir_for_node_none_without_branch() {
3346 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/test/workspace"));
3347 let mut orch = orch;
3348 let node = SRBNNode::new("n1".into(), "goal".into(), ModelTier::Actuator);
3349 orch.add_node(node);
3350 let idx = orch.node_indices["n1"];
3351 assert!(orch.sandbox_dir_for_node(idx).is_none());
3352 }
3353
3354 #[tokio::test]
3355 async fn test_rewrite_churn_guardrail() {
3356 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_churn"));
3357 let mut orch = orch;
3358 let node = SRBNNode::new("node_a".into(), "goal".into(), ModelTier::Actuator);
3359 orch.add_node(node);
3360 let count = orch.count_lineage_rewrites("node_a");
3362 assert_eq!(count, 0);
3363 }
3364
3365 #[tokio::test]
3366 async fn test_run_resumed_skips_terminal_nodes() {
3367 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_resume"));
3368
3369 let mut n1 = SRBNNode::new("done".into(), "completed".into(), ModelTier::Actuator);
3370 n1.state = NodeState::Completed;
3371 let mut n2 = SRBNNode::new("failed".into(), "failed".into(), ModelTier::Actuator);
3372 n2.state = NodeState::Failed;
3373 orch.add_node(n1);
3374 orch.add_node(n2);
3375
3376 let result = orch.run_resumed().await;
3378 assert!(result.is_ok());
3379 }
3380
3381 #[tokio::test]
3382 async fn test_persist_review_decision_no_panic() {
3383 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_review"));
3384 orch.persist_review_decision("node_x", "approved", None);
3387 }
3388
3389 #[tokio::test]
3394 async fn test_check_structural_dependencies_blocks_prose_only() {
3395 use perspt_core::types::{NodeClass, RestrictionMap};
3396
3397 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_struct_dep"));
3398
3399 let mut parent = SRBNNode::new("iface_1".into(), "Define API".into(), ModelTier::Architect);
3401 parent.node_class = NodeClass::Interface;
3402
3403 let mut child = SRBNNode::new("impl_1".into(), "Implement API".into(), ModelTier::Actuator);
3405 child.node_class = NodeClass::Implementation;
3406
3407 let parent_idx = orch.add_node(parent);
3408 let child_idx = orch.add_node(child.clone());
3409 orch.graph
3410 .add_edge(parent_idx, child_idx, Dependency { kind: "dep".into() });
3411
3412 let rmap = RestrictionMap::for_node("impl_1");
3414 let gaps = orch.check_structural_dependencies(&child, &rmap);
3415
3416 assert_eq!(gaps.len(), 1);
3417 assert_eq!(gaps[0].0, "iface_1");
3418 assert!(gaps[0].1.contains("no Signature/Schema/InterfaceSeal"));
3419 }
3420
3421 #[tokio::test]
3422 async fn test_check_structural_dependencies_passes_with_digest() {
3423 use perspt_core::types::{ArtifactKind, NodeClass, RestrictionMap, StructuralDigest};
3424
3425 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_struct_ok"));
3426
3427 let mut parent = SRBNNode::new("iface_2".into(), "Define API".into(), ModelTier::Architect);
3428 parent.node_class = NodeClass::Interface;
3429
3430 let mut child = SRBNNode::new("impl_2".into(), "Implement API".into(), ModelTier::Actuator);
3431 child.node_class = NodeClass::Implementation;
3432
3433 let parent_idx = orch.add_node(parent);
3434 let child_idx = orch.add_node(child.clone());
3435 orch.graph
3436 .add_edge(parent_idx, child_idx, Dependency { kind: "dep".into() });
3437
3438 let mut rmap = RestrictionMap::for_node("impl_2");
3440 rmap.structural_digests.push(StructuralDigest::from_content(
3441 "iface_2",
3442 "api.rs",
3443 ArtifactKind::Signature,
3444 b"fn do_thing(x: i32) -> bool;",
3445 ));
3446
3447 let gaps = orch.check_structural_dependencies(&child, &rmap);
3448 assert!(gaps.is_empty(), "Expected no gaps when digest present");
3449 }
3450
3451 #[tokio::test]
3452 async fn test_check_structural_dependencies_skips_non_implementation() {
3453 use perspt_core::types::{NodeClass, RestrictionMap};
3454
3455 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_struct_skip"));
3456
3457 let mut node = SRBNNode::new("integ_1".into(), "Wire modules".into(), ModelTier::Actuator);
3459 node.node_class = NodeClass::Integration;
3460 orch.add_node(node.clone());
3461
3462 let rmap = RestrictionMap::for_node("integ_1");
3463 let gaps = orch.check_structural_dependencies(&node, &rmap);
3464 assert!(gaps.is_empty(), "Integration nodes should skip the check");
3465 }
3466
3467 #[tokio::test]
3468 async fn test_tier_default_models_are_differentiated() {
3469 let arch = ModelTier::Architect.default_model();
3471 let act = ModelTier::Actuator.default_model();
3472 let spec = ModelTier::Speculator.default_model();
3473
3474 assert_ne!(arch, act, "Architect and Actuator defaults should differ");
3476 assert_ne!(spec, arch, "Speculator should differ from Architect");
3478 }
3479
3480 #[tokio::test]
3485 async fn test_orchestrator_stores_all_four_tier_models() {
3486 let orch = SRBNOrchestrator::new_with_models(
3487 PathBuf::from("/tmp/test_tiers"),
3488 false,
3489 Some("arch-model".into()),
3490 Some("act-model".into()),
3491 Some("ver-model".into()),
3492 Some("spec-model".into()),
3493 None,
3494 None,
3495 None,
3496 None,
3497 );
3498 assert_eq!(orch.architect_model, "arch-model");
3499 assert_eq!(orch.actuator_model, "act-model");
3500 assert_eq!(orch.verifier_model, "ver-model");
3501 assert_eq!(orch.speculator_model, "spec-model");
3502 }
3503
3504 #[tokio::test]
3505 async fn test_orchestrator_default_tier_models() {
3506 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_tier_defaults"));
3507 assert_eq!(orch.architect_model, ModelTier::Architect.default_model());
3508 assert_eq!(orch.actuator_model, ModelTier::Actuator.default_model());
3509 assert_eq!(orch.verifier_model, ModelTier::Verifier.default_model());
3510 assert_eq!(orch.speculator_model, ModelTier::Speculator.default_model());
3511 }
3512
3513 #[tokio::test]
3514 async fn test_create_nodes_rejects_duplicate_output_files() {
3515 use perspt_core::types::PlannedTask;
3516
3517 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_dup_outputs"));
3518
3519 let plan = TaskPlan {
3520 tasks: vec![
3521 PlannedTask {
3522 id: "task_1".into(),
3523 goal: "Create math".into(),
3524 output_files: vec!["src/math.py".into(), "tests/test_math.py".into()],
3525 ..PlannedTask::new("task_1", "Create math")
3526 },
3527 PlannedTask {
3528 id: "task_2".into(),
3529 goal: "Create tests".into(),
3530 output_files: vec!["tests/test_math.py".into()],
3531 ..PlannedTask::new("task_2", "Create tests")
3532 },
3533 ],
3534 };
3535
3536 let result = orch.create_nodes_from_plan(&plan);
3537 assert!(result.is_err(), "Should reject duplicate output_files");
3538 let err = result.unwrap_err().to_string();
3539 assert!(
3540 err.contains("tests/test_math.py"),
3541 "Error should mention the duplicate file: {}",
3542 err
3543 );
3544 }
3545
3546 #[tokio::test]
3547 async fn test_create_nodes_accepts_unique_output_files() {
3548 use perspt_core::types::PlannedTask;
3549
3550 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_unique_outputs"));
3551
3552 let plan = TaskPlan {
3553 tasks: vec![
3554 PlannedTask {
3555 id: "task_1".into(),
3556 goal: "Create math".into(),
3557 output_files: vec!["src/math.py".into()],
3558 ..PlannedTask::new("task_1", "Create math")
3559 },
3560 PlannedTask {
3561 id: "test_1".into(),
3562 goal: "Test math".into(),
3563 output_files: vec!["tests/test_math.py".into()],
3564 dependencies: vec!["task_1".into()],
3565 ..PlannedTask::new("test_1", "Test math")
3566 },
3567 ],
3568 };
3569
3570 let result = orch.create_nodes_from_plan(&plan);
3571 assert!(result.is_ok(), "Should accept unique output_files");
3572 assert_eq!(orch.graph.node_count(), 2);
3573 }
3574
3575 #[tokio::test]
3576 async fn test_ownership_manifest_built_with_majority_plugin_vote() {
3577 use perspt_core::types::PlannedTask;
3578
3579 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_plugin_vote"));
3580
3581 let plan = TaskPlan {
3582 tasks: vec![PlannedTask {
3583 id: "task_1".into(),
3584 goal: "Create Python module".into(),
3585 output_files: vec![
3586 "src/main.py".into(),
3587 "src/helper.py".into(),
3588 "src/__init__.py".into(),
3589 ],
3590 ..PlannedTask::new("task_1", "Create Python module")
3591 }],
3592 };
3593
3594 orch.create_nodes_from_plan(&plan).unwrap();
3595
3596 assert_eq!(orch.context.ownership_manifest.len(), 3);
3598 let idx = orch.node_indices["task_1"];
3600 assert_eq!(orch.graph[idx].owner_plugin, "python");
3601 }
3602
3603 #[tokio::test]
3604 async fn test_apply_bundle_strips_paths_outside_node_output_targets() {
3605 use perspt_core::types::{ArtifactBundle, ArtifactOperation, PlannedTask};
3606
3607 let temp_dir = std::env::temp_dir().join(format!(
3608 "perspt_bundle_target_guard_{}",
3609 uuid::Uuid::new_v4()
3610 ));
3611 std::fs::create_dir_all(temp_dir.join("src")).unwrap();
3612
3613 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
3614 let plan = TaskPlan {
3615 tasks: vec![
3616 PlannedTask {
3617 id: "validate_module".into(),
3618 goal: "Create validation module".into(),
3619 output_files: vec!["src/validate.rs".into()],
3620 ..PlannedTask::new("validate_module", "Create validation module")
3621 },
3622 PlannedTask {
3623 id: "lib_module".into(),
3624 goal: "Export validation module".into(),
3625 output_files: vec!["src/lib.rs".into()],
3626 dependencies: vec!["validate_module".into()],
3627 ..PlannedTask::new("lib_module", "Export validation module")
3628 },
3629 ],
3630 };
3631
3632 orch.create_nodes_from_plan(&plan).unwrap();
3633
3634 let bundle = ArtifactBundle {
3635 artifacts: vec![
3636 ArtifactOperation::Write {
3637 path: "src/validate.rs".into(),
3638 content: "pub fn ok() {}".into(),
3639 },
3640 ArtifactOperation::Write {
3641 path: "src/lib.rs".into(),
3642 content: "pub mod validate;".into(),
3643 },
3644 ],
3645 commands: vec![],
3646 };
3647
3648 orch.apply_bundle_transactionally(
3651 &bundle,
3652 "validate_module",
3653 perspt_core::types::NodeClass::Implementation,
3654 )
3655 .await
3656 .expect("Should apply valid artifacts after stripping undeclared paths");
3657
3658 assert!(temp_dir.join("src/validate.rs").exists());
3660 assert!(!temp_dir.join("src/lib.rs").exists());
3662 }
3663
3664 #[tokio::test]
3665 async fn test_apply_bundle_keeps_legal_support_file() {
3666 use perspt_core::types::{ArtifactBundle, ArtifactOperation, PlannedTask};
3667
3668 let temp_dir = std::env::temp_dir().join(format!(
3669 "perspt_bundle_support_file_{}",
3670 uuid::Uuid::new_v4()
3671 ));
3672 std::fs::create_dir_all(temp_dir.join("src")).unwrap();
3673
3674 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
3675 let plan = TaskPlan {
3676 tasks: vec![PlannedTask {
3677 id: "main_module".into(),
3678 goal: "Create Rust main".into(),
3679 output_files: vec!["src/main.rs".into()],
3680 ..PlannedTask::new("main_module", "Create Rust main")
3681 }],
3682 };
3683 orch.create_nodes_from_plan(&plan).unwrap();
3684
3685 let bundle = ArtifactBundle {
3686 artifacts: vec![
3687 ArtifactOperation::Write {
3688 path: "src/main.rs".into(),
3689 content: "fn main() {}".into(),
3690 },
3691 ArtifactOperation::Write {
3692 path: "build.rs".into(),
3693 content: "fn main() {}".into(),
3694 },
3695 ],
3696 commands: vec![],
3697 };
3698
3699 orch.apply_bundle_transactionally(
3700 &bundle,
3701 "main_module",
3702 perspt_core::types::NodeClass::Implementation,
3703 )
3704 .await
3705 .expect("legal support files should survive semantic filtering");
3706
3707 assert!(temp_dir.join("src/main.rs").exists());
3708 assert!(temp_dir.join("build.rs").exists());
3709 let _ = std::fs::remove_dir_all(&temp_dir);
3710 }
3711
3712 #[tokio::test]
3713 async fn test_apply_bundle_denies_root_manifest_mutation() {
3714 use perspt_core::types::{ArtifactBundle, ArtifactOperation, PlannedTask};
3715
3716 let temp_dir = std::env::temp_dir().join(format!(
3717 "perspt_bundle_manifest_policy_{}",
3718 uuid::Uuid::new_v4()
3719 ));
3720 std::fs::create_dir_all(temp_dir.join("src")).unwrap();
3721
3722 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
3723 let plan = TaskPlan {
3724 tasks: vec![PlannedTask {
3725 id: "main_module".into(),
3726 goal: "Create Rust main".into(),
3727 output_files: vec!["src/main.rs".into()],
3728 ..PlannedTask::new("main_module", "Create Rust main")
3729 }],
3730 };
3731 orch.create_nodes_from_plan(&plan).unwrap();
3732
3733 let bundle = ArtifactBundle {
3734 artifacts: vec![
3735 ArtifactOperation::Write {
3736 path: "src/main.rs".into(),
3737 content: "fn main() {}".into(),
3738 },
3739 ArtifactOperation::Write {
3740 path: "Cargo.toml".into(),
3741 content: "[package]\nname = \"bad\"\n".into(),
3742 },
3743 ],
3744 commands: vec![],
3745 };
3746
3747 orch.apply_bundle_transactionally(
3748 &bundle,
3749 "main_module",
3750 perspt_core::types::NodeClass::Implementation,
3751 )
3752 .await
3753 .expect("declared artifact should still apply after denied manifest is stripped");
3754
3755 assert!(temp_dir.join("src/main.rs").exists());
3756 assert!(!temp_dir.join("Cargo.toml").exists());
3757 let _ = std::fs::remove_dir_all(&temp_dir);
3758 }
3759
3760 #[tokio::test]
3761 async fn test_apply_bundle_writes_into_branch_sandbox() {
3762 use perspt_core::types::{ArtifactBundle, ArtifactOperation, PlannedTask};
3763
3764 let temp_dir = std::env::temp_dir().join(format!(
3765 "perspt_branch_sandbox_write_{}",
3766 uuid::Uuid::new_v4()
3767 ));
3768 std::fs::create_dir_all(temp_dir.join("src")).unwrap();
3769 std::fs::write(temp_dir.join("src/lib.rs"), "pub fn old() {}\n").unwrap();
3770
3771 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
3772 orch.context.session_id = uuid::Uuid::new_v4().to_string();
3773
3774 let plan = TaskPlan {
3775 tasks: vec![
3776 PlannedTask {
3777 id: "parent".into(),
3778 goal: "Parent node".into(),
3779 output_files: vec!["src/lib.rs".into()],
3780 ..PlannedTask::new("parent", "Parent node")
3781 },
3782 PlannedTask {
3783 id: "child".into(),
3784 goal: "Child node".into(),
3785 context_files: vec!["src/lib.rs".into()],
3786 output_files: vec!["src/child.rs".into()],
3787 dependencies: vec!["parent".into()],
3788 ..PlannedTask::new("child", "Child node")
3789 },
3790 ],
3791 };
3792
3793 orch.create_nodes_from_plan(&plan).unwrap();
3794 let child_idx = orch.node_indices["child"];
3795 let branch_id = orch.maybe_create_provisional_branch(child_idx).unwrap();
3796 let sandbox_dir = orch.sandbox_dir_for_node(child_idx).unwrap();
3797
3798 let bundle = ArtifactBundle {
3799 artifacts: vec![ArtifactOperation::Write {
3800 path: "src/child.rs".into(),
3801 content: "pub fn child() {}\n".into(),
3802 }],
3803 commands: vec![],
3804 };
3805
3806 orch.apply_bundle_transactionally(
3807 &bundle,
3808 "child",
3809 perspt_core::types::NodeClass::Implementation,
3810 )
3811 .await
3812 .unwrap();
3813
3814 assert!(sandbox_dir.join("src/child.rs").exists());
3815 assert!(!temp_dir.join("src/child.rs").exists());
3816
3817 orch.merge_provisional_branch(&branch_id, child_idx);
3818 }
3819
3820 #[test]
3821 fn test_verification_stages_for_node_classes() {
3822 use perspt_core::plugin::VerifierStage;
3823
3824 let interface_node =
3826 SRBNNode::new("iface".into(), "Define trait".into(), ModelTier::Actuator);
3827 let mut interface_node = interface_node;
3829 interface_node.node_class = perspt_core::types::NodeClass::Interface;
3830 let stages = verification_stages_for_node(&interface_node);
3831 assert_eq!(stages, vec![VerifierStage::SyntaxCheck]);
3832
3833 let mut implementation_node = SRBNNode::new(
3835 "impl".into(),
3836 "Implement feature".into(),
3837 ModelTier::Actuator,
3838 );
3839 implementation_node.node_class = perspt_core::types::NodeClass::Implementation;
3840 let stages = verification_stages_for_node(&implementation_node);
3841 assert_eq!(
3842 stages,
3843 vec![VerifierStage::SyntaxCheck, VerifierStage::Build]
3844 );
3845
3846 implementation_node
3848 .contract
3849 .weighted_tests
3850 .push(perspt_core::types::WeightedTest {
3851 test_name: "test_feature".into(),
3852 criticality: perspt_core::types::Criticality::High,
3853 });
3854 let stages = verification_stages_for_node(&implementation_node);
3855 assert_eq!(
3856 stages,
3857 vec![
3858 VerifierStage::SyntaxCheck,
3859 VerifierStage::Build,
3860 VerifierStage::Test
3861 ]
3862 );
3863
3864 let mut integration_node =
3866 SRBNNode::new("test".into(), "Verify feature".into(), ModelTier::Actuator);
3867 integration_node.node_class = perspt_core::types::NodeClass::Integration;
3868 integration_node
3869 .contract
3870 .weighted_tests
3871 .push(perspt_core::types::WeightedTest {
3872 test_name: "test_feature".into(),
3873 criticality: perspt_core::types::Criticality::High,
3874 });
3875 let stages = verification_stages_for_node(&integration_node);
3876 assert_eq!(
3877 stages,
3878 vec![
3879 VerifierStage::SyntaxCheck,
3880 VerifierStage::Build,
3881 VerifierStage::Test,
3882 VerifierStage::Lint,
3883 ]
3884 );
3885 }
3886
3887 #[tokio::test]
3892 async fn test_classify_workspace_empty_dir() {
3893 let temp = tempfile::tempdir().unwrap();
3894 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
3895 let state = orch.classify_workspace("build a web app");
3896 assert!(matches!(state, WorkspaceState::Greenfield { .. }));
3898 }
3899
3900 #[tokio::test]
3901 async fn test_classify_workspace_empty_dir_no_lang() {
3902 let temp = tempfile::tempdir().unwrap();
3903 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
3904 let state = orch.classify_workspace("do something");
3905 match state {
3907 WorkspaceState::Greenfield { inferred_lang } => assert!(inferred_lang.is_none()),
3908 _ => panic!("expected Greenfield, got {:?}", state),
3909 }
3910 }
3911
3912 #[tokio::test]
3913 async fn test_classify_workspace_existing_rust_project() {
3914 let temp = tempfile::tempdir().unwrap();
3915 std::fs::write(
3917 temp.path().join("Cargo.toml"),
3918 "[package]\nname = \"test\"\nversion = \"0.1.0\"",
3919 )
3920 .unwrap();
3921 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
3922 let state = orch.classify_workspace("add a feature");
3923 match state {
3924 WorkspaceState::ExistingProject { plugins } => {
3925 assert!(plugins.contains(&"rust".to_string()));
3926 }
3927 _ => panic!("expected ExistingProject, got {:?}", state),
3928 }
3929 }
3930
3931 #[tokio::test]
3932 async fn test_classify_workspace_existing_python_project() {
3933 let temp = tempfile::tempdir().unwrap();
3934 std::fs::write(
3935 temp.path().join("pyproject.toml"),
3936 "[project]\nname = \"test\"",
3937 )
3938 .unwrap();
3939 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
3940 let state = orch.classify_workspace("add a feature");
3941 match state {
3942 WorkspaceState::ExistingProject { plugins } => {
3943 assert!(plugins.contains(&"python".to_string()));
3944 }
3945 _ => panic!("expected ExistingProject, got {:?}", state),
3946 }
3947 }
3948
3949 #[tokio::test]
3950 async fn test_classify_workspace_existing_js_project() {
3951 let temp = tempfile::tempdir().unwrap();
3952 std::fs::write(temp.path().join("package.json"), "{}").unwrap();
3953 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
3954 let state = orch.classify_workspace("add auth");
3955 match state {
3956 WorkspaceState::ExistingProject { plugins } => {
3957 assert!(plugins.contains(&"javascript".to_string()));
3958 }
3959 _ => panic!("expected ExistingProject, got {:?}", state),
3960 }
3961 }
3962
3963 #[tokio::test]
3964 async fn test_classify_workspace_ambiguous_with_misc_files() {
3965 let temp = tempfile::tempdir().unwrap();
3966 std::fs::write(temp.path().join("notes.txt"), "hello").unwrap();
3968 std::fs::write(temp.path().join("data.csv"), "a,b,c").unwrap();
3969 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
3970 let state = orch.classify_workspace("do something");
3971 assert!(matches!(state, WorkspaceState::Ambiguous));
3972 }
3973
3974 #[tokio::test]
3975 async fn test_classify_workspace_greenfield_with_rust_task() {
3976 let temp = tempfile::tempdir().unwrap();
3977 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
3978 let state = orch.classify_workspace("create a rust CLI tool");
3979 match state {
3980 WorkspaceState::Greenfield { inferred_lang } => {
3981 assert_eq!(inferred_lang, Some("rust".to_string()));
3982 }
3983 _ => panic!("expected Greenfield, got {:?}", state),
3984 }
3985 }
3986
3987 #[tokio::test]
3988 async fn test_classify_workspace_greenfield_with_python_task() {
3989 let temp = tempfile::tempdir().unwrap();
3990 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
3991 let state = orch.classify_workspace("build a python flask API");
3992 match state {
3993 WorkspaceState::Greenfield { inferred_lang } => {
3994 assert_eq!(inferred_lang, Some("python".to_string()));
3995 }
3996 _ => panic!("expected Greenfield, got {:?}", state),
3997 }
3998 }
3999
4000 #[tokio::test]
4005 async fn test_check_prerequisites_returns_true_when_tools_available() {
4006 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
4007 let registry = perspt_core::plugin::PluginRegistry::new();
4008 if let Some(plugin) = registry.get("rust") {
4010 let result = orch.check_tool_prerequisites(plugin);
4011 let _ = result;
4014 }
4015 }
4016
4017 #[test]
4018 fn test_required_binaries_rust_includes_cargo() {
4019 let registry = perspt_core::plugin::PluginRegistry::new();
4020 let plugin = registry.get("rust").unwrap();
4021 let bins = plugin.required_binaries();
4022 assert!(bins.iter().any(|(name, _, _)| *name == "cargo"));
4023 assert!(bins.iter().any(|(name, _, _)| *name == "rustc"));
4024 }
4025
4026 #[test]
4027 fn test_required_binaries_python_includes_uv() {
4028 let registry = perspt_core::plugin::PluginRegistry::new();
4029 let plugin = registry.get("python").unwrap();
4030 let bins = plugin.required_binaries();
4031 assert!(bins.iter().any(|(name, _, _)| *name == "uv"));
4032 assert!(bins.iter().any(|(name, _, _)| *name == "python3"));
4033 }
4034
4035 #[test]
4036 fn test_required_binaries_js_includes_node() {
4037 let registry = perspt_core::plugin::PluginRegistry::new();
4038 let plugin = registry.get("javascript").unwrap();
4039 let bins = plugin.required_binaries();
4040 assert!(bins.iter().any(|(name, _, _)| *name == "node"));
4041 assert!(bins.iter().any(|(name, _, _)| *name == "npm"));
4042 }
4043
4044 #[tokio::test]
4049 async fn test_fallback_defaults_to_none_without_explicit_config() {
4050 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
4051 assert!(orch.architect_fallback_model.is_none());
4052 assert!(orch.actuator_fallback_model.is_none());
4053 assert!(orch.verifier_fallback_model.is_none());
4054 assert!(orch.speculator_fallback_model.is_none());
4055 }
4056
4057 #[tokio::test]
4058 async fn test_explicit_fallback_stored_correctly() {
4059 let orch = SRBNOrchestrator::new_with_models(
4060 PathBuf::from("/tmp/test_fallback"),
4061 false,
4062 None,
4063 None,
4064 None,
4065 None,
4066 Some("gpt-4o".into()),
4067 Some("gpt-4o-mini".into()),
4068 Some("gpt-4o".into()),
4069 Some("gpt-4o-mini".into()),
4070 );
4071 assert_eq!(orch.architect_fallback_model, Some("gpt-4o".to_string()));
4072 assert_eq!(
4073 orch.actuator_fallback_model,
4074 Some("gpt-4o-mini".to_string())
4075 );
4076 assert_eq!(orch.verifier_fallback_model, Some("gpt-4o".to_string()));
4077 assert_eq!(
4078 orch.speculator_fallback_model,
4079 Some("gpt-4o-mini".to_string())
4080 );
4081 }
4082
4083 #[tokio::test]
4084 async fn test_per_tier_models_independent() {
4085 let orch = SRBNOrchestrator::new_with_models(
4086 PathBuf::from("/tmp/test_tiers_independent"),
4087 false,
4088 Some("arch".into()),
4089 Some("act".into()),
4090 Some("ver".into()),
4091 Some("spec".into()),
4092 None,
4093 None,
4094 None,
4095 None,
4096 );
4097 assert_ne!(orch.architect_model, orch.actuator_model);
4099 assert_ne!(orch.verifier_model, orch.speculator_model);
4100 }
4101
4102 #[test]
4107 fn test_extract_missing_python_modules_basic() {
4108 let output = r#"
4109FAILED tests/test_core.py::TestPipeline::test_run - ModuleNotFoundError: No module named 'httpx'
4110E ModuleNotFoundError: No module named 'pydantic'
4111ImportError: No module named 'pyarrow'
4112"#;
4113 let mut missing = SRBNOrchestrator::extract_missing_python_modules(output);
4114 missing.sort();
4115 assert_eq!(missing, vec!["httpx", "pyarrow", "pydantic"]);
4116 }
4117
4118 #[test]
4119 fn test_extract_missing_python_modules_subpackage() {
4120 let output = "ModuleNotFoundError: No module named 'foo.bar.baz'";
4121 let missing = SRBNOrchestrator::extract_missing_python_modules(output);
4122 assert_eq!(missing, vec!["foo"]);
4123 }
4124
4125 #[test]
4126 fn test_extract_missing_python_modules_stdlib_filtered() {
4127 let output = r#"
4128ModuleNotFoundError: No module named 'numpy'
4129ModuleNotFoundError: No module named 'os'
4130ModuleNotFoundError: No module named 'json'
4131"#;
4132 let missing = SRBNOrchestrator::extract_missing_python_modules(output);
4133 assert_eq!(missing, vec!["numpy"]);
4134 }
4135
4136 #[test]
4137 fn test_extract_missing_python_modules_empty() {
4138 let output = "All tests passed!\n3 passed in 0.5s";
4139 let missing = SRBNOrchestrator::extract_missing_python_modules(output);
4140 assert!(missing.is_empty());
4141 }
4142
4143 #[test]
4144 fn test_python_import_to_package_mapping() {
4145 assert_eq!(SRBNOrchestrator::python_import_to_package("PIL"), "pillow");
4146 assert_eq!(SRBNOrchestrator::python_import_to_package("yaml"), "pyyaml");
4147 assert_eq!(
4148 SRBNOrchestrator::python_import_to_package("cv2"),
4149 "opencv-python"
4150 );
4151 assert_eq!(
4152 SRBNOrchestrator::python_import_to_package("sklearn"),
4153 "scikit-learn"
4154 );
4155 assert_eq!(
4156 SRBNOrchestrator::python_import_to_package("bs4"),
4157 "beautifulsoup4"
4158 );
4159 assert_eq!(SRBNOrchestrator::python_import_to_package("httpx"), "httpx");
4161 assert_eq!(
4162 SRBNOrchestrator::python_import_to_package("fastapi"),
4163 "fastapi"
4164 );
4165 }
4166
4167 #[test]
4168 fn test_normalize_command_to_uv_pip_install() {
4169 assert_eq!(
4170 SRBNOrchestrator::normalize_command_to_uv("pip install httpx"),
4171 "uv add httpx"
4172 );
4173 assert_eq!(
4174 SRBNOrchestrator::normalize_command_to_uv("pip3 install httpx pydantic"),
4175 "uv add httpx pydantic"
4176 );
4177 assert_eq!(
4178 SRBNOrchestrator::normalize_command_to_uv("python -m pip install requests"),
4179 "uv add requests"
4180 );
4181 assert_eq!(
4182 SRBNOrchestrator::normalize_command_to_uv("python3 -m pip install flask"),
4183 "uv add flask"
4184 );
4185 }
4186
4187 #[test]
4188 fn test_normalize_command_to_uv_requirements_file() {
4189 assert_eq!(
4190 SRBNOrchestrator::normalize_command_to_uv("pip install -r requirements.txt"),
4191 "uv pip install -r requirements.txt"
4192 );
4193 }
4194
4195 #[test]
4196 fn test_normalize_command_to_uv_passthrough() {
4197 assert_eq!(
4199 SRBNOrchestrator::normalize_command_to_uv("uv add httpx"),
4200 "uv add httpx"
4201 );
4202 assert_eq!(
4204 SRBNOrchestrator::normalize_command_to_uv("cargo add serde"),
4205 "cargo add serde"
4206 );
4207 assert_eq!(
4208 SRBNOrchestrator::normalize_command_to_uv("npm install lodash"),
4209 "npm install lodash"
4210 );
4211 }
4212
4213 #[test]
4214 fn test_extract_commands_from_correction_rust_plugin_policy() {
4215 let response = r#"Here's the fix:
4216Commands:
4217```
4218uv add httpx
4219cargo add serde
4220pip install numpy
4221```
4222File: main.rs
4223```rust
4224use serde;
4225```"#;
4226 let commands = SRBNOrchestrator::extract_commands_from_correction(response, "rust");
4228 assert!(
4229 commands.contains(&"cargo add serde".to_string()),
4230 "{:?}",
4231 commands
4232 );
4233 assert!(
4234 !commands.contains(&"uv add httpx".to_string()),
4235 "Rust plugin should deny uv commands: {:?}",
4236 commands
4237 );
4238 assert!(
4239 !commands.contains(&"pip install numpy".to_string()),
4240 "Rust plugin should deny pip commands: {:?}",
4241 commands
4242 );
4243 }
4244
4245 #[test]
4246 fn test_extract_commands_from_correction_python_plugin_policy() {
4247 let response = r#"Commands:
4248```
4249uv add httpx
4250cargo add serde
4251pip install numpy
4252```"#;
4253 let commands = SRBNOrchestrator::extract_commands_from_correction(response, "python");
4255 assert!(
4256 commands.contains(&"uv add httpx".to_string()),
4257 "{:?}",
4258 commands
4259 );
4260 assert!(
4261 commands.contains(&"pip install numpy".to_string()),
4262 "{:?}",
4263 commands
4264 );
4265 assert!(
4266 !commands.contains(&"cargo add serde".to_string()),
4267 "Python plugin should deny cargo commands: {:?}",
4268 commands
4269 );
4270 }
4271
4272 #[test]
4273 fn test_typed_parse_pipeline_multiple_files() {
4274 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4275 let content = r#"Here are the files:
4276
4277File: src/etl_pipeline/core.py
4278```python
4279def run_pipeline():
4280 pass
4281```
4282
4283File: src/etl_pipeline/validator.py
4284```python
4285def validate(data):
4286 return True
4287```
4288
4289File: tests/test_core.py
4290```python
4291from etl_pipeline.core import run_pipeline
4292
4293def test_run():
4294 run_pipeline()
4295```
4296"#;
4297 let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4298 assert!(state.is_ok(), "Expected successful parse, got {}", state);
4299 let bundle = bundle_opt.unwrap();
4300 assert_eq!(bundle.artifacts.len(), 3, "Expected 3 artifacts");
4301 assert_eq!(bundle.artifacts[0].path(), "src/etl_pipeline/core.py");
4302 assert_eq!(bundle.artifacts[1].path(), "src/etl_pipeline/validator.py");
4303 assert_eq!(bundle.artifacts[2].path(), "tests/test_core.py");
4304 }
4305
4306 #[test]
4307 fn test_typed_parse_pipeline_single_file() {
4308 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4309 let content = r#"File: main.py
4310```python
4311print("hello")
4312```"#;
4313 let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4314 assert!(state.is_ok());
4315 let bundle = bundle_opt.unwrap();
4316 assert_eq!(bundle.artifacts.len(), 1);
4317 assert_eq!(bundle.artifacts[0].path(), "main.py");
4318 }
4319
4320 #[test]
4321 fn test_typed_parse_pipeline_mixed_file_and_diff() {
4322 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4323 let content = r#"File: new_module.py
4324```python
4325def new_fn():
4326 pass
4327```
4328
4329Diff: existing.py
4330```diff
4331--- existing.py
4332+++ existing.py
4333@@ -1 +1,2 @@
4334+import new_module
4335 def old_fn():
4336```"#;
4337 let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4338 assert!(state.is_ok());
4339 let bundle = bundle_opt.unwrap();
4340 assert_eq!(bundle.artifacts.len(), 2);
4341 assert_eq!(bundle.artifacts[0].path(), "new_module.py");
4342 assert!(
4343 bundle.artifacts[0].is_write(),
4344 "new_module.py should be a write"
4345 );
4346 assert_eq!(bundle.artifacts[1].path(), "existing.py");
4347 assert!(
4348 bundle.artifacts[1].is_diff(),
4349 "existing.py should be a diff"
4350 );
4351 }
4352
4353 #[test]
4354 fn test_typed_parse_pipeline_legacy_multi_file() {
4355 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4356 let content = r#"File: core.py
4357```python
4358def core():
4359 pass
4360```
4361
4362File: utils.py
4363```python
4364def util():
4365 pass
4366```"#;
4367 let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4368 assert!(state.is_ok(), "Should parse multi-file response");
4369 let bundle = bundle_opt.unwrap();
4370 assert_eq!(bundle.artifacts.len(), 2, "Should have 2 artifacts");
4371 assert_eq!(bundle.artifacts[0].path(), "core.py");
4372 assert_eq!(bundle.artifacts[1].path(), "utils.py");
4373 }
4374
4375 #[test]
4380 fn test_typed_parse_pipeline_structured_json() {
4381 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4382 let content = r#"Here is the output:
4383```json
4384{
4385 "artifacts": [
4386 {"operation": "write", "path": "src/main.py", "content": "print('hello')"},
4387 {"operation": "diff", "path": "src/lib.py", "patch": "--- a\n+++ b\n@@ -1 +1 @@\n-old\n+new"}
4388 ],
4389 "commands": ["uv add requests"]
4390}
4391```"#;
4392 let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4393 assert!(state.is_ok(), "Should parse structured JSON bundle");
4394 let bundle = bundle_opt.unwrap();
4395 assert_eq!(bundle.artifacts.len(), 2);
4396 assert!(bundle.artifacts[0].is_write());
4397 assert_eq!(bundle.artifacts[0].path(), "src/main.py");
4398 assert!(bundle.artifacts[1].is_diff());
4399 assert_eq!(bundle.artifacts[1].path(), "src/lib.py");
4400 assert_eq!(bundle.commands, vec!["uv add requests"]);
4401 }
4402
4403 #[test]
4404 fn test_typed_parse_pipeline_schema_invalid_classified() {
4405 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4406 let content = r#"```json
4407{"foo":"bar"}
4408```"#;
4409 let (bundle_opt, state, record_opt) = orch.parse_artifact_bundle_typed(content, "test", 1);
4410 assert!(bundle_opt.is_none());
4411 assert!(matches!(
4412 state,
4413 perspt_core::types::ParseResultState::SchemaInvalid
4414 ));
4415 let record = record_opt.expect("schema failure should be recorded");
4416 assert!(matches!(
4417 record.retry_classification,
4418 Some(perspt_core::types::RetryClassification::MalformedRetry)
4419 ));
4420 }
4421
4422 #[test]
4423 fn test_typed_parse_pipeline_semantic_rejection_classified() {
4424 use perspt_core::types::PlannedTask;
4425
4426 let mut orch = SRBNOrchestrator::new_for_testing(std::path::PathBuf::from("/tmp/test"));
4427 let plan = TaskPlan {
4428 tasks: vec![PlannedTask {
4429 id: "parser".into(),
4430 goal: "Create parser".into(),
4431 output_files: vec!["src/parser.rs".into()],
4432 ..PlannedTask::new("parser", "Create parser")
4433 }],
4434 };
4435 orch.create_nodes_from_plan(&plan).unwrap();
4436
4437 let content = r#"```json
4438{
4439 "artifacts": [
4440 {"operation": "write", "path": "src/wrong.rs", "content": "pub fn wrong() {}"}
4441 ],
4442 "commands": []
4443}
4444```"#;
4445 let (bundle_opt, state, record_opt) =
4446 orch.parse_artifact_bundle_typed(content, "parser", 1);
4447 assert!(bundle_opt.is_none());
4448 assert!(matches!(
4449 state,
4450 perspt_core::types::ParseResultState::SemanticallyRejected
4451 ));
4452 let record = record_opt.expect("semantic rejection should be recorded");
4453 assert!(matches!(
4454 record.retry_classification,
4455 Some(perspt_core::types::RetryClassification::Retarget)
4456 ));
4457 }
4458
4459 #[test]
4460 fn test_typed_parse_pipeline_json_empty_path_rejected() {
4461 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4462 let content = r#"```json
4463{
4464 "artifacts": [
4465 {"operation": "write", "path": "", "content": "bad"}
4466 ],
4467 "commands": []
4468}
4469```"#;
4470 let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4471 assert!(
4472 bundle_opt.is_none(),
4473 "Invalid bundle with empty path should be rejected"
4474 );
4475 assert!(
4476 !state.is_ok(),
4477 "Parse state should not be Ok for invalid bundle: {}",
4478 state
4479 );
4480 }
4481
4482 #[test]
4483 fn test_typed_parse_pipeline_json_absolute_path_rejected() {
4484 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4485 let content = r#"```json
4486{
4487 "artifacts": [
4488 {"operation": "write", "path": "/etc/passwd", "content": "bad"}
4489 ],
4490 "commands": []
4491}
4492```"#;
4493 let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4494 assert!(
4495 bundle_opt.is_none(),
4496 "Invalid bundle with absolute path should be rejected"
4497 );
4498 assert!(
4499 !state.is_ok(),
4500 "Parse state should not be Ok for path traversal: {}",
4501 state
4502 );
4503 }
4504
4505 #[test]
4506 fn test_typed_parse_pipeline_returns_no_payload_for_garbage() {
4507 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4508 let content = "This is just a plain text response with no code blocks at all.";
4509 let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4510 assert!(bundle_opt.is_none());
4511 assert!(
4512 matches!(
4513 state,
4514 perspt_core::types::ParseResultState::NoStructuredPayload
4515 ),
4516 "Expected NoStructuredPayload, got {}",
4517 state
4518 );
4519 }
4520
4521 #[tokio::test]
4522 async fn test_effective_working_dir_with_sandbox() {
4523 let temp_dir = std::env::temp_dir().join(format!(
4526 "perspt_eff_workdir_sandbox_{}",
4527 uuid::Uuid::new_v4()
4528 ));
4529 std::fs::create_dir_all(&temp_dir).unwrap();
4530
4531 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
4532 orch.context.session_id = "test_session".into();
4533
4534 let parent = SRBNNode::new("root".into(), "root goal".into(), ModelTier::Actuator);
4535 let child = SRBNNode::new("child".into(), "child goal".into(), ModelTier::Actuator);
4536 orch.add_node(parent);
4537 orch.add_node(child);
4538 orch.add_dependency("root", "child", "dep").unwrap();
4539
4540 let child_idx = orch.node_indices["child"];
4541 let branch_id = orch.maybe_create_provisional_branch(child_idx).unwrap();
4542
4543 let sandbox_path = temp_dir
4544 .join(".perspt")
4545 .join("sandboxes")
4546 .join("test_session")
4547 .join(&branch_id);
4548 assert!(sandbox_path.exists(), "Sandbox should have been created");
4549
4550 let eff = orch.effective_working_dir(child_idx);
4552 assert_eq!(eff, sandbox_path);
4553
4554 let _ = std::fs::remove_dir_all(&temp_dir);
4556 }
4557
4558 #[tokio::test]
4559 async fn test_sandbox_dir_for_node_returns_path_when_exists() {
4560 let temp_dir = std::env::temp_dir().join(format!(
4561 "perspt_sandbox_dir_exists_{}",
4562 uuid::Uuid::new_v4()
4563 ));
4564 std::fs::create_dir_all(&temp_dir).unwrap();
4565
4566 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
4567 orch.context.session_id = "sess".into();
4568
4569 let parent = SRBNNode::new("p".into(), "g".into(), ModelTier::Actuator);
4570 let child = SRBNNode::new("c".into(), "g".into(), ModelTier::Actuator);
4571 orch.add_node(parent);
4572 orch.add_node(child);
4573 orch.add_dependency("p", "c", "dep").unwrap();
4574
4575 let child_idx = orch.node_indices["c"];
4576 let branch_id = orch.maybe_create_provisional_branch(child_idx).unwrap();
4577
4578 let sandbox = orch.sandbox_dir_for_node(child_idx);
4579 assert!(sandbox.is_some());
4580 let sandbox_path = sandbox.unwrap();
4581 assert!(sandbox_path.ends_with(&branch_id));
4582
4583 let _ = std::fs::remove_dir_all(&temp_dir);
4584 }
4585
4586 #[tokio::test]
4587 async fn test_root_node_bypasses_sandbox() {
4588 let temp_dir =
4591 std::env::temp_dir().join(format!("perspt_root_bypass_{}", uuid::Uuid::new_v4()));
4592 std::fs::create_dir_all(&temp_dir).unwrap();
4593
4594 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
4595
4596 let root = SRBNNode::new("root".into(), "root goal".into(), ModelTier::Actuator);
4597 orch.add_node(root);
4598
4599 let root_idx = orch.node_indices["root"];
4600 let branch = orch.maybe_create_provisional_branch(root_idx);
4602 assert!(
4603 branch.is_some(),
4604 "Root node should now get a provisional branch for sandbox isolation"
4605 );
4606
4607 let wd = orch.effective_working_dir(root_idx);
4609 assert_ne!(wd, temp_dir, "Root should use sandbox, not raw workspace");
4610 assert!(wd.to_string_lossy().contains("sandboxes"));
4611
4612 let _ = std::fs::remove_dir_all(&temp_dir);
4613 }
4614
4615 #[tokio::test]
4616 async fn test_step_commit_copies_sandbox_to_workspace() {
4617 use perspt_core::types::{ArtifactBundle, ArtifactOperation, PlannedTask};
4620
4621 let temp_dir =
4622 std::env::temp_dir().join(format!("perspt_commit_copy_{}", uuid::Uuid::new_v4()));
4623 std::fs::create_dir_all(temp_dir.join("src")).unwrap();
4624
4625 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
4626 orch.context.session_id = uuid::Uuid::new_v4().to_string();
4627
4628 let plan = TaskPlan {
4629 tasks: vec![
4630 PlannedTask {
4631 id: "parent".into(),
4632 goal: "Parent".into(),
4633 output_files: vec!["src/parent.rs".into()],
4634 ..PlannedTask::new("parent", "Parent")
4635 },
4636 PlannedTask {
4637 id: "child".into(),
4638 goal: "Child".into(),
4639 output_files: vec!["src/child.rs".into()],
4640 dependencies: vec!["parent".into()],
4641 ..PlannedTask::new("child", "Child")
4642 },
4643 ],
4644 };
4645 orch.create_nodes_from_plan(&plan).unwrap();
4646
4647 let child_idx = orch.node_indices["child"];
4648 let _branch_id = orch.maybe_create_provisional_branch(child_idx).unwrap();
4649
4650 let bundle = ArtifactBundle {
4652 artifacts: vec![ArtifactOperation::Write {
4653 path: "src/child.rs".into(),
4654 content: "pub fn child_fn() {}\n".into(),
4655 }],
4656 commands: vec![],
4657 };
4658 orch.apply_bundle_transactionally(
4659 &bundle,
4660 "child",
4661 perspt_core::types::NodeClass::Implementation,
4662 )
4663 .await
4664 .unwrap();
4665
4666 let sandbox = orch.sandbox_dir_for_node(child_idx).unwrap();
4668 assert!(sandbox.join("src/child.rs").exists());
4669 assert!(!temp_dir.join("src/child.rs").exists());
4670
4671 let child_idx = orch.node_indices["child"];
4673 let _ = orch.step_commit(child_idx).await;
4674
4675 assert!(
4677 temp_dir.join("src/child.rs").exists(),
4678 "step_commit should copy sandbox files to workspace"
4679 );
4680 let content = std::fs::read_to_string(temp_dir.join("src/child.rs")).unwrap();
4681 assert_eq!(content, "pub fn child_fn() {}\n");
4682
4683 let _ = std::fs::remove_dir_all(&temp_dir);
4684 }
4685
4686 #[test]
4687 fn test_typed_parse_pipeline_json_path_traversal_rejected() {
4688 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4689 let content = r#"```json
4690{
4691 "artifacts": [
4692 {"operation": "write", "path": "../../../etc/shadow", "content": "bad"}
4693 ],
4694 "commands": []
4695}
4696```"#;
4697 let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4698 assert!(
4699 bundle_opt.is_none(),
4700 "Invalid bundle with path traversal should be rejected"
4701 );
4702 assert!(
4703 !state.is_ok(),
4704 "Parse state should not be Ok for path traversal: {}",
4705 state
4706 );
4707 }
4708
4709 #[test]
4712 fn test_dependency_expectations_threaded_to_nodes() {
4713 use perspt_core::types::{DependencyExpectation, PlannedTask, TaskPlan};
4714
4715 let mut plan = TaskPlan::new();
4716 let mut t1 = PlannedTask::new("t1", "Create server module");
4717 t1.output_files = vec!["src/server.py".to_string()];
4718 t1.dependency_expectations = DependencyExpectation {
4719 required_packages: vec!["flask".to_string(), "pydantic".to_string()],
4720 setup_commands: vec![],
4721 min_toolchain_version: Some("3.11".to_string()),
4722 };
4723 plan.tasks.push(t1);
4724
4725 let mut orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4726 orch.create_nodes_from_plan(&plan).unwrap();
4727
4728 let idx = orch.node_indices["t1"];
4730 let node = &orch.graph[idx];
4731 assert_eq!(node.dependency_expectations.required_packages.len(), 2);
4732 assert_eq!(node.dependency_expectations.required_packages[0], "flask");
4733 assert_eq!(
4734 node.dependency_expectations
4735 .min_toolchain_version
4736 .as_deref(),
4737 Some("3.11")
4738 );
4739 }
4740
4741 #[test]
4742 fn test_verifier_readiness_gate_no_plugins() {
4743 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4744 orch.check_verifier_readiness_gate();
4746 }
4747
4748 #[test]
4749 fn test_architect_prompt_includes_dependency_expectations() {
4750 let ev = perspt_core::types::PromptEvidence {
4751 user_goal: Some("Build a web server".to_string()),
4752 project_summary: Some("empty project".to_string()),
4753 working_dir: Some("/tmp".to_string()),
4754 ..Default::default()
4755 };
4756 let prompt = crate::prompt_compiler::compile(
4757 perspt_core::types::PromptIntent::ArchitectExisting,
4758 &ev,
4759 )
4760 .text;
4761 assert!(
4762 prompt.contains("dependency_expectations"),
4763 "Architect prompt must include dependency_expectations in the JSON schema"
4764 );
4765 assert!(
4766 prompt.contains("required_packages"),
4767 "Architect prompt must mention required_packages"
4768 );
4769 assert!(
4770 prompt.contains("min_toolchain_version"),
4771 "Architect prompt must mention min_toolchain_version"
4772 );
4773 }
4774
4775 #[test]
4778 fn test_budget_gate_stops_execution_when_exhausted() {
4779 let mut orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4780 orch.set_budget(Some(0), None, None);
4782 assert!(
4783 orch.budget.any_exhausted(),
4784 "Budget with max_steps=0 should be immediately exhausted"
4785 );
4786 }
4787
4788 #[test]
4789 fn test_budget_step_recording() {
4790 let mut budget = perspt_core::types::BudgetEnvelope::new("test-session");
4791 budget.max_steps = Some(3);
4792 assert!(!budget.any_exhausted());
4793 budget.record_step();
4794 budget.record_step();
4795 assert!(!budget.any_exhausted());
4796 budget.record_step();
4797 assert!(budget.steps_exhausted());
4798 assert!(budget.any_exhausted());
4799 }
4800
4801 #[test]
4802 fn test_set_budget_configures_envelope() {
4803 let mut orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4804 orch.set_budget(Some(10), Some(5), Some(2.50));
4805 assert_eq!(orch.budget.max_steps, Some(10));
4806 assert_eq!(orch.budget.max_revisions, Some(5));
4807 assert_eq!(orch.budget.max_cost_usd, Some(2.50));
4808 assert!(!orch.budget.any_exhausted());
4809 }
4810
4811 #[test]
4812 fn test_node_outcome_equality() {
4813 assert_eq!(NodeOutcome::Completed, NodeOutcome::Completed);
4814 assert_eq!(NodeOutcome::Escalated, NodeOutcome::Escalated);
4815 assert_ne!(NodeOutcome::Completed, NodeOutcome::Escalated);
4816 }
4817
4818 #[test]
4819 fn test_session_outcome_from_counts() {
4820 fn derive_outcome(
4823 completed: usize,
4824 escalated: usize,
4825 total: usize,
4826 ) -> perspt_core::SessionOutcome {
4827 if escalated == 0 && completed >= total {
4828 perspt_core::SessionOutcome::Success
4829 } else if completed > 0 {
4830 perspt_core::SessionOutcome::PartialSuccess
4831 } else {
4832 perspt_core::SessionOutcome::Failed
4833 }
4834 }
4835
4836 assert_eq!(
4838 derive_outcome(3, 0, 3),
4839 perspt_core::SessionOutcome::Success,
4840 );
4841 assert_eq!(
4843 derive_outcome(2, 1, 3),
4844 perspt_core::SessionOutcome::PartialSuccess,
4845 );
4846 assert_eq!(derive_outcome(0, 3, 3), perspt_core::SessionOutcome::Failed,);
4848 assert_eq!(
4850 derive_outcome(5, 0, 20),
4851 perspt_core::SessionOutcome::PartialSuccess,
4852 );
4853 assert_eq!(
4855 derive_outcome(0, 0, 20),
4856 perspt_core::SessionOutcome::Failed,
4857 );
4858 }
4859
4860 #[test]
4861 fn test_resumed_outcome_from_counts() {
4862 fn derive_resumed_outcome(
4865 executed: usize,
4866 escalated: usize,
4867 terminal_count: usize,
4868 total: usize,
4869 ) -> perspt_core::SessionOutcome {
4870 if escalated == 0 && executed + terminal_count >= total {
4871 perspt_core::SessionOutcome::Success
4872 } else if executed > 0 {
4873 perspt_core::SessionOutcome::PartialSuccess
4874 } else {
4875 perspt_core::SessionOutcome::Failed
4876 }
4877 }
4878
4879 assert_eq!(
4881 derive_resumed_outcome(3, 0, 2, 5),
4882 perspt_core::SessionOutcome::Success,
4883 );
4884 assert_eq!(
4886 derive_resumed_outcome(2, 1, 2, 5),
4887 perspt_core::SessionOutcome::PartialSuccess,
4888 );
4889 assert_eq!(
4891 derive_resumed_outcome(1, 0, 2, 5),
4892 perspt_core::SessionOutcome::PartialSuccess,
4893 );
4894 assert_eq!(
4896 derive_resumed_outcome(0, 0, 5, 5),
4897 perspt_core::SessionOutcome::Success,
4898 );
4899 assert_eq!(
4901 derive_resumed_outcome(0, 0, 2, 5),
4902 perspt_core::SessionOutcome::Failed,
4903 );
4904 }
4905
4906 #[test]
4907 fn test_sheaf_pre_check_stub_escalates_after_retry() {
4908 let dir = tempfile::tempdir().unwrap();
4909 let stub_path = dir.path().join("stub.rs");
4910 std::fs::write(&stub_path, "fn main() {\n todo!()\n}\n").unwrap();
4911
4912 let (mut orch, idx) = orch_with_node(dir.path().to_path_buf());
4913 orch.graph[idx]
4914 .output_targets
4915 .push(std::path::PathBuf::from("stub.rs"));
4916 orch.graph[idx].owner_plugin = "rust".to_string();
4917
4918 let first = orch.sheaf_pre_check(idx);
4920 assert!(first.is_some(), "First pre-check should detect stub");
4921
4922 let second = orch.sheaf_pre_check(idx);
4925 assert!(
4926 second.is_some(),
4927 "Final guard should still detect stub after retry"
4928 );
4929 }
4930
4931 fn orch_with_node(
4933 working_dir: std::path::PathBuf,
4934 ) -> (SRBNOrchestrator, petgraph::graph::NodeIndex) {
4935 let mut orch = SRBNOrchestrator::new(working_dir, false);
4936 let node = SRBNNode::new(
4937 "test-node".to_string(),
4938 "test goal".to_string(),
4939 perspt_core::ModelTier::Actuator,
4940 );
4941 let idx = orch.add_node(node);
4942 (orch, idx)
4943 }
4944
4945 #[test]
4946 fn test_sheaf_pre_check_passes_when_no_outputs() {
4947 let (orch, idx) = orch_with_node(std::path::PathBuf::from("/tmp/test"));
4948 assert!(orch.sheaf_pre_check(idx).is_none());
4949 }
4950
4951 #[test]
4952 fn test_sheaf_pre_check_detects_missing_files() {
4953 let (mut orch, idx) = orch_with_node(std::path::PathBuf::from("/tmp/test"));
4954 orch.graph[idx]
4955 .output_targets
4956 .push(std::path::PathBuf::from("nonexistent_file_xyz.rs"));
4957 let result = orch.sheaf_pre_check(idx);
4958 assert!(result.is_some());
4959 assert!(result.unwrap().contains("missing"));
4960 }
4961
4962 #[test]
4963 fn test_sheaf_pre_check_detects_empty_files() {
4964 let dir = tempfile::tempdir().unwrap();
4965 std::fs::File::create(dir.path().join("empty.rs")).unwrap();
4966
4967 let (mut orch, idx) = orch_with_node(dir.path().to_path_buf());
4968 orch.graph[idx]
4969 .output_targets
4970 .push(std::path::PathBuf::from("empty.rs"));
4971 let result = orch.sheaf_pre_check(idx);
4972 assert!(result.is_some());
4973 assert!(result.unwrap().contains("empty"));
4974 }
4975
4976 #[test]
4977 fn test_sheaf_pre_check_passes_for_valid_files() {
4978 let dir = tempfile::tempdir().unwrap();
4979 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4980
4981 let (mut orch, idx) = orch_with_node(dir.path().to_path_buf());
4982 orch.graph[idx]
4983 .output_targets
4984 .push(std::path::PathBuf::from("main.rs"));
4985 assert!(orch.sheaf_pre_check(idx).is_none());
4986 }
4987
4988 #[test]
4989 fn test_v_boot_energy_from_degraded_sensors() {
4990 use perspt_core::types::{
4991 EnergyComponents, SensorStatus, StageOutcome, VerificationResult,
4992 };
4993
4994 let vr = VerificationResult {
4996 syntax_ok: true,
4997 build_ok: true,
4998 tests_ok: true,
4999 lint_ok: true,
5000 diagnostics_count: 0,
5001 tests_passed: 5,
5002 tests_failed: 0,
5003 summary: String::new(),
5004 raw_output: None,
5005 degraded: true,
5006 degraded_reason: Some("test sensor fallback".into()),
5007 stage_outcomes: vec![
5008 StageOutcome {
5009 stage: "syntax_check".into(),
5010 passed: true,
5011 sensor_status: SensorStatus::Available,
5012 output: None,
5013 },
5014 StageOutcome {
5015 stage: "build".into(),
5016 passed: true,
5017 sensor_status: SensorStatus::Fallback {
5018 actual: "cargo check".into(),
5019 reason: "primary not found".into(),
5020 },
5021 output: None,
5022 },
5023 StageOutcome {
5024 stage: "test".into(),
5025 passed: true,
5026 sensor_status: SensorStatus::Unavailable {
5027 reason: "no test runner".into(),
5028 },
5029 output: None,
5030 },
5031 ],
5032 };
5033
5034 let mut energy = EnergyComponents::default();
5036 for so in &vr.stage_outcomes {
5037 match &so.sensor_status {
5038 SensorStatus::Unavailable { .. } => energy.v_boot += 3.0,
5039 SensorStatus::Fallback { .. } => energy.v_boot += 1.0,
5040 SensorStatus::Available => {}
5041 }
5042 }
5043 assert!(
5045 (energy.v_boot - 4.0).abs() < f32::EPSILON,
5046 "Expected V_boot=4.0, got {}",
5047 energy.v_boot
5048 );
5049 }
5050
5051 #[test]
5054 fn test_detect_stub_rust_todo() {
5055 let dir = tempfile::tempdir().unwrap();
5056 let path = dir.path().join("lib.rs");
5057 std::fs::write(&path, "fn main() {\n todo!()\n}\n").unwrap();
5058 let result = detect_stub_content(&path, "rust");
5059 assert!(result.is_some(), "Should detect todo!() stub");
5060 assert!(result.unwrap().contains("todo!()"));
5061 }
5062
5063 #[test]
5064 fn test_detect_stub_rust_unimplemented() {
5065 let dir = tempfile::tempdir().unwrap();
5066 let path = dir.path().join("lib.rs");
5067 std::fs::write(&path, "fn run() {\n unimplemented!()\n}\n").unwrap();
5068 let result = detect_stub_content(&path, "rust");
5069 assert!(result.is_some(), "Should detect unimplemented!() stub");
5070 }
5071
5072 #[test]
5073 fn test_detect_stub_rust_real_code_not_flagged() {
5074 let dir = tempfile::tempdir().unwrap();
5075 let path = dir.path().join("lib.rs");
5076 let real_code = r#"
5077use std::collections::HashMap;
5078
5079fn add(a: i32, b: i32) -> i32 {
5080 a + b
5081}
5082
5083fn multiply(a: i32, b: i32) -> i32 {
5084 a * b
5085}
5086
5087fn compute(data: &[i32]) -> i32 {
5088 data.iter().sum()
5089}
5090
5091fn transform(input: &str) -> String {
5092 input.to_uppercase()
5093}
5094
5095fn process() {
5096 let x = add(1, 2);
5097 let y = multiply(x, 3);
5098 println!("{}", y);
5099 // todo!() in a comment should not trigger
5100}
5101"#;
5102 std::fs::write(&path, real_code).unwrap();
5103 let result = detect_stub_content(&path, "rust");
5104 assert!(
5105 result.is_none(),
5106 "Real code with comment-only todo should not be flagged"
5107 );
5108 }
5109
5110 #[test]
5111 fn test_detect_stub_rust_real_code_with_one_todo_branch() {
5112 let dir = tempfile::tempdir().unwrap();
5113 let path = dir.path().join("lib.rs");
5114 let code = r#"
5115fn add(a: i32, b: i32) -> i32 { a + b }
5116fn sub(a: i32, b: i32) -> i32 { a - b }
5117fn mul(a: i32, b: i32) -> i32 { a * b }
5118fn div(a: i32, b: i32) -> i32 { a / b }
5119fn modulo(a: i32, b: i32) -> i32 { todo!() }
5120"#;
5121 std::fs::write(&path, code).unwrap();
5122 let result = detect_stub_content(&path, "rust");
5123 assert!(
5124 result.is_none(),
5125 "File with 5+ real lines and one todo!() should NOT be flagged"
5126 );
5127 }
5128
5129 #[test]
5130 fn test_detect_stub_python_pass_body() {
5131 let dir = tempfile::tempdir().unwrap();
5132 let path = dir.path().join("main.py");
5133 std::fs::write(&path, "def run():\n pass\n").unwrap();
5134 let result = detect_stub_content(&path, "python");
5135 assert!(result.is_some(), "Should detect pass-only Python function");
5136 }
5137
5138 #[test]
5139 fn test_detect_stub_python_not_implemented() {
5140 let dir = tempfile::tempdir().unwrap();
5141 let path = dir.path().join("main.py");
5142 std::fs::write(&path, "def run():\n raise NotImplementedError()\n").unwrap();
5143 let result = detect_stub_content(&path, "python");
5144 assert!(result.is_some(), "Should detect NotImplementedError stub");
5145 }
5146
5147 #[test]
5148 fn test_detect_stub_python_ellipsis_body() {
5149 let dir = tempfile::tempdir().unwrap();
5150 let path = dir.path().join("main.py");
5151 std::fs::write(&path, "def run():\n ...\n").unwrap();
5152 let result = detect_stub_content(&path, "python");
5153 assert!(
5154 result.is_some(),
5155 "Should detect ellipsis-only Python function"
5156 );
5157 }
5158
5159 #[test]
5160 fn test_detect_stub_python_real_code_not_flagged() {
5161 let dir = tempfile::tempdir().unwrap();
5162 let path = dir.path().join("main.py");
5163 let code = "import os\n\ndef run():\n data = os.listdir('.')\n filtered = [f for f in data if f.endswith('.py')]\n for f in filtered:\n print(f)\n return filtered\n";
5164 std::fs::write(&path, code).unwrap();
5165 let result = detect_stub_content(&path, "python");
5166 assert!(result.is_none(), "Real Python code should not be flagged");
5167 }
5168
5169 #[test]
5170 fn test_detect_stub_js_throw_not_implemented() {
5171 let dir = tempfile::tempdir().unwrap();
5172 let path = dir.path().join("index.js");
5173 std::fs::write(
5174 &path,
5175 "function run() {\n throw new Error(\"not implemented\");\n}\n",
5176 )
5177 .unwrap();
5178 let result = detect_stub_content(&path, "javascript");
5179 assert!(
5180 result.is_some(),
5181 "Should detect JS throw not-implemented stub"
5182 );
5183 }
5184
5185 #[test]
5186 fn test_detect_stub_universal_comment() {
5187 let dir = tempfile::tempdir().unwrap();
5188 let path = dir.path().join("lib.rs");
5189 std::fs::write(&path, "// stub — will be replaced by agent\n").unwrap();
5190 let result = detect_stub_content(&path, "rust");
5191 assert!(result.is_some(), "Should detect universal stub comment");
5192 }
5193
5194 #[test]
5195 fn test_detect_stub_extension_fallback() {
5196 let dir = tempfile::tempdir().unwrap();
5197 let path = dir.path().join("main.py");
5198 std::fs::write(&path, "# placeholder\ndef run():\n pass\n").unwrap();
5199 let result = detect_stub_content(&path, "unknown");
5201 assert!(
5202 result.is_some(),
5203 "Should detect stub via extension fallback"
5204 );
5205 }
5206
5207 #[test]
5208 fn test_detect_stub_empty_file_returns_none() {
5209 let dir = tempfile::tempdir().unwrap();
5210 let path = dir.path().join("empty.rs");
5211 std::fs::write(&path, "").unwrap();
5212 let result = detect_stub_content(&path, "rust");
5215 assert!(result.is_none(), "Empty file has no stub pattern to match");
5216 }
5217}