1mod bundle;
6mod commit;
7mod convergence;
8mod init;
9mod planning;
10mod repair;
11pub mod sdk_bridge;
12mod solo;
13mod verification;
14
15use crate::agent::{ActuatorAgent, Agent, ArchitectAgent, SpeculatorAgent, VerifierAgent};
16use crate::context_retriever::ContextRetriever;
17use crate::lsp::LspClient;
18use crate::test_runner::{self};
19use crate::tools::{AgentTools, ToolCall};
20use crate::types::{AgentContext, EnergyComponents, ModelTier, NodeState, SRBNNode, TaskPlan};
21use anyhow::{Context, Result};
22use perspt_core::types::{
23 EscalationCategory, EscalationReport, NodeClass, ProvisionalBranch, ProvisionalBranchState,
24 RewriteAction, RewriteRecord, SheafValidationResult, SheafValidatorClass, WorkspaceState,
25};
26use petgraph::graph::{DiGraph, NodeIndex};
27use petgraph::visit::{EdgeRef, Topo, Walker};
28use std::collections::HashMap;
29use std::path::PathBuf;
30use std::sync::atomic::{AtomicBool, Ordering};
31use std::sync::Arc;
32use std::time::Instant;
33
34#[derive(Debug, Clone)]
36pub struct Dependency {
37 pub kind: String,
39}
40
41#[derive(Debug, Clone)]
43pub enum ApprovalResult {
44 Approved,
46 ApprovedWithEdit(String),
48 Rejected,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum NodeOutcome {
55 Completed,
57 Escalated,
59 Reworked,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67enum SettleDecision {
68 Achieved,
70 Replanned,
72 Stop,
74}
75
76#[derive(Debug, Clone)]
80struct ReplanState {
81 count: usize,
83 max: usize,
85 last_completed: usize,
87 last_phi: Option<f64>,
89}
90
91impl ReplanState {
92 fn new(max: usize) -> Self {
93 Self {
94 count: 0,
95 max,
96 last_completed: 0,
97 last_phi: None,
98 }
99 }
100}
101
102fn parse_goal_verdict(resp: &str) -> Option<perspt_core::types::GoalVerdict> {
106 let start = resp.find('{')?;
107 let end = resp.rfind('}')?;
108 if end <= start {
109 return None;
110 }
111 serde_json::from_str(&resp[start..=end]).ok()
112}
113
114pub struct SRBNOrchestrator {
116 pub graph: DiGraph<SRBNNode, Dependency>,
118 node_indices: HashMap<String, NodeIndex>,
120 pub context: AgentContext,
122 pub auto_approve: bool,
124 lsp_clients: HashMap<String, LspClient>,
126 agents: Vec<Box<dyn Agent>>,
128 tools: AgentTools,
130 last_written_file: Option<PathBuf>,
132 file_version: i32,
134 provider: std::sync::Arc<perspt_core::llm_provider::GenAIProvider>,
136 architect_model: String,
138 actuator_model: String,
140 verifier_model: String,
142 speculator_model: String,
144 architect_fallback_model: Option<String>,
146 actuator_fallback_model: Option<String>,
148 verifier_fallback_model: Option<String>,
150 speculator_fallback_model: Option<String>,
152 event_sender: Option<perspt_core::events::channel::EventSender>,
154 action_receiver: Option<perspt_core::events::channel::ActionReceiver>,
156 pub ledger: crate::ledger::MerkleLedger,
158 pub last_tool_failure: Option<String>,
160 last_context_provenance: Option<perspt_core::types::ContextProvenance>,
162 last_formatted_context: String,
164 last_verification_result: Option<perspt_core::types::VerificationResult>,
166 sdk_gate: sdk_bridge::SdkGateState,
170 last_applied_bundle: Option<perspt_core::types::ArtifactBundle>,
172 last_repair_footprint: Option<perspt_core::RepairFootprint>,
174 blocked_dependencies: Vec<perspt_core::types::BlockedDependency>,
176 budget: perspt_core::types::BudgetEnvelope,
178 pub planning_policy: perspt_core::PlanningPolicy,
180 pub package_manager: Option<String>,
184 pub stability_epsilon: f32,
186 pub energy_alpha: f32,
188 pub energy_beta: f32,
190 pub energy_gamma: f32,
192 abort_requested: Arc<AtomicBool>,
194}
195
196fn epoch_seconds() -> i64 {
198 use std::time::{SystemTime, UNIX_EPOCH};
199 SystemTime::now()
200 .duration_since(UNIX_EPOCH)
201 .unwrap()
202 .as_secs() as i64
203}
204
205fn detect_stub_content(path: &std::path::Path, plugin_hint: &str) -> Option<String> {
214 let content = std::fs::read_to_string(path).ok()?;
215
216 let lang = if !plugin_hint.is_empty() && plugin_hint != "unknown" {
218 plugin_hint.to_ascii_lowercase()
219 } else {
220 path.extension()
221 .and_then(|e| e.to_str())
222 .map(|e| match e {
223 "rs" => "rust",
224 "py" => "python",
225 "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" => "javascript",
226 _ => "",
227 })
228 .unwrap_or("")
229 .to_string()
230 };
231
232 let universal_patterns = [
234 "// stub",
235 "# stub",
236 "// placeholder",
237 "# placeholder",
238 "// will be replaced",
239 "# will be replaced",
240 "/* todo */",
241 ];
242
243 let lang_patterns: &[&str] = match lang.as_str() {
245 "rust" => &["todo!()", "unimplemented!()"],
246 "python" => &["raise NotImplementedError", "raise NotImplementedError()"],
247 "javascript" | "typescript" => &[
248 "throw new Error(\"not implemented\")",
249 "throw new Error('not implemented')",
250 "throw new Error(\"TODO\")",
251 "throw new Error('TODO')",
252 ],
253 _ => &[],
254 };
255
256 let content_lower = content.to_ascii_lowercase();
257
258 let mut matched_pattern = None;
260 for pat in &universal_patterns {
261 if content_lower.contains(pat) {
262 matched_pattern = Some(*pat);
263 break;
264 }
265 }
266 if matched_pattern.is_none() {
267 for pat in lang_patterns {
268 if content.contains(pat) {
269 matched_pattern = Some(*pat);
270 break;
271 }
272 }
273 }
274
275 if matched_pattern.is_none() && lang == "python" {
277 let trimmed_lines: Vec<&str> = content
278 .lines()
279 .map(|l| l.trim())
280 .filter(|l| !l.is_empty() && !l.starts_with('#'))
281 .collect();
282 let body_only: Vec<&&str> = trimmed_lines
283 .iter()
284 .filter(|l| {
285 !l.starts_with("def ")
286 && !l.starts_with("class ")
287 && !l.starts_with("import ")
288 && !l.starts_with("from ")
289 })
290 .collect();
291 if body_only.len() <= 2 && body_only.iter().all(|l| **l == "pass" || **l == "...") {
292 matched_pattern = Some("only pass/... body");
293 }
294 }
295
296 let pattern = matched_pattern?;
297
298 let real_lines = count_real_code_lines(&content, &lang);
300 if real_lines >= 5 {
301 return None;
304 }
305
306 Some(format!(
307 "found '{}' with only {} line(s) of real code",
308 pattern, real_lines
309 ))
310}
311
312fn count_real_code_lines(content: &str, lang: &str) -> usize {
314 content
315 .lines()
316 .filter(|line| {
317 let trimmed = line.trim();
318 if trimmed.is_empty() {
319 return false;
320 }
321 match lang {
323 "rust" => {
324 if trimmed.starts_with("//")
325 || trimmed.starts_with("/*")
326 || trimmed.starts_with('*')
327 {
328 return false;
329 }
330 if trimmed.starts_with("use ")
332 || trimmed.starts_with("extern ")
333 || trimmed.starts_with("mod ")
334 {
335 return false;
336 }
337 }
338 "python" => {
339 if trimmed.starts_with('#')
340 || trimmed.starts_with("\"\"\"")
341 || trimmed.starts_with("'''")
342 {
343 return false;
344 }
345 if trimmed.starts_with("import ") || trimmed.starts_with("from ") {
346 return false;
347 }
348 }
349 "javascript" | "typescript" => {
350 if trimmed.starts_with("//")
351 || trimmed.starts_with("/*")
352 || trimmed.starts_with('*')
353 {
354 return false;
355 }
356 if trimmed.starts_with("import ")
357 || trimmed.starts_with("require(")
358 || trimmed.starts_with("const ") && trimmed.contains("require(")
359 {
360 return false;
361 }
362 }
363 _ => {
364 if trimmed.starts_with("//")
365 || trimmed.starts_with('#')
366 || trimmed.starts_with("/*")
367 {
368 return false;
369 }
370 }
371 }
372 true
373 })
374 .count()
375}
376
377impl SRBNOrchestrator {
378 pub fn new(working_dir: PathBuf, auto_approve: bool) -> Self {
380 Self::new_with_models(
381 working_dir,
382 auto_approve,
383 None,
384 None,
385 None,
386 None,
387 None,
388 None,
389 None,
390 None,
391 )
392 }
393
394 #[allow(clippy::too_many_arguments)]
396 pub fn new_with_models(
397 working_dir: PathBuf,
398 auto_approve: bool,
399 architect_model: Option<String>,
400 actuator_model: Option<String>,
401 verifier_model: Option<String>,
402 speculator_model: Option<String>,
403 architect_fallback_model: Option<String>,
404 actuator_fallback_model: Option<String>,
405 verifier_fallback_model: Option<String>,
406 speculator_fallback_model: Option<String>,
407 ) -> Self {
408 let provider = std::sync::Arc::new(
410 perspt_core::llm_provider::GenAIProvider::new().unwrap_or_else(|e| {
411 log::warn!("Failed to create GenAIProvider: {}, using default", e);
412 perspt_core::llm_provider::GenAIProvider::new().expect("GenAI must initialize")
413 }),
414 );
415
416 Self::new_with_models_and_provider(
417 working_dir,
418 auto_approve,
419 provider,
420 architect_model,
421 actuator_model,
422 verifier_model,
423 speculator_model,
424 architect_fallback_model,
425 actuator_fallback_model,
426 verifier_fallback_model,
427 speculator_fallback_model,
428 )
429 }
430
431 #[allow(clippy::too_many_arguments)]
438 pub fn new_with_models_and_provider(
439 working_dir: PathBuf,
440 auto_approve: bool,
441 provider: std::sync::Arc<perspt_core::llm_provider::GenAIProvider>,
442 architect_model: Option<String>,
443 actuator_model: Option<String>,
444 verifier_model: Option<String>,
445 speculator_model: Option<String>,
446 architect_fallback_model: Option<String>,
447 actuator_fallback_model: Option<String>,
448 verifier_fallback_model: Option<String>,
449 speculator_fallback_model: Option<String>,
450 ) -> Self {
451 let context = AgentContext {
452 working_dir: working_dir.clone(),
453 auto_approve,
454 ..Default::default()
455 };
456
457 let tools = AgentTools::new(working_dir.clone(), !auto_approve);
459
460 let stored_architect_model = architect_model
462 .clone()
463 .unwrap_or_else(|| ModelTier::Architect.default_model().to_string());
464 let stored_actuator_model = actuator_model
465 .clone()
466 .unwrap_or_else(|| ModelTier::Actuator.default_model().to_string());
467 let stored_verifier_model = verifier_model
468 .clone()
469 .unwrap_or_else(|| ModelTier::Verifier.default_model().to_string());
470 let stored_speculator_model = speculator_model
471 .clone()
472 .unwrap_or_else(|| ModelTier::Speculator.default_model().to_string());
473
474 Self {
475 graph: DiGraph::new(),
476 node_indices: HashMap::new(),
477 context,
478 auto_approve,
479 lsp_clients: HashMap::new(),
480 agents: vec![
481 Box::new(ArchitectAgent::new(provider.clone(), architect_model)),
482 Box::new(ActuatorAgent::new(provider.clone(), actuator_model)),
483 Box::new(VerifierAgent::new(provider.clone(), verifier_model)),
484 Box::new(SpeculatorAgent::new(provider.clone(), speculator_model)),
485 ],
486 tools,
487 last_written_file: None,
488 file_version: 0,
489 provider,
490 architect_model: stored_architect_model,
491 actuator_model: stored_actuator_model,
492 verifier_model: stored_verifier_model,
493 speculator_model: stored_speculator_model,
494 architect_fallback_model,
495 actuator_fallback_model,
496 verifier_fallback_model,
497 speculator_fallback_model,
498 event_sender: None,
499 action_receiver: None,
500 #[cfg(test)]
501 ledger: crate::ledger::MerkleLedger::in_memory().expect("Failed to create test ledger"),
502 #[cfg(not(test))]
503 ledger: crate::ledger::MerkleLedger::new().expect("Failed to create ledger"),
504 last_tool_failure: None,
505 last_context_provenance: None,
506 last_formatted_context: String::new(),
507 last_verification_result: None,
508 sdk_gate: sdk_bridge::SdkGateState::new(),
509 last_applied_bundle: None,
510 last_repair_footprint: None,
511 blocked_dependencies: Vec::new(),
512 budget: perspt_core::types::BudgetEnvelope::new("pending"),
513 planning_policy: perspt_core::PlanningPolicy::default(),
514 package_manager: None,
515 stability_epsilon: 0.1,
516 energy_alpha: 1.0,
517 energy_beta: 0.5,
518 energy_gamma: 2.0,
519 abort_requested: Arc::new(AtomicBool::new(false)),
520 }
521 }
522
523 #[cfg(test)]
525 pub fn new_for_testing(working_dir: PathBuf) -> Self {
526 let context = AgentContext {
527 working_dir: working_dir.clone(),
528 auto_approve: true,
529 ..Default::default()
530 };
531
532 let provider = std::sync::Arc::new(
533 perspt_core::llm_provider::GenAIProvider::new().unwrap_or_else(|e| {
534 log::warn!("Failed to create GenAIProvider: {}, using default", e);
535 perspt_core::llm_provider::GenAIProvider::new().expect("GenAI must initialize")
536 }),
537 );
538
539 let tools = AgentTools::new(working_dir.clone(), false);
540
541 Self {
542 graph: DiGraph::new(),
543 node_indices: HashMap::new(),
544 context,
545 auto_approve: true,
546 lsp_clients: HashMap::new(),
547 agents: vec![
548 Box::new(ArchitectAgent::new(provider.clone(), None)),
549 Box::new(ActuatorAgent::new(provider.clone(), None)),
550 Box::new(VerifierAgent::new(provider.clone(), None)),
551 Box::new(SpeculatorAgent::new(provider.clone(), None)),
552 ],
553 tools,
554 last_written_file: None,
555 file_version: 0,
556 provider,
557 architect_model: ModelTier::Architect.default_model().to_string(),
558 actuator_model: ModelTier::Actuator.default_model().to_string(),
559 verifier_model: ModelTier::Verifier.default_model().to_string(),
560 speculator_model: ModelTier::Speculator.default_model().to_string(),
561 architect_fallback_model: None,
562 actuator_fallback_model: None,
563 verifier_fallback_model: None,
564 speculator_fallback_model: None,
565 event_sender: None,
566 action_receiver: None,
567 ledger: crate::ledger::MerkleLedger::in_memory().expect("Failed to create test ledger"),
568 last_tool_failure: None,
569 last_context_provenance: None,
570 last_formatted_context: String::new(),
571 last_verification_result: None,
572 sdk_gate: sdk_bridge::SdkGateState::new(),
573 last_applied_bundle: None,
574 last_repair_footprint: None,
575 blocked_dependencies: Vec::new(),
576 budget: perspt_core::types::BudgetEnvelope::new("test"),
577 planning_policy: perspt_core::PlanningPolicy::default(),
578 package_manager: None,
579 stability_epsilon: 0.1,
580 energy_alpha: 1.0,
581 energy_beta: 0.5,
582 energy_gamma: 2.0,
583 abort_requested: Arc::new(AtomicBool::new(false)),
584 }
585 }
586
587 pub fn add_node(&mut self, node: SRBNNode) -> NodeIndex {
589 let node_id = node.node_id.clone();
590 let idx = self.graph.add_node(node);
591 self.node_indices.insert(node_id, idx);
592 idx
593 }
594
595 pub fn connect_tui(
597 &mut self,
598 event_sender: perspt_core::events::channel::EventSender,
599 action_receiver: perspt_core::events::channel::ActionReceiver,
600 ) {
601 self.tools.set_event_sender(event_sender.clone());
602 self.event_sender = Some(event_sender);
603 self.action_receiver = Some(action_receiver);
604 }
605
606 pub fn abort_flag(&self) -> Arc<AtomicBool> {
608 self.abort_requested.clone()
609 }
610
611 fn is_abort_requested(&self) -> bool {
613 self.abort_requested.load(Ordering::Relaxed)
614 }
615
616 fn finalize_session(&mut self, result: &Result<perspt_core::SessionOutcome>) {
618 let status = if self.is_abort_requested() {
619 "ABORTED"
620 } else {
621 match result {
622 Ok(perspt_core::SessionOutcome::Success) => "COMPLETED",
623 Ok(perspt_core::SessionOutcome::PartialSuccess) => "PARTIAL",
624 Ok(perspt_core::SessionOutcome::Failed) | Err(_) => "FAILED",
625 }
626 };
627 if let Err(e) = self.ledger.end_session(status) {
628 log::error!("Failed to finalize session as {}: {}", status, e);
629 }
630 }
631
632 pub fn set_budget(
637 &mut self,
638 max_steps: Option<u32>,
639 max_revisions: Option<u32>,
640 max_cost_usd: Option<f64>,
641 ) {
642 self.budget.max_steps = max_steps;
643 self.budget.max_revisions = max_revisions;
644 self.budget.max_cost_usd = max_cost_usd;
645 }
646
647 pub fn set_package_manager(&mut self, pm: Option<String>) {
652 self.package_manager = pm;
653 }
654
655 pub fn set_energy_weights(&mut self, alpha: f32, beta: f32, gamma: f32) {
660 self.energy_alpha = alpha;
661 self.energy_beta = beta;
662 self.energy_gamma = gamma;
663 self.sdk_gate.set_energy_weights(alpha, beta, gamma);
664 }
665
666 pub fn rehydrate_session(
681 &mut self,
682 session_id: &str,
683 ) -> Result<crate::ledger::SessionSnapshot> {
684 self.context.session_id = session_id.to_string();
686 self.ledger.current_session = Some(crate::ledger::SessionRecordLegacy {
687 session_id: session_id.to_string(),
688 task: String::new(),
689 started_at: epoch_seconds(),
690 ended_at: None,
691 status: "RESUMING".to_string(),
692 total_nodes: 0,
693 completed_nodes: 0,
694 });
695
696 let snapshot = self.ledger.load_session_snapshot()?;
697
698 if let Ok(Some(row)) = self.ledger.get_budget_envelope() {
701 self.budget = perspt_core::types::BudgetEnvelope {
702 session_id: row.session_id,
703 max_steps: row.max_steps.map(|v| v as u32),
704 steps_used: row.steps_used as u32,
705 max_revisions: row.max_revisions.map(|v| v as u32),
706 revisions_used: row.revisions_used as u32,
707 max_cost_usd: row.max_cost_usd,
708 cost_used_usd: row.cost_used_usd,
709 };
710 log::info!(
711 "Restored budget envelope: steps {}/{:?}, revisions {}/{:?}, cost ${:.2}/{:?}",
712 self.budget.steps_used,
713 self.budget.max_steps,
714 self.budget.revisions_used,
715 self.budget.max_revisions,
716 self.budget.cost_used_usd,
717 self.budget.max_cost_usd,
718 );
719 }
720
721 if snapshot.node_details.is_empty() {
723 anyhow::bail!(
724 "Session {} has no persisted nodes — cannot resume",
725 session_id
726 );
727 }
728
729 let node_ids: std::collections::HashSet<&str> = snapshot
731 .node_details
732 .iter()
733 .map(|d| d.record.node_id.as_str())
734 .collect();
735 let orphaned_edges = snapshot
736 .graph_edges
737 .iter()
738 .filter(|e| {
739 !node_ids.contains(e.parent_node_id.as_str())
740 || !node_ids.contains(e.child_node_id.as_str())
741 })
742 .count();
743 if orphaned_edges > 0 {
744 log::warn!(
745 "Session {} has {} orphaned edge(s) referencing unknown nodes — \
746 edges will be dropped during resume",
747 session_id,
748 orphaned_edges
749 );
750 self.emit_log(format!(
751 "⚠️ Resume: dropping {} orphaned graph edge(s)",
752 orphaned_edges
753 ));
754 }
755
756 let mut node_map: HashMap<String, NodeIndex> = HashMap::new();
758
759 for detail in &snapshot.node_details {
760 let rec = &detail.record;
761
762 let state = parse_node_state(&rec.state);
763 let node_class = rec
764 .node_class
765 .as_deref()
766 .map(parse_node_class)
767 .unwrap_or_default();
768
769 let mut node = SRBNNode::new(
770 rec.node_id.clone(),
771 rec.goal.clone().unwrap_or_default(),
772 ModelTier::Actuator,
773 );
774 node.state = state;
775 node.node_class = node_class;
776 node.owner_plugin = rec.owner_plugin.clone().unwrap_or_default();
777 node.parent_id = rec.parent_id.clone();
778 node.children = rec
779 .children
780 .as_deref()
781 .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
782 .unwrap_or_default();
783 node.monitor.attempt_count = rec.attempt_count as usize;
784
785 if let Some(last_energy) = detail.energy_history.last() {
787 node.monitor.energy_history.push(last_energy.v_total);
788 }
789
790 if let Some(seal) = detail.interface_seals.last() {
792 if seal.seal_hash.len() == 32 {
793 let mut hash = [0u8; 32];
794 hash.copy_from_slice(&seal.seal_hash);
795 node.interface_seal_hash = Some(hash);
796 }
797 }
798
799 let idx = self.add_node(node);
800 node_map.insert(rec.node_id.clone(), idx);
801 }
802
803 for edge in &snapshot.graph_edges {
805 if let (Some(&from_idx), Some(&to_idx)) = (
806 node_map.get(&edge.parent_node_id),
807 node_map.get(&edge.child_node_id),
808 ) {
809 self.graph.add_edge(
810 from_idx,
811 to_idx,
812 Dependency {
813 kind: edge.edge_type.clone(),
814 },
815 );
816 }
817 }
818
819 for (child_id, &child_idx) in &node_map {
821 let parents: Vec<NodeIndex> = self
822 .graph
823 .neighbors_directed(child_idx, petgraph::Direction::Incoming)
824 .collect();
825
826 for parent_idx in parents {
827 let parent = &self.graph[parent_idx];
828 if parent.node_class == NodeClass::Interface
829 && parent.interface_seal_hash.is_none()
830 && !parent.state.is_terminal()
831 {
832 self.blocked_dependencies
833 .push(perspt_core::types::BlockedDependency {
834 child_node_id: child_id.clone(),
835 parent_node_id: parent.node_id.clone(),
836 required_seal_paths: Vec::new(),
837 blocked_at: epoch_seconds(),
838 });
839 }
840 }
841 }
842
843 let terminal = snapshot
844 .node_details
845 .iter()
846 .filter(|d| {
847 let s = parse_node_state(&d.record.state);
848 s.is_terminal()
849 })
850 .count();
851 let resumable = snapshot.node_details.len() - terminal;
852
853 log::info!(
854 "Rehydrated session {}: {} nodes ({} terminal, {} resumable), {} edges",
855 session_id,
856 snapshot.node_details.len(),
857 terminal,
858 resumable,
859 snapshot.graph_edges.len()
860 );
861
862 if let Some(ref mut sess) = self.ledger.current_session {
864 sess.total_nodes = snapshot.node_details.len();
865 sess.completed_nodes = terminal;
866 sess.status = "RUNNING".to_string();
867 }
868
869 for detail in &snapshot.node_details {
873 let state = parse_node_state(&detail.record.state);
874 if state.is_terminal() {
875 continue;
876 }
877
878 if let Some(ref prov) = detail.context_provenance {
879 let retriever = ContextRetriever::new(self.context.working_dir.clone());
880 let drift = retriever.validate_provenance_record(prov);
881 if !drift.is_empty() {
882 log::warn!(
883 "Provenance drift for node '{}': {} file(s) missing: {}",
884 detail.record.node_id,
885 drift.len(),
886 drift.join(", ")
887 );
888 self.emit_log(format!(
889 "⚠️ Provenance drift: node '{}' has {} missing file(s)",
890 detail.record.node_id,
891 drift.len()
892 ));
893 self.emit_event(perspt_core::AgentEvent::ProvenanceDrift {
894 node_id: detail.record.node_id.clone(),
895 missing_files: drift,
896 reason: "Files referenced in persisted context no longer exist".to_string(),
897 });
898 }
899 }
900 }
901
902 Ok(snapshot)
903 }
904
905 pub async fn run_resumed(&mut self) -> Result<()> {
912 let result = self.run_resumed_inner().await;
913 self.finalize_session(&result);
914 result.map(|_| ())
915 }
916
917 async fn run_resumed_inner(&mut self) -> Result<perspt_core::SessionOutcome> {
919 let topo = Topo::new(&self.graph);
920 let indices: Vec<_> = topo.iter(&self.graph).collect();
921 let total_nodes = indices.len();
922 let mut executed = 0;
923 let mut escalated: usize = 0;
924
925 let terminal_count = indices
927 .iter()
928 .filter(|i| self.graph[**i].state.is_terminal())
929 .count();
930 let blocked_count = indices
931 .iter()
932 .filter(|i| !self.graph[**i].state.is_terminal() && self.check_seal_prerequisites(**i))
933 .count();
934 let resumable_count = total_nodes - terminal_count - blocked_count;
935 self.emit_log(format!(
936 "📊 Differential resume: {} total, {} skipped (terminal), {} blocked (seal), {} to execute",
937 total_nodes, terminal_count, blocked_count, resumable_count
938 ));
939
940 for (i, idx) in indices.iter().enumerate() {
941 if self.is_abort_requested() {
943 self.emit_log("⚠️ Session aborted — stopping resumed execution".to_string());
944 break;
945 }
946
947 if self.budget.any_exhausted() {
949 let node_id = self.graph[*idx].node_id.clone();
950 self.emit_log(format!(
951 "⛔ Budget exhausted — skipping node '{}' and remaining nodes",
952 node_id
953 ));
954 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
955 node_id,
956 status: perspt_core::NodeStatus::Escalated,
957 });
958 break;
959 }
960
961 let node = &self.graph[*idx];
962
963 if node.state.is_terminal() {
965 log::debug!("Skipping terminal node {} ({:?})", node.node_id, node.state);
966 continue;
967 }
968
969 if self.check_seal_prerequisites(*idx) {
971 log::warn!(
972 "Node {} blocked on seal prerequisite — skipping",
973 self.graph[*idx].node_id
974 );
975 continue;
976 }
977
978 let node = &self.graph[*idx];
979 self.emit_log(format!(
980 "📝 [resume {}/{}] {}",
981 i + 1,
982 total_nodes,
983 node.goal
984 ));
985 self.emit_event(perspt_core::AgentEvent::NodeSelected {
986 node_id: node.node_id.clone(),
987 goal: node.goal.clone(),
988 node_class: node.node_class.to_string(),
989 });
990 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
991 node_id: node.node_id.clone(),
992 status: perspt_core::NodeStatus::Running,
993 });
994
995 match self.execute_node(*idx).await {
996 Ok(NodeOutcome::Completed) => {
997 executed += 1;
998 self.budget.record_step();
999
1000 if let Err(e) = self.ledger.upsert_budget_envelope(&self.budget) {
1002 log::warn!("Failed to persist budget envelope: {}", e);
1003 }
1004
1005 if let Some(node) = self.graph.node_weight(*idx) {
1006 self.emit_event(perspt_core::AgentEvent::NodeCompleted {
1007 node_id: node.node_id.clone(),
1008 goal: node.goal.clone(),
1009 });
1010 }
1011 }
1012 Ok(NodeOutcome::Escalated) => {
1013 escalated += 1;
1014 self.budget.record_step();
1015 continue;
1016 }
1017 Ok(NodeOutcome::Reworked) => {
1018 self.budget.record_step();
1023 continue;
1024 }
1025 Err(e) => {
1026 escalated += 1;
1027 let node_id = self.graph[*idx].node_id.clone();
1028 log::error!("Node {} failed on resume: {}", node_id, e);
1029 self.emit_log(format!("❌ Node {} failed: {}", node_id, e));
1030 self.graph[*idx].state = NodeState::Escalated;
1031 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1032 node_id,
1033 status: perspt_core::NodeStatus::Escalated,
1034 });
1035 continue;
1036 }
1037 }
1038 }
1039
1040 log::info!(
1041 "Resumed execution completed: {} of {} nodes executed",
1042 executed,
1043 total_nodes
1044 );
1045
1046 let outcome = if escalated == 0 && executed + terminal_count >= total_nodes {
1049 perspt_core::SessionOutcome::Success
1050 } else if executed > 0 {
1051 perspt_core::SessionOutcome::PartialSuccess
1052 } else {
1053 perspt_core::SessionOutcome::Failed
1054 };
1055 self.emit_event(perspt_core::AgentEvent::Complete {
1056 success: outcome == perspt_core::SessionOutcome::Success,
1057 message: format!(
1058 "Resumed: {}/{} completed, {} escalated",
1059 executed, total_nodes, escalated
1060 ),
1061 });
1062 Ok(outcome)
1063 }
1064
1065 fn emit_event(&self, event: perspt_core::AgentEvent) {
1067 if let Some(ref sender) = self.event_sender {
1068 let _ = sender.send(event);
1069 }
1070 }
1071
1072 fn emit_log(&self, msg: impl Into<String>) {
1074 self.emit_event(perspt_core::AgentEvent::Log(msg.into()));
1075 }
1076
1077 fn record_step_quietly(
1079 &self,
1080 node_id: &str,
1081 step: &str,
1082 outcome: &str,
1083 energy: Option<&perspt_core::types::EnergyComponents>,
1084 attempt_count: i32,
1085 duration_ms: i32,
1086 ) {
1087 let record = perspt_store::SrbnStepRecord {
1088 session_id: self.context.session_id.clone(),
1089 node_id: node_id.to_string(),
1090 step: step.to_string(),
1091 outcome: outcome.to_string(),
1092 energy_json: energy.and_then(|e| serde_json::to_string(e).ok()),
1093 parse_state: None,
1094 retry_classification: None,
1095 attempt_count,
1096 duration_ms,
1097 };
1098 if let Err(e) = self.ledger.record_step(&record) {
1099 log::warn!("Failed to record step '{}' for {}: {}", step, node_id, e);
1100 }
1101 }
1102
1103 async fn await_approval(
1107 &mut self,
1108 action_type: perspt_core::ActionType,
1109 description: String,
1110 diff: Option<String>,
1111 ) -> ApprovalResult {
1112 self.await_approval_for_node(action_type, description, diff, None)
1113 .await
1114 }
1115
1116 async fn await_approval_for_node(
1118 &mut self,
1119 action_type: perspt_core::ActionType,
1120 description: String,
1121 diff: Option<String>,
1122 review_node_id: Option<&str>,
1123 ) -> ApprovalResult {
1124 if self.auto_approve {
1126 if let Some(nid) = review_node_id {
1127 self.persist_review_decision(nid, "auto_approved", None);
1128 }
1129 return ApprovalResult::Approved;
1130 }
1131
1132 if self.action_receiver.is_none() {
1134 if let Some(nid) = review_node_id {
1135 self.persist_review_decision(nid, "auto_approved", None);
1136 }
1137 return ApprovalResult::Approved;
1138 }
1139
1140 let request_id = uuid::Uuid::new_v4().to_string();
1142
1143 self.emit_event(perspt_core::AgentEvent::ApprovalRequest {
1145 request_id: request_id.clone(),
1146 node_id: review_node_id.unwrap_or("current").to_string(),
1147 action_type,
1148 description,
1149 diff,
1150 });
1151
1152 if let Some(ref mut receiver) = self.action_receiver {
1154 while let Some(action) = receiver.recv().await {
1155 match action {
1156 perspt_core::AgentAction::Approve { request_id: rid } if rid == request_id => {
1157 self.emit_log("✓ Approved by user");
1158 if let Some(nid) = review_node_id {
1159 self.persist_review_decision(nid, "approved", None);
1160 }
1161 return ApprovalResult::Approved;
1162 }
1163 perspt_core::AgentAction::ApproveWithEdit {
1164 request_id: rid,
1165 edited_value,
1166 } if rid == request_id => {
1167 self.emit_log(format!("✓ Approved with edit: {}", edited_value));
1168 if let Some(nid) = review_node_id {
1169 self.persist_review_decision(nid, "approved_with_edit", None);
1170 }
1171 return ApprovalResult::ApprovedWithEdit(edited_value);
1172 }
1173 perspt_core::AgentAction::Reject {
1174 request_id: rid,
1175 reason,
1176 } if rid == request_id => {
1177 let msg = reason.unwrap_or_else(|| "User rejected".to_string());
1178 self.emit_log(format!("✗ Rejected: {}", msg));
1179 if let Some(nid) = review_node_id {
1180 self.persist_review_decision(nid, "rejected", Some(&msg));
1181 }
1182 return ApprovalResult::Rejected;
1183 }
1184 perspt_core::AgentAction::RequestCorrection {
1185 request_id: rid,
1186 feedback,
1187 } if rid == request_id => {
1188 self.emit_log(format!("🔄 Correction requested: {}", feedback));
1189 if let Some(nid) = review_node_id {
1190 self.persist_review_decision(
1191 nid,
1192 "correction_requested",
1193 Some(&feedback),
1194 );
1195 }
1196 return ApprovalResult::Rejected;
1197 }
1198 perspt_core::AgentAction::Abort => {
1199 self.emit_log("⚠️ Session aborted by user");
1200 self.abort_requested.store(true, Ordering::Relaxed);
1201 if let Some(nid) = review_node_id {
1202 self.persist_review_decision(nid, "aborted", None);
1203 }
1204 return ApprovalResult::Rejected;
1205 }
1206 _ => {
1207 continue;
1209 }
1210 }
1211 }
1212 }
1213
1214 ApprovalResult::Rejected }
1216
1217 fn persist_review_decision(&self, node_id: &str, outcome: &str, note: Option<&str>) {
1219 let degraded = self.last_verification_result.as_ref().map(|vr| vr.degraded);
1220 if let Err(e) = self
1221 .ledger
1222 .record_review_outcome(node_id, outcome, note, None, degraded, None)
1223 {
1224 log::warn!("Failed to persist review decision for {}: {}", node_id, e);
1225 }
1226 }
1227
1228 pub fn add_dependency(&mut self, from_id: &str, to_id: &str, kind: &str) -> Result<()> {
1230 let from_idx = self
1231 .node_indices
1232 .get(from_id)
1233 .context(format!("Node not found: {}", from_id))?;
1234 let to_idx = self
1235 .node_indices
1236 .get(to_id)
1237 .context(format!("Node not found: {}", to_id))?;
1238
1239 self.graph.add_edge(
1240 *from_idx,
1241 *to_idx,
1242 Dependency {
1243 kind: kind.to_string(),
1244 },
1245 );
1246 Ok(())
1247 }
1248
1249 pub async fn run(&mut self, task: String) -> Result<()> {
1251 log::info!("Starting SRBN execution for task: {}", task);
1252 self.emit_log(format!("🚀 Starting task: {}", task));
1253
1254 let session_id = uuid::Uuid::new_v4().to_string();
1256 self.context.session_id = session_id.clone();
1257 self.ledger.start_session(
1258 &session_id,
1259 &task,
1260 &self.context.working_dir.to_string_lossy(),
1261 )?;
1262
1263 let result = self.run_orchestration(task).await;
1265 self.finalize_session(&result);
1266 result.map(|_| ())
1267 }
1268
1269 async fn run_orchestration(&mut self, task: String) -> Result<perspt_core::SessionOutcome> {
1271 if self.context.log_llm {
1272 self.emit_log("📝 LLM request logging enabled".to_string());
1273 }
1274
1275 let execution_mode = self.detect_execution_mode(&task);
1277 self.context.execution_mode = execution_mode;
1278 self.emit_log(format!("🎯 Execution mode: {}", execution_mode));
1279
1280 if execution_mode == perspt_core::types::ExecutionMode::Solo {
1281 log::info!("Using Solo Mode for explicit single-file task");
1283 self.emit_log("⚡ Solo Mode: Single-file execution".to_string());
1284 return self
1285 .run_solo_mode(task)
1286 .await
1287 .map(|()| perspt_core::SessionOutcome::Success);
1288 }
1289
1290 let workspace_state = self.classify_workspace(&task);
1292 self.context.workspace_state = workspace_state.clone();
1293 self.emit_log(format!("📋 Workspace: {}", workspace_state));
1294
1295 if let WorkspaceState::ExistingProject { ref plugins } = workspace_state {
1298 self.context.active_plugins = plugins.clone();
1299 self.emit_log(format!("🔌 Detected plugins: {}", plugins.join(", ")));
1300 self.emit_plugin_readiness();
1301 }
1302
1303 self.step_init_project(&task).await?;
1305
1306 if !matches!(workspace_state, WorkspaceState::ExistingProject { .. }) {
1309 self.redetect_plugins_after_init();
1310 }
1311
1312 self.check_verifier_readiness_gate();
1316
1317 {
1320 let plugin_refs: Vec<String> = self.context.active_plugins.clone();
1321 let refs: Vec<&str> = plugin_refs.iter().map(|s| s.as_str()).collect();
1322 if !refs.is_empty() {
1323 self.emit_log("🔍 Starting language servers...".to_string());
1324 if let Err(e) = self.start_lsp_for_plugins(&refs).await {
1325 log::warn!("Failed to start LSP: {}", e);
1326 self.emit_log("⚠️ Continuing without LSP".to_string());
1327 } else {
1328 self.emit_log("✅ Language servers ready".to_string());
1329 }
1330 }
1331 }
1332
1333 if self.planning_policy == perspt_core::PlanningPolicy::default() {
1337 self.planning_policy = match &self.context.workspace_state {
1338 WorkspaceState::Greenfield { .. } => perspt_core::PlanningPolicy::GreenfieldBuild,
1339 WorkspaceState::ExistingProject { .. } => {
1340 perspt_core::PlanningPolicy::FeatureIncrement
1341 }
1342 WorkspaceState::Ambiguous => perspt_core::PlanningPolicy::FeatureIncrement,
1343 };
1344 }
1345
1346 if self.ledger.get_feature_charter().ok().flatten().is_none() {
1350 let mut charter = perspt_core::FeatureCharter::new(&self.context.session_id, &task);
1351 match self.planning_policy {
1352 perspt_core::PlanningPolicy::LocalEdit => {
1353 charter.max_modules = Some(1);
1354 charter.max_files = Some(5);
1355 charter.max_revisions = Some(3);
1356 }
1357 perspt_core::PlanningPolicy::FeatureIncrement => {
1358 charter.max_modules = Some(10);
1359 charter.max_files = Some(30);
1360 charter.max_revisions = Some(5);
1361 }
1362 perspt_core::PlanningPolicy::LargeFeature
1363 | perspt_core::PlanningPolicy::GreenfieldBuild
1364 | perspt_core::PlanningPolicy::ArchitecturalRevision => {
1365 charter.max_modules = Some(25);
1366 charter.max_files = Some(80);
1367 charter.max_revisions = Some(10);
1368 }
1369 }
1370 if let Some(ref lang) = self.context.active_plugins.first() {
1371 charter.language_constraint = Some(lang.to_string());
1372 }
1373 if let Err(e) = self.ledger.record_feature_charter(&charter) {
1374 log::warn!("Failed to persist default FeatureCharter: {}", e);
1375 } else {
1376 log::info!(
1377 "Registered default FeatureCharter (max_modules={:?}, max_files={:?})",
1378 charter.max_modules,
1379 charter.max_files
1380 );
1381 }
1382 }
1383
1384 if self.planning_policy.needs_architect() {
1387 self.step_sheafify(task.clone()).await?;
1388 } else {
1389 self.emit_log("📐 LocalEdit policy — skipping architect, single-node plan".to_string());
1390 self.create_deterministic_fallback_graph(&task)?;
1391 }
1392
1393 self.emit_log(format!("📐 Planning policy: {:?}", self.planning_policy));
1395
1396 let node_count = self.graph.node_count();
1398 self.emit_event(perspt_core::AgentEvent::PlanReady {
1399 nodes: node_count,
1400 plugins: self.context.active_plugins.clone(),
1401 execution_mode: execution_mode.to_string(),
1402 });
1403
1404 for node_id in self.node_indices.keys() {
1406 if let Some(idx) = self.node_indices.get(node_id) {
1407 if let Some(node) = self.graph.node_weight(*idx) {
1408 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1409 node_id: node.node_id.clone(),
1410 status: perspt_core::NodeStatus::Pending,
1411 });
1412 }
1413 }
1414 }
1415
1416 let mut completed_count: usize = 0;
1426 let mut escalated_count: usize = 0;
1427 let mut goal_achieved = false;
1428
1429 let max_revisions = self
1433 .ledger
1434 .get_feature_charter()
1435 .ok()
1436 .flatten()
1437 .and_then(|c| c.max_revisions)
1438 .unwrap_or(5) as usize;
1439 let max_rounds = (self.graph.node_count() + 1) * (max_revisions + 2) + 16;
1440 let mut replan_state = ReplanState::new(max_revisions);
1441
1442 const MAX_REWORKS_PER_NODE: usize = 6;
1445 let mut rework_counts: HashMap<NodeIndex, usize> = HashMap::new();
1446
1447 let mut round: usize = 0;
1448 loop {
1449 round += 1;
1450 if round > max_rounds {
1451 log::warn!("Control loop reached round cap ({max_rounds}) — stopping");
1452 self.emit_log(format!(
1453 "⛔ Control loop reached round cap ({max_rounds}) — stopping"
1454 ));
1455 break;
1456 }
1457
1458 if self.is_abort_requested() {
1460 self.emit_log("⚠️ Session aborted — stopping execution".to_string());
1461 break;
1462 }
1463 if self.budget.any_exhausted() {
1465 self.emit_log("⛔ Budget exhausted — stopping execution".to_string());
1466 break;
1467 }
1468
1469 let idx = match self.next_ready_node() {
1471 Some(idx) => idx,
1472 None => {
1473 match self
1476 .evaluate_goal_completion(&task, &mut replan_state)
1477 .await
1478 {
1479 SettleDecision::Achieved => {
1480 goal_achieved = true;
1481 break;
1482 }
1483 SettleDecision::Replanned => {
1484 continue;
1486 }
1487 SettleDecision::Stop => break,
1488 }
1489 }
1490 };
1491
1492 let total_nodes = self.graph.node_count();
1495 if let Some(node) = self.graph.node_weight(idx) {
1496 self.emit_log(format!(
1497 "📝 [{}/{}] {}",
1498 completed_count + 1,
1499 total_nodes,
1500 node.goal
1501 ));
1502 self.emit_event(perspt_core::AgentEvent::NodeSelected {
1503 node_id: node.node_id.clone(),
1504 goal: node.goal.clone(),
1505 node_class: node.node_class.to_string(),
1506 });
1507 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1508 node_id: node.node_id.clone(),
1509 status: perspt_core::NodeStatus::Running,
1510 });
1511 }
1512
1513 let outcome = self.execute_node(idx).await;
1514 self.budget.record_step();
1517 self.emit_event(perspt_core::AgentEvent::BudgetUpdated {
1518 steps_used: self.budget.steps_used,
1519 max_steps: self.budget.max_steps,
1520 cost_used_usd: self.budget.cost_used_usd,
1521 max_cost_usd: self.budget.max_cost_usd,
1522 revisions_used: self.budget.revisions_used,
1523 max_revisions: self.budget.max_revisions,
1524 });
1525 if let Err(e) = self.ledger.upsert_budget_envelope(&self.budget) {
1526 log::warn!("Failed to persist budget envelope: {}", e);
1527 }
1528
1529 match outcome {
1530 Ok(NodeOutcome::Completed) => {
1531 completed_count += 1;
1532 if let Some(node) = self.graph.node_weight(idx) {
1533 self.emit_event(perspt_core::AgentEvent::NodeCompleted {
1534 node_id: node.node_id.clone(),
1535 goal: node.goal.clone(),
1536 });
1537 }
1538 }
1539 Ok(NodeOutcome::Reworked) => {
1540 let count = rework_counts.entry(idx).or_insert(0);
1544 *count += 1;
1545 if *count > MAX_REWORKS_PER_NODE {
1546 let node_id = self.graph[idx].node_id.clone();
1547 log::warn!(
1548 "Node {node_id} exceeded rework limit ({MAX_REWORKS_PER_NODE}) — escalating"
1549 );
1550 self.emit_log(format!(
1551 "⛔ Node {node_id} exceeded rework limit — escalating"
1552 ));
1553 self.graph[idx].state = NodeState::Escalated;
1554 escalated_count += 1;
1555 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1556 node_id,
1557 status: perspt_core::NodeStatus::Escalated,
1558 });
1559 }
1560 }
1563 Ok(NodeOutcome::Escalated) => {
1564 escalated_count += 1;
1565 if let Some(node) = self.graph.node_weight(idx) {
1566 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1567 node_id: node.node_id.clone(),
1568 status: perspt_core::NodeStatus::Escalated,
1569 });
1570 }
1571 }
1572 Err(e) => {
1573 escalated_count += 1;
1574 let node_id = self.graph[idx].node_id.clone();
1575 eprintln!("[SRBN-DIAG] Node {} failed: {:#}", node_id, e);
1576 log::error!("Node {} failed: {}", node_id, e);
1577 self.emit_log(format!("❌ Node {} failed: {}", node_id, e));
1578
1579 if let Some(bid) = self.graph[idx].provisional_branch_id.clone() {
1581 self.flush_provisional_branch(&bid, &node_id);
1582 }
1583 self.flush_descendant_branches(idx);
1584
1585 self.graph[idx].state = NodeState::Escalated;
1586 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1587 node_id: node_id.clone(),
1588 status: perspt_core::NodeStatus::Escalated,
1589 });
1590 }
1591 }
1592 }
1593
1594 let total_nodes = self.graph.node_count();
1595 log::info!("SRBN execution completed");
1596
1597 if let Err(e) = crate::tools::cleanup_session_sandboxes(
1599 &self.context.working_dir,
1600 &self.context.session_id,
1601 ) {
1602 log::warn!("Failed to clean up session sandboxes: {}", e);
1603 }
1604
1605 let all_completed_no_escalation =
1609 escalated_count == 0 && completed_count >= total_nodes && total_nodes > 0;
1610 let outcome = if goal_achieved || all_completed_no_escalation {
1611 perspt_core::SessionOutcome::Success
1612 } else if completed_count > 0 {
1613 perspt_core::SessionOutcome::PartialSuccess
1614 } else {
1615 perspt_core::SessionOutcome::Failed
1616 };
1617 self.emit_event(perspt_core::AgentEvent::Complete {
1618 success: outcome == perspt_core::SessionOutcome::Success,
1619 message: format!(
1620 "{}/{} nodes completed, {} escalated",
1621 completed_count, total_nodes, escalated_count
1622 ),
1623 });
1624 Ok(outcome)
1625 }
1626
1627 fn next_ready_node(&mut self) -> Option<NodeIndex> {
1635 let mut candidates: Vec<NodeIndex> = self
1636 .graph
1637 .node_indices()
1638 .filter(|&idx| {
1639 matches!(
1640 self.graph[idx].state,
1641 NodeState::TaskQueued | NodeState::Retry
1642 )
1643 })
1644 .filter(|&idx| {
1645 self.graph
1646 .neighbors_directed(idx, petgraph::Direction::Incoming)
1647 .all(|parent| self.graph[parent].state == NodeState::Completed)
1648 })
1649 .collect();
1650 candidates.sort();
1651
1652 for idx in candidates {
1653 if self.check_seal_prerequisites(idx) {
1656 continue;
1657 }
1658 return Some(idx);
1659 }
1660 None
1661 }
1662
1663 async fn evaluate_goal_completion(
1676 &mut self,
1677 task: &str,
1678 state: &mut ReplanState,
1679 ) -> SettleDecision {
1680 let completed = self.count_in_state(NodeState::Completed);
1681 let escalated = self.count_in_state(NodeState::Escalated);
1682 let total = self.graph.node_count();
1683 let phi = self.workflow_phi();
1684
1685 log::info!(
1687 target: "perspt::sdk_gate",
1688 "settle: completed={completed}/{total} escalated={escalated} Φ={phi:.2} replans={}/{}",
1689 state.count, state.max
1690 );
1691 self.emit_log(format!(
1692 "📐 settle: {completed}/{total} done, {escalated} escalated, Φ={phi:.2} (replans {}/{})",
1693 state.count, state.max
1694 ));
1695
1696 let det = self.deterministic_goal_gate();
1697
1698 if !self.auto_approve {
1700 return if det {
1701 SettleDecision::Achieved
1702 } else {
1703 SettleDecision::Stop
1704 };
1705 }
1706
1707 if det {
1708 match self.goal_verdict(task).await {
1710 Some(v) if v.achieved => {
1711 self.emit_log("✅ Goal verdict: achieved".to_string());
1712 SettleDecision::Achieved
1713 }
1714 Some(v) => {
1715 self.emit_log(format!(
1716 "🛠️ Goal not yet met — missing: {}",
1717 v.missing.join("; ")
1718 ));
1719 self.try_replan(task, &v.missing, completed, phi, state)
1720 .await
1721 }
1722 None => {
1723 log::warn!("Goal verdict unavailable — accepting deterministic completion");
1725 SettleDecision::Achieved
1726 }
1727 }
1728 } else {
1729 let missing = self.collect_unmet_summary();
1731 self.try_replan(task, &missing, completed, phi, state).await
1732 }
1733 }
1734
1735 async fn try_replan(
1737 &mut self,
1738 task: &str,
1739 missing: &[String],
1740 completed: usize,
1741 phi: f64,
1742 state: &mut ReplanState,
1743 ) -> SettleDecision {
1744 if state.count >= state.max {
1745 self.emit_log(format!(
1746 "⛔ Re-plan budget exhausted ({}/{}) — stopping",
1747 state.count, state.max
1748 ));
1749 return SettleDecision::Stop;
1750 }
1751 if self.budget.any_exhausted() {
1752 return SettleDecision::Stop;
1753 }
1754 if state.count > 0 {
1757 let progressed = completed > state.last_completed
1758 || state.last_phi.map(|p| phi < p - 1e-6).unwrap_or(true);
1759 if !progressed {
1760 self.emit_log(
1761 "⛔ Re-plan made no progress (Φ/completed not improving) — stopping"
1762 .to_string(),
1763 );
1764 return SettleDecision::Stop;
1765 }
1766 }
1767
1768 match self.amend_plan_for_goal(task, missing).await {
1769 Ok(n) if n > 0 => {
1770 state.count += 1;
1771 state.last_completed = completed;
1772 state.last_phi = Some(phi);
1773 self.emit_log(format!(
1774 "🗺️ Re-planned: +{n} task(s) toward the goal (revision {}/{})",
1775 state.count, state.max
1776 ));
1777 SettleDecision::Replanned
1778 }
1779 Ok(_) => {
1780 self.emit_log("⚠️ Amendment produced no new tasks — stopping".to_string());
1781 SettleDecision::Stop
1782 }
1783 Err(e) => {
1784 log::warn!("Plan amendment failed: {e}");
1785 self.emit_log(format!("⚠️ Plan amendment failed: {e}"));
1786 SettleDecision::Stop
1787 }
1788 }
1789 }
1790
1791 fn deterministic_goal_gate(&self) -> bool {
1795 let mut any = false;
1796 for idx in self.graph.node_indices() {
1797 any = true;
1798 if self.graph[idx].state != NodeState::Completed {
1799 return false;
1800 }
1801 }
1802 any
1803 }
1804
1805 fn count_in_state(&self, state: NodeState) -> usize {
1807 self.graph
1808 .node_indices()
1809 .filter(|&i| self.graph[i].state == state)
1810 .count()
1811 }
1812
1813 fn workflow_phi(&self) -> f64 {
1817 let accepted_energy: f64 = self
1821 .graph
1822 .node_indices()
1823 .map(|i| self.graph[i].monitor.current_energy() as f64)
1824 .filter(|e| e.is_finite())
1825 .sum();
1826 let remaining = match self.budget.max_steps {
1827 Some(m) => m.saturating_sub(self.budget.steps_used),
1828 None => 0,
1829 };
1830 perspt_sdk::phi(accepted_energy, 0.5, remaining)
1831 }
1832
1833 fn collect_unmet_summary(&self) -> Vec<String> {
1835 self.graph
1836 .node_indices()
1837 .filter(|&i| self.graph[i].state != NodeState::Completed)
1838 .map(|i| format!("{}: {}", self.graph[i].node_id, self.graph[i].goal))
1839 .collect()
1840 }
1841
1842 async fn goal_verdict(&mut self, task: &str) -> Option<perspt_core::types::GoalVerdict> {
1845 let tree = crate::tools::list_sandbox_files(&self.context.working_dir)
1846 .ok()
1847 .filter(|t| !t.is_empty())
1848 .map(|t| t.join("\n"));
1849
1850 let mut files: Vec<(String, String)> = Vec::new();
1852 'outer: for idx in self.graph.node_indices() {
1853 for target in &self.graph[idx].output_targets {
1854 let path = self.context.working_dir.join(target);
1855 if let Ok(content) = std::fs::read_to_string(&path) {
1856 files.push((target.to_string_lossy().to_string(), content));
1857 if files.len() >= 12 {
1858 break 'outer;
1859 }
1860 }
1861 }
1862 }
1863
1864 let ev = perspt_core::types::PromptEvidence {
1865 user_goal: Some(task.to_string()),
1866 project_file_tree: tree,
1867 existing_file_contents: files,
1868 build_test_output: self.context.last_test_output.clone(),
1869 ..Default::default()
1870 };
1871 let prompt = crate::prompt_compiler::compile(
1872 perspt_core::types::PromptIntent::GoalCompletionCheck,
1873 &ev,
1874 )
1875 .text;
1876 let model = self.verifier_model.clone();
1877 let resp = self
1878 .call_llm_with_logging(&model, &prompt, None)
1879 .await
1880 .ok()?;
1881 parse_goal_verdict(&resp)
1882 }
1883
1884 async fn execute_node(&mut self, idx: NodeIndex) -> Result<NodeOutcome> {
1886 let node = &self.graph[idx];
1887 log::info!("Executing node: {} ({})", node.node_id, node.goal);
1888
1889 let branch_id = self.maybe_create_provisional_branch(idx);
1891
1892 self.graph[idx].state = NodeState::Coding;
1894 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
1895 node_id: self.graph[idx].node_id.clone(),
1896 status: perspt_core::NodeStatus::Coding,
1897 });
1898
1899 let speculate_start = std::time::Instant::now();
1901 self.step_speculate(idx).await?;
1902 self.record_step_quietly(
1903 &self.graph[idx].node_id.clone(),
1904 "speculate",
1905 "ok",
1906 None,
1907 0,
1908 speculate_start.elapsed().as_millis() as i32,
1909 );
1910
1911 let verify_start = std::time::Instant::now();
1913 let mut energy = self.step_verify(idx).await?;
1914 self.record_step_quietly(
1915 &self.graph[idx].node_id.clone(),
1916 "verify",
1917 "ok",
1918 Some(&energy),
1919 0,
1920 verify_start.elapsed().as_millis() as i32,
1921 );
1922
1923 let mut sheaf_pre_check_retries = 0u32;
1929 let mut converge_start;
1930 loop {
1931 converge_start = std::time::Instant::now();
1933 if !self.step_converge(idx, energy.clone()).await? {
1934 self.record_step_quietly(
1935 &self.graph[idx].node_id.clone(),
1936 "converge",
1937 "escalated",
1938 Some(&energy),
1939 self.graph[idx].monitor.attempt_count as i32,
1940 converge_start.elapsed().as_millis() as i32,
1941 );
1942 let category = self.classify_non_convergence(idx);
1944 let action = self.choose_repair_action(idx, &category);
1945
1946 let node = &self.graph[idx];
1948 let report = EscalationReport {
1949 node_id: node.node_id.clone(),
1950 session_id: self.context.session_id.clone(),
1951 category,
1952 action: action.clone(),
1953 energy_snapshot: EnergyComponents {
1954 v_syn: node.monitor.current_energy(),
1955 ..Default::default()
1956 },
1957 stage_outcomes: self
1958 .last_verification_result
1959 .as_ref()
1960 .map(|vr| vr.stage_outcomes.clone())
1961 .unwrap_or_default(),
1962 evidence: self.build_escalation_evidence(idx),
1963 affected_node_ids: self.affected_dependents(idx),
1964 timestamp: epoch_seconds(),
1965 };
1966
1967 if let Err(e) = self.ledger.record_escalation_report(&report) {
1968 log::warn!("Failed to persist escalation report: {}", e);
1969 }
1970
1971 if let Some(bundle) = self.last_applied_bundle.take() {
1973 if let Err(e) = self
1974 .ledger
1975 .record_artifact_bundle(&self.graph[idx].node_id, &bundle)
1976 {
1977 log::warn!(
1978 "Failed to persist artifact bundle on escalation for {}: {}",
1979 self.graph[idx].node_id,
1980 e
1981 );
1982 }
1983 }
1984
1985 self.emit_event(perspt_core::AgentEvent::EscalationClassified {
1986 node_id: report.node_id.clone(),
1987 category: report.category.to_string(),
1988 action: report.action.to_string(),
1989 });
1990
1991 let node_id_for_flush = self.graph[idx].node_id.clone();
1993 if let Some(ref bid) = branch_id {
1994 self.flush_provisional_branch(bid, &node_id_for_flush);
1995 }
1996 self.flush_descendant_branches(idx);
1997
1998 let applied = self.apply_repair_action(idx, &action).await;
2000
2001 if applied {
2002 log::info!(
2006 "Node {} reworked via {}: {} — will re-evaluate",
2007 self.graph[idx].node_id,
2008 action,
2009 category
2010 );
2011 return Ok(NodeOutcome::Reworked);
2012 }
2013
2014 self.graph[idx].state = NodeState::Escalated;
2015 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
2016 node_id: self.graph[idx].node_id.clone(),
2017 status: perspt_core::NodeStatus::Escalated,
2018 });
2019 log::warn!(
2020 "Node {} escalated to user: {} → {}",
2021 self.graph[idx].node_id,
2022 category,
2023 action
2024 );
2025
2026 return Ok(NodeOutcome::Escalated);
2027 }
2028
2029 if sheaf_pre_check_retries < 1 {
2032 if let Some(evidence) = self.sheaf_pre_check(idx) {
2033 sheaf_pre_check_retries += 1;
2034 log::warn!(
2035 "Sheaf pre-check failed for {}, retrying convergence: {}",
2036 self.graph[idx].node_id,
2037 evidence
2038 );
2039 self.emit_log(format!("⚠️ Sheaf pre-check: {}", evidence));
2040 self.context.last_test_output = Some(format!(
2042 "Structural pre-check failure: {}\nEnsure all declared output files are generated correctly.",
2043 evidence
2044 ));
2045 energy = self.step_verify(idx).await?;
2047 energy.v_sheaf += 2.0;
2048 continue;
2049 }
2050 }
2051 break;
2052 } if sheaf_pre_check_retries > 0 {
2058 if let Some(evidence) = self.sheaf_pre_check(idx) {
2059 log::warn!(
2060 "Sheaf pre-check still failing for {} after retry, escalating: {}",
2061 self.graph[idx].node_id,
2062 evidence
2063 );
2064 self.emit_log(format!(
2065 "❌ Sheaf pre-check failed after retry: {}",
2066 evidence
2067 ));
2068 self.graph[idx].state = NodeState::Escalated;
2069 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
2070 node_id: self.graph[idx].node_id.clone(),
2071 status: perspt_core::NodeStatus::Escalated,
2072 });
2073 let node_id_for_flush = self.graph[idx].node_id.clone();
2075 if let Some(ref bid) = branch_id {
2076 self.flush_provisional_branch(bid, &node_id_for_flush);
2077 }
2078 self.flush_descendant_branches(idx);
2079 return Ok(NodeOutcome::Escalated);
2080 }
2081 }
2082
2083 self.record_step_quietly(
2085 &self.graph[idx].node_id.clone(),
2086 "converge",
2087 "ok",
2088 Some(&energy),
2089 self.graph[idx].monitor.attempt_count as i32,
2090 converge_start.elapsed().as_millis() as i32,
2091 );
2092
2093 let sheaf_start = std::time::Instant::now();
2095 self.step_sheaf_validate(idx).await?;
2096 self.record_step_quietly(
2097 &self.graph[idx].node_id.clone(),
2098 "sheaf_validate",
2099 "ok",
2100 None,
2101 0,
2102 sheaf_start.elapsed().as_millis() as i32,
2103 );
2104
2105 let commit_start = std::time::Instant::now();
2107 self.step_commit(idx).await?;
2108 self.record_step_quietly(
2109 &self.graph[idx].node_id.clone(),
2110 "commit",
2111 "ok",
2112 None,
2113 0,
2114 commit_start.elapsed().as_millis() as i32,
2115 );
2116
2117 if let Some(ref bid) = branch_id {
2119 self.merge_provisional_branch(bid, idx);
2120 }
2121
2122 Ok(NodeOutcome::Completed)
2123 }
2124
2125 async fn step_speculate(&mut self, idx: NodeIndex) -> Result<()> {
2127 log::info!("Step 3: Speculation - Generating implementation");
2128
2129 let retriever = ContextRetriever::new(self.effective_working_dir(idx))
2133 .with_max_file_bytes(8 * 1024)
2134 .with_max_context_bytes(100 * 1024); let node = &self.graph[idx];
2137 let mut restriction_map =
2138 retriever.build_restriction_map(node, &self.context.ownership_manifest);
2139
2140 self.inject_sealed_interfaces(idx, &mut restriction_map);
2145
2146 let node = &self.graph[idx];
2147 let context_package = retriever.assemble_context_package(node, &restriction_map);
2148 let formatted_context = retriever.format_context_package(&context_package);
2149
2150 let node = &self.graph[idx];
2153 let missing_owned: Vec<String> = restriction_map
2154 .owned_files
2155 .iter()
2156 .filter(|f| {
2157 !context_package.included_files.contains_key(*f)
2159 && !node
2160 .output_targets
2161 .iter()
2162 .any(|ot| ot.to_string_lossy() == **f)
2163 })
2164 .cloned()
2165 .collect();
2166
2167 if context_package.budget_exceeded || !missing_owned.is_empty() {
2168 let reason = if context_package.budget_exceeded && !missing_owned.is_empty() {
2169 format!(
2170 "Budget exceeded and {} owned file(s) missing",
2171 missing_owned.len()
2172 )
2173 } else if context_package.budget_exceeded {
2174 "Context budget exceeded; some files replaced with structural digests".to_string()
2175 } else {
2176 format!(
2177 "{} owned file(s) could not be read: {}",
2178 missing_owned.len(),
2179 missing_owned.join(", ")
2180 )
2181 };
2182
2183 log::warn!("Context degraded for node '{}': {}", node.node_id, reason);
2184 self.emit_log(format!("⚠️ Context degraded: {}", reason));
2185 self.emit_event(perspt_core::AgentEvent::ContextDegraded {
2186 node_id: node.node_id.clone(),
2187 budget_exceeded: context_package.budget_exceeded,
2188 missing_owned_files: missing_owned.clone(),
2189 included_file_count: context_package.included_files.len(),
2190 total_bytes: context_package.total_bytes,
2191 reason: reason.clone(),
2192 });
2193
2194 if !missing_owned.is_empty() {
2197 self.emit_event(perspt_core::AgentEvent::ContextBlocked {
2198 node_id: node.node_id.clone(),
2199 missing_owned_files: missing_owned,
2200 reason: reason.clone(),
2201 });
2202 self.graph[idx].state = NodeState::Escalated;
2203 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
2204 node_id: self.graph[idx].node_id.clone(),
2205 status: perspt_core::NodeStatus::Escalated,
2206 });
2207 let err_msg = format!(
2208 "Context blocked for node '{}': {}. Node escalated.",
2209 self.graph[idx].node_id, reason
2210 );
2211 eprintln!("[SRBN-DIAG] {}", err_msg);
2212 return Err(anyhow::anyhow!(err_msg));
2213 }
2214 }
2215
2216 {
2219 let node = &self.graph[idx];
2220 let prose_only_deps = self.check_structural_dependencies(node, &restriction_map);
2221 if !prose_only_deps.is_empty() {
2222 for (dep_node_id, dep_reason) in &prose_only_deps {
2223 self.emit_event(perspt_core::AgentEvent::StructuralDependencyMissing {
2224 node_id: node.node_id.clone(),
2225 dependency_node_id: dep_node_id.clone(),
2226 reason: dep_reason.clone(),
2227 });
2228 }
2229 let dep_names: Vec<&str> =
2230 prose_only_deps.iter().map(|(id, _)| id.as_str()).collect();
2231 let block_reason = format!(
2232 "Required structural dependencies lack machine-verifiable digests (only prose summaries): [{}]",
2233 dep_names.join(", ")
2234 );
2235 eprintln!(
2236 "[SRBN-DIAG] Structural dependency check failed for '{}': {}",
2237 self.graph[idx].node_id, block_reason
2238 );
2239 self.emit_log(format!("🚫 {}", block_reason));
2240 self.graph[idx].state = NodeState::Escalated;
2241 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
2242 node_id: self.graph[idx].node_id.clone(),
2243 status: perspt_core::NodeStatus::Escalated,
2244 });
2245 return Err(anyhow::anyhow!(
2246 "Structural dependency check failed for node '{}': {}",
2247 self.graph[idx].node_id,
2248 block_reason
2249 ));
2250 }
2251 }
2252
2253 self.last_context_provenance = Some(context_package.provenance());
2255 self.last_formatted_context = formatted_context.clone();
2257
2258 let speculator_hints = if self.planning_policy.needs_speculator() {
2263 let node_id = self.graph[idx].node_id.clone();
2264 let node_goal = self.graph[idx].goal.clone();
2265 let child_goals: Vec<String> = self
2266 .graph
2267 .edges(idx)
2268 .filter_map(|edge| {
2269 let child = &self.graph[edge.target()];
2270 if child.state == NodeState::TaskQueued {
2271 Some(format!("- {}: {}", child.node_id, child.goal))
2272 } else {
2273 None
2274 }
2275 })
2276 .collect();
2277
2278 if !child_goals.is_empty() {
2279 let ev = perspt_core::types::PromptEvidence {
2280 node_goal: Some(node_goal.clone()),
2281 context_files: vec![node_id.clone()],
2282 output_files: child_goals.clone(),
2283 ..Default::default()
2284 };
2285 let speculator_prompt = crate::prompt_compiler::compile(
2286 perspt_core::types::PromptIntent::SpeculatorLookahead,
2287 &ev,
2288 )
2289 .text;
2290
2291 log::debug!(
2292 "Speculator lookahead for node {} using model {}",
2293 node_id,
2294 self.speculator_model
2295 );
2296 self.call_llm_with_logging(
2297 &self.speculator_model.clone(),
2298 &speculator_prompt,
2299 Some(&node_id),
2300 )
2301 .await
2302 .unwrap_or_else(|e| {
2303 log::warn!(
2304 "Speculator lookahead failed ({}), proceeding without hints",
2305 e
2306 );
2307 String::new()
2308 })
2309 } else {
2310 String::new()
2311 }
2312 } else {
2313 String::new()
2314 };
2315
2316 let actuator = &self.agents[1];
2317 let node = &self.graph[idx];
2318 let node_id = node.node_id.clone();
2319
2320 let base_prompt = actuator.build_prompt(node, &self.context);
2322 let mut prompt = if formatted_context.is_empty() {
2323 base_prompt
2324 } else {
2325 format!(
2326 "{}\n\n## Node Context (PSP-5 Restriction Map)\n\n{}",
2327 base_prompt, formatted_context
2328 )
2329 };
2330
2331 if !speculator_hints.is_empty() {
2332 prompt = format!(
2333 "{}\n\n## Speculator Lookahead Hints\n\n{}",
2334 prompt, speculator_hints
2335 );
2336 }
2337
2338 let wd = self.effective_working_dir(idx);
2341 if let Ok(tree) = crate::tools::list_sandbox_files(&wd) {
2342 if !tree.is_empty() {
2343 prompt = format!(
2344 "{}\n\n## Current Project Tree\n\n```\n{}\n```",
2345 prompt,
2346 tree.join("\n")
2347 );
2348 }
2349 }
2350
2351 let model = actuator.model().to_string();
2352
2353 let response = self
2354 .call_llm_with_logging(&model, &prompt, Some(&node_id))
2355 .await?;
2356
2357 let message = crate::types::AgentMessage::new(crate::types::ModelTier::Actuator, response);
2358 let content = &message.content;
2359
2360 if let Some(command) = self.extract_command_from_response(content) {
2362 log::info!("Extracted command: {}", command);
2363 self.emit_log(format!("🔧 Command proposed: {}", command));
2364
2365 let node_id = self.graph[idx].node_id.clone();
2367 let approval_result = self
2368 .await_approval_for_node(
2369 perspt_core::ActionType::Command {
2370 command: command.clone(),
2371 },
2372 format!("Execute shell command: {}", command),
2373 None,
2374 Some(&node_id),
2375 )
2376 .await;
2377
2378 if !matches!(
2379 approval_result,
2380 ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
2381 ) {
2382 self.emit_log("⏭️ Command skipped (not approved)");
2383 return Ok(());
2384 }
2385
2386 let mut args = HashMap::new();
2388 args.insert("command".to_string(), command.clone());
2389
2390 let call = ToolCall {
2391 name: "run_command".to_string(),
2392 arguments: args,
2393 };
2394
2395 let result = self.tools.execute(&call).await;
2396 if result.success {
2397 log::info!("✓ Command succeeded: {}", command);
2398 self.emit_log(format!("✅ Command succeeded: {}", command));
2399 self.emit_log(result.output);
2400 } else {
2401 log::warn!("Command failed: {:?}", result.error);
2402 self.emit_log(format!("❌ Command failed: {:?}", result.error));
2403 }
2404 }
2405 else {
2407 let (bundle_opt, parse_state, record_opt) =
2408 self.parse_artifact_bundle_typed(content, &node_id, 0);
2409
2410 if let Some(ref record) = record_opt {
2411 log::info!(
2412 "PSP-7 initial gen: parse_state={}, accepted={}",
2413 record.parse_state,
2414 record.accepted
2415 );
2416 }
2417
2418 match parse_state {
2419 perspt_core::types::ParseResultState::StrictJsonOk
2420 | perspt_core::types::ParseResultState::TolerantRecoveryOk => {
2421 let bundle = bundle_opt.expect("Accepted parse must yield a bundle");
2422 let affected_files: Vec<String> = bundle
2423 .affected_paths()
2424 .into_iter()
2425 .map(ToString::to_string)
2426 .collect();
2427 log::info!(
2428 "Parsed artifact bundle for node {} ({}): {} artifacts, {} commands",
2429 node_id,
2430 parse_state,
2431 bundle.artifacts.len(),
2432 bundle.commands.len()
2433 );
2434 self.emit_log(format!(
2435 "📝 Bundle proposed: {} artifact(s) across {} file(s)",
2436 bundle.artifacts.len(),
2437 affected_files.len()
2438 ));
2439
2440 let approval_result = self
2441 .await_approval_for_node(
2442 perspt_core::ActionType::BundleWrite {
2443 node_id: node_id.clone(),
2444 files: affected_files.clone(),
2445 },
2446 format!("Apply bundle touching: {}", affected_files.join(", ")),
2447 serde_json::to_string_pretty(&bundle).ok(),
2448 Some(&node_id),
2449 )
2450 .await;
2451
2452 if !matches!(
2453 approval_result,
2454 ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
2455 ) {
2456 self.emit_log("⏭️ Bundle application skipped (not approved)");
2457 return Ok(());
2458 }
2459
2460 let node_class = self.graph[idx].node_class;
2461 match self
2462 .apply_bundle_transactionally(&bundle, &node_id, node_class)
2463 .await
2464 {
2465 Ok(()) => {
2466 self.last_tool_failure = None;
2467 self.last_applied_bundle = Some(bundle.clone());
2468 }
2469 Err(e) => return Err(e),
2470 }
2471
2472 let effective_commands = self
2474 .last_applied_bundle
2475 .as_ref()
2476 .map(|b| b.commands.clone())
2477 .unwrap_or_default();
2478 if !effective_commands.is_empty() {
2479 self.emit_log(format!(
2480 "🔧 Executing {} bundle command(s)...",
2481 effective_commands.len()
2482 ));
2483 let work_dir = self.effective_working_dir(idx);
2484 let is_python = self.graph[idx].owner_plugin == "python";
2485 for raw_command in &effective_commands {
2486 let command = if is_python {
2487 Self::normalize_command_to_uv(raw_command)
2488 } else {
2489 raw_command.clone()
2490 };
2491
2492 let cmd_approval = self
2493 .await_approval_for_node(
2494 perspt_core::ActionType::Command {
2495 command: command.clone(),
2496 },
2497 format!("Execute bundle command: {}", command),
2498 None,
2499 Some(&node_id),
2500 )
2501 .await;
2502
2503 if !matches!(
2504 cmd_approval,
2505 ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
2506 ) {
2507 self.emit_log(format!(
2508 "⏭️ Bundle command skipped (not approved): {}",
2509 command
2510 ));
2511 continue;
2512 }
2513
2514 let mut args = HashMap::new();
2515 args.insert("command".to_string(), command.clone());
2516 args.insert(
2517 "working_dir".to_string(),
2518 work_dir.to_string_lossy().to_string(),
2519 );
2520
2521 let call = ToolCall {
2522 name: "run_command".to_string(),
2523 arguments: args,
2524 };
2525
2526 let result = self.tools.execute(&call).await;
2527 if result.success {
2528 log::info!("✓ Bundle command succeeded: {}", command);
2529 self.emit_log(format!("✅ {}", command));
2530 if !result.output.is_empty() {
2531 let truncated: String =
2532 result.output.chars().take(500).collect();
2533 self.emit_log(truncated);
2534 }
2535 } else {
2536 let err_msg = result.error.unwrap_or_else(|| result.output.clone());
2537 log::warn!("Bundle command failed: {} — {}", command, err_msg);
2538 self.emit_log(format!(
2539 "❌ Command failed: {} — {}",
2540 command, err_msg
2541 ));
2542 self.last_tool_failure = Some(format!(
2543 "Bundle command '{}' failed: {}",
2544 command, err_msg
2545 ));
2546 }
2547 }
2548
2549 if is_python {
2550 log::info!("Running uv sync --dev after bundle commands...");
2551 let sync_result = tokio::process::Command::new("uv")
2552 .args(["sync", "--dev"])
2553 .current_dir(&work_dir)
2554 .stdout(std::process::Stdio::piped())
2555 .stderr(std::process::Stdio::piped())
2556 .output()
2557 .await;
2558 match sync_result {
2559 Ok(output) if output.status.success() => {
2560 self.emit_log("🐍 uv sync --dev completed".to_string());
2561 }
2562 Ok(output) => {
2563 let stderr = String::from_utf8_lossy(&output.stderr);
2564 log::warn!("uv sync --dev failed: {}", stderr);
2565 }
2566 Err(e) => {
2567 log::warn!("Failed to run uv sync --dev: {}", e);
2568 }
2569 }
2570 }
2571 }
2572 }
2573
2574 perspt_core::types::ParseResultState::SemanticallyRejected => {
2575 log::warn!(
2577 "Bundle for '{}' semantically rejected, retrying with retarget prompt",
2578 node_id
2579 );
2580 self.emit_log(format!(
2581 "🔄 Bundle for '{}' targeted wrong files — retrying...",
2582 node_id
2583 ));
2584
2585 let raw_paths: Vec<String> =
2586 perspt_core::normalize::extract_file_markers(content)
2587 .iter()
2588 .filter_map(|m| m.path.clone())
2589 .collect();
2590 let expected: Vec<String> = self.graph[idx]
2591 .output_targets
2592 .iter()
2593 .map(|p| p.to_string_lossy().to_string())
2594 .collect();
2595 let ev = perspt_core::types::PromptEvidence {
2596 output_files: expected.clone(),
2597 existing_file_contents: vec![(raw_paths.join(", "), prompt.clone())],
2598 ..Default::default()
2599 };
2600 let retry_prompt = crate::prompt_compiler::compile(
2601 perspt_core::types::PromptIntent::BundleRetarget,
2602 &ev,
2603 )
2604 .text;
2605
2606 let retry_response = self
2607 .call_llm_with_logging(&model, &retry_prompt, Some(&node_id))
2608 .await?;
2609
2610 let (retry_bundle_opt, retry_state, _) =
2611 self.parse_artifact_bundle_typed(&retry_response, &node_id, 1);
2612
2613 if let Some(retry_bundle) = retry_bundle_opt {
2614 let node_class = self.graph[idx].node_class;
2615 self.apply_bundle_transactionally(&retry_bundle, &node_id, node_class)
2616 .await?;
2617 self.last_tool_failure = None;
2618 self.last_applied_bundle = Some(retry_bundle);
2619 } else {
2620 return Err(anyhow::anyhow!(
2621 "Retry for '{}' did not produce a valid bundle ({})",
2622 node_id,
2623 retry_state
2624 ));
2625 }
2626 }
2627
2628 _ => {
2629 log::debug!(
2631 "No artifact bundle found in response ({}), response length: {}",
2632 parse_state,
2633 content.len()
2634 );
2635 self.emit_log("ℹ️ No file changes detected in response".to_string());
2636 }
2637 }
2638 }
2639
2640 self.context.history.push(message);
2641 Ok(())
2642 }
2643
2644 fn extract_command_from_response(&self, content: &str) -> Option<String> {
2647 for line in content.lines() {
2648 let trimmed = line.trim();
2649 if trimmed.starts_with("[COMMAND]") {
2650 return Some(trimmed.trim_start_matches("[COMMAND]").trim().to_string());
2651 }
2652 if trimmed.starts_with("$ ") || trimmed.starts_with("➜ ") {
2654 return Some(
2655 trimmed
2656 .trim_start_matches("$ ")
2657 .trim_start_matches("➜ ")
2658 .trim()
2659 .to_string(),
2660 );
2661 }
2662 }
2663 None
2664 }
2665
2666 pub fn session_id(&self) -> &str {
2672 &self.context.session_id
2673 }
2674
2675 pub fn node_count(&self) -> usize {
2677 self.graph.node_count()
2678 }
2679
2680 pub async fn start_lsp_for_plugins(&mut self, plugin_names: &[&str]) -> Result<()> {
2685 let registry = perspt_core::plugin::PluginRegistry::new();
2686
2687 for &name in plugin_names {
2688 if self.lsp_clients.contains_key(name) {
2689 log::debug!("LSP client already running for {}", name);
2690 continue;
2691 }
2692
2693 let plugin = match registry.get(name) {
2694 Some(p) => p,
2695 None => {
2696 log::warn!("No plugin found for '{}', skipping LSP startup", name);
2697 continue;
2698 }
2699 };
2700
2701 let profile = plugin.verifier_profile();
2702 let lsp_config = match profile.lsp.effective_config() {
2703 Some(cfg) => cfg.clone(),
2704 None => {
2705 log::warn!(
2706 "No available LSP for {} (primary and fallback unavailable)",
2707 name
2708 );
2709 continue;
2710 }
2711 };
2712
2713 log::info!(
2714 "Starting LSP for {}: {} {:?}",
2715 name,
2716 lsp_config.server_binary,
2717 lsp_config.args
2718 );
2719
2720 let mut client = LspClient::from_config(&lsp_config);
2721 match client
2722 .start_with_config(&lsp_config, &self.context.working_dir)
2723 .await
2724 {
2725 Ok(()) => {
2726 log::info!("{} LSP started successfully", name);
2727 self.lsp_clients.insert(name.to_string(), client);
2728 }
2729 Err(e) => {
2730 log::warn!(
2731 "Failed to start {} LSP: {} (continuing without it)",
2732 name,
2733 e
2734 );
2735 }
2736 }
2737 }
2738
2739 Ok(())
2740 }
2741
2742 fn lsp_key_for_file(&self, path: &str) -> Option<String> {
2747 let registry = perspt_core::plugin::PluginRegistry::new();
2748
2749 for plugin in registry.all() {
2751 if plugin.owns_file(path) {
2752 let name = plugin.name().to_string();
2753 if self.lsp_clients.contains_key(&name) {
2754 return Some(name);
2755 }
2756 }
2757 }
2758
2759 self.lsp_clients.keys().next().cloned()
2761 }
2762
2763 fn sandbox_dir_for_node(&self, idx: NodeIndex) -> Option<std::path::PathBuf> {
2774 let branch_id = self.graph[idx].provisional_branch_id.as_ref()?;
2775 let sandbox_path = self
2776 .context
2777 .working_dir
2778 .join(".perspt")
2779 .join("sandboxes")
2780 .join(&self.context.session_id)
2781 .join(branch_id);
2782 if sandbox_path.exists() {
2783 Some(sandbox_path)
2784 } else {
2785 None
2786 }
2787 }
2788
2789 fn sheaf_pre_check(&self, idx: NodeIndex) -> Option<String> {
2796 let node = &self.graph[idx];
2797 if node.output_targets.is_empty() {
2798 return None;
2799 }
2800
2801 let work_dir = self.effective_working_dir(idx);
2802 let mut issues = Vec::new();
2803
2804 for path in &node.output_targets {
2805 let full = work_dir.join(path);
2806 match std::fs::metadata(&full) {
2807 Ok(m) if m.len() == 0 => {
2808 issues.push(format!("empty: {}", path.display()));
2809 }
2810 Err(_) => {
2811 issues.push(format!("missing: {}", path.display()));
2812 }
2813 Ok(_) => {
2814 if let Some(reason) = detect_stub_content(&full, &node.owner_plugin) {
2816 issues.push(format!("stub content in {}: {}", path.display(), reason));
2817 }
2818 }
2819 }
2820 }
2821
2822 if issues.is_empty() {
2823 None
2824 } else {
2825 Some(format!("Output target issues: {}", issues.join(", ")))
2826 }
2827 }
2828
2829 fn effective_working_dir(&self, idx: NodeIndex) -> std::path::PathBuf {
2832 self.sandbox_dir_for_node(idx)
2833 .unwrap_or_else(|| self.context.working_dir.clone())
2834 }
2835
2836 fn maybe_create_provisional_branch(&mut self, idx: NodeIndex) -> Option<String> {
2839 let parents: Vec<NodeIndex> = self
2841 .graph
2842 .neighbors_directed(idx, petgraph::Direction::Incoming)
2843 .collect();
2844
2845 let node = &self.graph[idx];
2846 let node_id = node.node_id.clone();
2847 let session_id = self.context.session_id.clone();
2848
2849 let parent_node_id = if parents.is_empty() {
2852 "root".to_string()
2853 } else {
2854 self.graph[parents[0]].node_id.clone()
2855 };
2856
2857 let branch_id = format!("branch_{}_{}", node_id, uuid::Uuid::new_v4());
2858 let branch = ProvisionalBranch::new(
2859 branch_id.clone(),
2860 session_id.clone(),
2861 node_id.clone(),
2862 parent_node_id.clone(),
2863 );
2864
2865 if let Err(e) = self.ledger.record_provisional_branch(&branch) {
2867 log::warn!("Failed to record provisional branch: {}", e);
2868 }
2869
2870 for pidx in &parents {
2872 let parent_id = self.graph[*pidx].node_id.clone();
2873 let depends_on_seal = self.graph[*pidx].node_class == NodeClass::Interface;
2875 let lineage = perspt_core::types::BranchLineage {
2876 lineage_id: format!("lin_{}_{}", branch_id, parent_id),
2877 parent_branch_id: parent_id,
2878 child_branch_id: branch_id.clone(),
2879 depends_on_seal,
2880 };
2881 if let Err(e) = self.ledger.record_branch_lineage(&lineage) {
2882 log::warn!("Failed to record branch lineage: {}", e);
2883 }
2884 }
2885
2886 self.graph[idx].provisional_branch_id = Some(branch_id.clone());
2888
2889 match crate::tools::create_sandbox(&self.context.working_dir, &session_id, &branch_id) {
2892 Ok(sandbox_path) => {
2893 log::debug!("Sandbox created at {}", sandbox_path.display());
2894
2895 let plugin_refs: Vec<&str> = self
2898 .context
2899 .active_plugins
2900 .iter()
2901 .map(|s| s.as_str())
2902 .collect();
2903 if let Err(e) = crate::tools::seed_sandbox_manifests(
2904 &self.context.working_dir,
2905 &sandbox_path,
2906 &plugin_refs,
2907 ) {
2908 log::warn!("Failed to seed sandbox manifests: {}", e);
2909 }
2910
2911 let node = &self.graph[idx];
2914 for target in &node.output_targets {
2915 if let Some(rel) = target.to_str() {
2916 if let Err(e) = crate::tools::copy_to_sandbox(
2917 &self.context.working_dir,
2918 &sandbox_path,
2919 rel,
2920 ) {
2921 log::debug!("Could not seed sandbox with {}: {}", rel, e);
2922 }
2923 }
2924 }
2925 let mut ancestor_queue: Vec<NodeIndex> = parents.clone();
2931 let mut visited = std::collections::HashSet::new();
2932 while let Some(ancestor_idx) = ancestor_queue.pop() {
2933 if !visited.insert(ancestor_idx) {
2934 continue;
2935 }
2936 for target in &self.graph[ancestor_idx].output_targets {
2937 if let Some(rel) = target.to_str() {
2938 if let Err(e) = crate::tools::copy_to_sandbox(
2939 &self.context.working_dir,
2940 &sandbox_path,
2941 rel,
2942 ) {
2943 log::debug!(
2944 "Could not seed sandbox with ancestor file {}: {}",
2945 rel,
2946 e
2947 );
2948 }
2949 }
2950 }
2951 for grandparent in self
2953 .graph
2954 .neighbors_directed(ancestor_idx, petgraph::Direction::Incoming)
2955 {
2956 ancestor_queue.push(grandparent);
2957 }
2958 }
2959 }
2960 Err(e) => {
2961 log::warn!("Failed to create sandbox for branch {}: {}", branch_id, e);
2962 }
2963 }
2964
2965 self.emit_event(perspt_core::AgentEvent::BranchCreated {
2966 branch_id: branch_id.clone(),
2967 node_id,
2968 parent_node_id,
2969 });
2970 log::info!("Created provisional branch {} for node", branch_id);
2971
2972 Some(branch_id)
2973 }
2974
2975 fn merge_provisional_branch(&mut self, branch_id: &str, idx: NodeIndex) {
2977 let node_id = self.graph[idx].node_id.clone();
2978 if let Err(e) = self
2979 .ledger
2980 .update_branch_state(branch_id, &ProvisionalBranchState::Merged.to_string())
2981 {
2982 log::warn!("Failed to merge branch {}: {}", branch_id, e);
2983 }
2984
2985 let sandbox_path = self
2987 .context
2988 .working_dir
2989 .join(".perspt")
2990 .join("sandboxes")
2991 .join(&self.context.session_id)
2992 .join(branch_id);
2993 if let Err(e) = crate::tools::cleanup_sandbox(&sandbox_path) {
2994 log::warn!(
2995 "Failed to cleanup sandbox for merged branch {}: {}",
2996 branch_id,
2997 e
2998 );
2999 }
3000
3001 self.emit_event(perspt_core::AgentEvent::BranchMerged {
3002 branch_id: branch_id.to_string(),
3003 node_id,
3004 });
3005 log::info!("Merged provisional branch {}", branch_id);
3006 }
3007
3008 fn flush_provisional_branch(&mut self, branch_id: &str, node_id: &str) {
3010 if let Err(e) = self
3011 .ledger
3012 .update_branch_state(branch_id, &ProvisionalBranchState::Flushed.to_string())
3013 {
3014 log::warn!("Failed to flush branch {}: {}", branch_id, e);
3015 }
3016
3017 let sandbox_path = self
3019 .context
3020 .working_dir
3021 .join(".perspt")
3022 .join("sandboxes")
3023 .join(&self.context.session_id)
3024 .join(branch_id);
3025 if let Err(e) = crate::tools::cleanup_sandbox(&sandbox_path) {
3026 log::warn!(
3027 "Failed to cleanup sandbox for flushed branch {}: {}",
3028 branch_id,
3029 e
3030 );
3031 }
3032
3033 log::info!(
3034 "Flushed provisional branch {} for node {}",
3035 branch_id,
3036 node_id
3037 );
3038 }
3039
3040 fn flush_descendant_branches(&mut self, idx: NodeIndex) {
3046 let parent_node_id = self.graph[idx].node_id.clone();
3047 let session_id = self.context.session_id.clone();
3048
3049 let descendant_indices = self.collect_descendants(idx);
3051
3052 let mut flushed_branch_ids = Vec::new();
3053 let mut requeue_node_ids = Vec::new();
3054
3055 for desc_idx in &descendant_indices {
3056 let desc_node = &self.graph[*desc_idx];
3057 if let Some(ref bid) = desc_node.provisional_branch_id {
3058 let bid_clone = bid.clone();
3060 let nid_clone = desc_node.node_id.clone();
3061 self.flush_provisional_branch(&bid_clone, &nid_clone);
3062 flushed_branch_ids.push(bid_clone);
3063 requeue_node_ids.push(nid_clone);
3064 }
3065 }
3066
3067 if flushed_branch_ids.is_empty() {
3068 return;
3069 }
3070
3071 let flush_record = perspt_core::types::BranchFlushRecord::new(
3073 &session_id,
3074 &parent_node_id,
3075 flushed_branch_ids.clone(),
3076 requeue_node_ids.clone(),
3077 format!(
3078 "Parent node {} failed verification/convergence",
3079 parent_node_id
3080 ),
3081 );
3082 if let Err(e) = self.ledger.record_branch_flush(&flush_record) {
3083 log::warn!("Failed to record branch flush: {}", e);
3084 }
3085
3086 self.emit_event(perspt_core::AgentEvent::BranchFlushed {
3087 parent_node_id: parent_node_id.clone(),
3088 flushed_branch_ids,
3089 reason: format!("Parent {} failed", parent_node_id),
3090 });
3091
3092 log::info!(
3093 "Flushed {} descendant branches for parent {}; {} nodes eligible for requeue",
3094 flush_record.flushed_branch_ids.len(),
3095 parent_node_id,
3096 requeue_node_ids.len(),
3097 );
3098 }
3099
3100 fn collect_descendants(&self, idx: NodeIndex) -> Vec<NodeIndex> {
3103 let mut descendants = Vec::new();
3104 let mut stack = vec![idx];
3105 let mut visited = std::collections::HashSet::new();
3106 visited.insert(idx);
3107
3108 while let Some(current) = stack.pop() {
3109 for child in self
3110 .graph
3111 .neighbors_directed(current, petgraph::Direction::Outgoing)
3112 {
3113 if visited.insert(child) {
3114 descendants.push(child);
3115 stack.push(child);
3116 }
3117 }
3118 }
3119 descendants
3120 }
3121
3122 fn emit_interface_seals(&mut self, idx: NodeIndex) {
3128 let node = &self.graph[idx];
3129 if node.node_class != NodeClass::Interface {
3130 return;
3131 }
3132
3133 let node_id = node.node_id.clone();
3134 let session_id = self.context.session_id.clone();
3135 let output_targets: Vec<_> = node.output_targets.clone();
3136 let mut sealed_paths = Vec::new();
3137 let mut seal_hash = [0u8; 32];
3138
3139 let retriever = ContextRetriever::new(self.context.working_dir.clone());
3140
3141 for target in &output_targets {
3142 let path_str = target.to_string_lossy().to_string();
3143 match retriever.compute_structural_digest(
3144 &path_str,
3145 perspt_core::types::ArtifactKind::InterfaceSeal,
3146 &node_id,
3147 ) {
3148 Ok(digest) => {
3149 let seal = perspt_core::types::InterfaceSealRecord::from_digest(
3150 &session_id,
3151 &node_id,
3152 &digest,
3153 );
3154 seal_hash = seal.seal_hash;
3155 sealed_paths.push(path_str);
3156
3157 if let Err(e) = self.ledger.record_interface_seal(&seal) {
3158 log::warn!("Failed to record interface seal: {}", e);
3159 }
3160 }
3161 Err(e) => {
3162 log::debug!("Skipping seal for {}: {}", path_str, e);
3163 }
3164 }
3165 }
3166
3167 if !sealed_paths.is_empty() {
3168 self.graph[idx].interface_seal_hash = Some(seal_hash);
3170
3171 self.emit_event(perspt_core::AgentEvent::InterfaceSealed {
3172 node_id: node_id.clone(),
3173 sealed_paths: sealed_paths.clone(),
3174 seal_hash: seal_hash
3175 .iter()
3176 .map(|b| format!("{:02x}", b))
3177 .collect::<String>(),
3178 });
3179 log::info!(
3180 "Sealed {} interface artifact(s) for node {}",
3181 sealed_paths.len(),
3182 node_id
3183 );
3184 }
3185 }
3186
3187 fn unblock_dependents(&mut self, idx: NodeIndex) {
3189 let node_id = self.graph[idx].node_id.clone();
3190
3191 let (unblocked, remaining): (Vec<_>, Vec<_>) = self
3193 .blocked_dependencies
3194 .drain(..)
3195 .partition(|dep| dep.parent_node_id == node_id);
3196
3197 self.blocked_dependencies = remaining;
3198
3199 for dep in unblocked {
3200 self.emit_event(perspt_core::AgentEvent::DependentUnblocked {
3201 child_node_id: dep.child_node_id.clone(),
3202 parent_node_id: node_id.clone(),
3203 });
3204 log::info!(
3205 "Unblocked dependent {} (parent {} sealed)",
3206 dep.child_node_id,
3207 node_id
3208 );
3209 }
3210 }
3211
3212 fn check_seal_prerequisites(&mut self, idx: NodeIndex) -> bool {
3215 let parents: Vec<NodeIndex> = self
3216 .graph
3217 .neighbors_directed(idx, petgraph::Direction::Incoming)
3218 .collect();
3219
3220 for pidx in parents {
3221 let parent = &self.graph[pidx];
3222 if parent.node_class == NodeClass::Interface
3223 && parent.interface_seal_hash.is_none()
3224 && parent.state != NodeState::Completed
3225 {
3226 let child_node_id = self.graph[idx].node_id.clone();
3228 let parent_node_id = parent.node_id.clone();
3229 let sealed_paths: Vec<String> = parent
3230 .output_targets
3231 .iter()
3232 .map(|p| p.to_string_lossy().to_string())
3233 .collect();
3234
3235 let dep = perspt_core::types::BlockedDependency::new(
3236 &child_node_id,
3237 &parent_node_id,
3238 sealed_paths,
3239 );
3240 self.blocked_dependencies.push(dep);
3241
3242 log::info!(
3243 "Node {} blocked: waiting on interface seal from {}",
3244 child_node_id,
3245 parent_node_id
3246 );
3247 return true;
3248 }
3249 }
3250 false
3251 }
3252
3253 fn check_structural_dependencies(
3259 &self,
3260 node: &SRBNNode,
3261 restriction_map: &perspt_core::types::RestrictionMap,
3262 ) -> Vec<(String, String)> {
3263 use perspt_core::types::{ArtifactKind, NodeClass};
3264
3265 let mut prose_only = Vec::new();
3266
3267 if node.node_class != NodeClass::Implementation {
3269 return prose_only;
3270 }
3271
3272 let idx = match self.node_indices.get(&node.node_id) {
3274 Some(i) => *i,
3275 None => return prose_only,
3276 };
3277
3278 let parents: Vec<NodeIndex> = self
3279 .graph
3280 .neighbors_directed(idx, petgraph::Direction::Incoming)
3281 .collect();
3282
3283 for pidx in parents {
3284 let parent = &self.graph[pidx];
3285 if parent.node_class != NodeClass::Interface {
3286 continue;
3287 }
3288
3289 let has_structural = restriction_map.structural_digests.iter().any(|d| {
3291 d.source_node_id == parent.node_id
3292 && matches!(
3293 d.artifact_kind,
3294 ArtifactKind::Signature
3295 | ArtifactKind::Schema
3296 | ArtifactKind::InterfaceSeal
3297 )
3298 });
3299
3300 if !has_structural {
3301 prose_only.push((
3302 parent.node_id.clone(),
3303 format!(
3304 "Interface node '{}' has no Signature/Schema/InterfaceSeal digest in the restriction map",
3305 parent.node_id
3306 ),
3307 ));
3308 }
3309 }
3310
3311 prose_only
3312 }
3313
3314 fn inject_sealed_interfaces(
3321 &self,
3322 idx: NodeIndex,
3323 restriction_map: &mut perspt_core::types::RestrictionMap,
3324 ) {
3325 let parents: Vec<NodeIndex> = self
3326 .graph
3327 .neighbors_directed(idx, petgraph::Direction::Incoming)
3328 .collect();
3329
3330 for pidx in parents {
3331 let parent = &self.graph[pidx];
3332 if parent.interface_seal_hash.is_none() {
3333 continue;
3334 }
3335
3336 let parent_node_id = &parent.node_id;
3337
3338 let seals = match self.ledger.get_interface_seals(parent_node_id) {
3340 Ok(rows) => rows,
3341 Err(e) => {
3342 log::debug!("Could not query seals for {}: {}", parent_node_id, e);
3343 continue;
3344 }
3345 };
3346
3347 for seal in seals {
3348 restriction_map
3350 .sealed_interfaces
3351 .retain(|p| *p != seal.sealed_path);
3352
3353 let mut hash = [0u8; 32];
3355 let len = seal.seal_hash.len().min(32);
3356 hash[..len].copy_from_slice(&seal.seal_hash[..len]);
3357
3358 let digest = perspt_core::types::StructuralDigest {
3360 digest_id: format!("seal_{}_{}", seal.node_id, seal.sealed_path),
3361 source_node_id: seal.node_id.clone(),
3362 source_path: seal.sealed_path.clone(),
3363 artifact_kind: perspt_core::types::ArtifactKind::InterfaceSeal,
3364 hash,
3365 version: seal.version as u32,
3366 };
3367 restriction_map.structural_digests.push(digest);
3368
3369 log::debug!(
3370 "Injected sealed digest for {} from parent {}",
3371 seal.sealed_path,
3372 parent_node_id,
3373 );
3374 }
3375 }
3376 }
3377}
3378
3379fn parse_node_state(s: &str) -> NodeState {
3381 NodeState::from_display_str(s)
3382}
3383
3384fn parse_node_class(s: &str) -> NodeClass {
3386 match s {
3387 "Interface" => NodeClass::Interface,
3388 "Implementation" => NodeClass::Implementation,
3389 "Integration" => NodeClass::Integration,
3390 _ => NodeClass::default(),
3391 }
3392}
3393
3394#[cfg(test)]
3395mod tests {
3396 use super::verification::verification_stages_for_node;
3397 use super::*;
3398 use std::path::PathBuf;
3399
3400 #[tokio::test]
3401 async fn test_orchestrator_creation() {
3402 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3403 assert_eq!(orch.node_count(), 0);
3404 }
3405
3406 #[tokio::test]
3407 async fn test_add_nodes() {
3408 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3409
3410 let node1 = SRBNNode::new(
3411 "node1".to_string(),
3412 "Test task 1".to_string(),
3413 ModelTier::Architect,
3414 );
3415 let node2 = SRBNNode::new(
3416 "node2".to_string(),
3417 "Test task 2".to_string(),
3418 ModelTier::Actuator,
3419 );
3420
3421 orch.add_node(node1);
3422 orch.add_node(node2);
3423 orch.add_dependency("node1", "node2", "depends_on").unwrap();
3424
3425 assert_eq!(orch.node_count(), 2);
3426 }
3427 #[tokio::test]
3428 async fn test_lsp_key_for_file_resolves_by_plugin() {
3429 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3430 orch.lsp_clients.insert(
3432 "rust".to_string(),
3433 crate::lsp::LspClient::new("rust-analyzer"),
3434 );
3435 orch.lsp_clients
3436 .insert("python".to_string(), crate::lsp::LspClient::new("pylsp"));
3437
3438 assert_eq!(
3440 orch.lsp_key_for_file("src/main.rs"),
3441 Some("rust".to_string())
3442 );
3443 assert_eq!(orch.lsp_key_for_file("app.py"), Some("python".to_string()));
3445 let key = orch.lsp_key_for_file("data.csv");
3447 assert!(key.is_some()); }
3449
3450 fn goal_presence_tmpdir(tag: &str) -> PathBuf {
3455 let dir = std::env::temp_dir().join(format!("perspt-gp-{}-{}", tag, uuid::Uuid::new_v4()));
3456 std::fs::create_dir_all(dir.join("src")).unwrap();
3457 dir
3458 }
3459
3460 #[tokio::test]
3461 async fn goal_presence_raises_energy_for_unwritten_symbol() {
3462 let dir = goal_presence_tmpdir("missing");
3466 std::fs::write(dir.join("src/lib.rs"), "// implement here\n").unwrap();
3467
3468 let mut orch = SRBNOrchestrator::new_for_testing(dir.clone());
3469 orch.context.defer_tests = true;
3470 let mut node = SRBNNode::new(
3471 "n".into(),
3472 "Add a public function `is_even(n: i32) -> bool` returning true for even n.".into(),
3473 ModelTier::Actuator,
3474 );
3475 node.output_targets = vec![PathBuf::from("src/lib.rs")];
3476 node.contract.interface_signature = "pub fn is_even(n: i32) -> bool".into();
3477 node.owner_plugin = "rust".into();
3478 orch.add_node(node);
3479 let idx = orch.node_indices["n"];
3480
3481 let energy = orch.step_verify(idx).await.unwrap();
3482 assert!(
3486 energy.v_str > 0.0,
3487 "missing required symbol must raise structural energy (got {})",
3488 energy.v_str
3489 );
3490 assert!(
3491 energy.total() > orch.graph[idx].monitor.stability_epsilon,
3492 "energy must exceed epsilon so the node is not falsely stable"
3493 );
3494
3495 std::fs::remove_dir_all(&dir).ok();
3496 }
3497
3498 #[tokio::test]
3499 async fn goal_presence_silent_when_symbol_present() {
3500 let dir = goal_presence_tmpdir("present");
3502 std::fs::write(
3503 dir.join("src/lib.rs"),
3504 "pub fn is_even(n: i32) -> bool { n % 2 == 0 }\n",
3505 )
3506 .unwrap();
3507
3508 let mut orch = SRBNOrchestrator::new_for_testing(dir.clone());
3509 orch.context.defer_tests = true;
3510 let mut node = SRBNNode::new("n".into(), "Add `is_even`.".into(), ModelTier::Actuator);
3511 node.output_targets = vec![PathBuf::from("src/lib.rs")];
3512 node.contract.interface_signature = "pub fn is_even(n: i32) -> bool".into();
3513 node.owner_plugin = "rust".into();
3514 orch.add_node(node);
3515 let idx = orch.node_indices["n"];
3516
3517 let energy = orch.step_verify(idx).await.unwrap();
3518 assert_eq!(energy.v_str, 0.0, "satisfied goal must not raise V_str");
3519
3520 std::fs::remove_dir_all(&dir).ok();
3521 }
3522
3523 #[tokio::test]
3528 async fn next_ready_node_respects_dependencies() {
3529 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3530 orch.add_node(SRBNNode::new("a".into(), "g".into(), ModelTier::Actuator));
3531 orch.add_node(SRBNNode::new("b".into(), "g".into(), ModelTier::Actuator));
3532 orch.add_dependency("a", "b", "depends_on").unwrap();
3533
3534 let idx = orch.next_ready_node().unwrap();
3536 assert_eq!(orch.graph[idx].node_id, "a");
3537
3538 let a_idx = orch.node_indices["a"];
3540 orch.graph[a_idx].state = NodeState::Completed;
3541 let idx2 = orch.next_ready_node().unwrap();
3542 assert_eq!(orch.graph[idx2].node_id, "b");
3543 }
3544
3545 #[tokio::test]
3546 async fn reworked_retry_node_is_ready_again() {
3547 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3549 orch.add_node(SRBNNode::new("a".into(), "g".into(), ModelTier::Actuator));
3550 let a = orch.node_indices["a"];
3551 orch.graph[a].state = NodeState::Retry;
3552 assert_eq!(orch.next_ready_node(), Some(a));
3553
3554 orch.graph[a].state = NodeState::Completed;
3556 assert_eq!(orch.next_ready_node(), None);
3557 orch.graph[a].state = NodeState::Escalated;
3558 assert_eq!(orch.next_ready_node(), None);
3559 }
3560
3561 #[tokio::test]
3562 async fn deterministic_goal_gate_requires_all_completed() {
3563 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3564 orch.add_node(SRBNNode::new("a".into(), "g".into(), ModelTier::Actuator));
3565 assert!(!orch.deterministic_goal_gate()); let a = orch.node_indices["a"];
3567 orch.graph[a].state = NodeState::Completed;
3568 assert!(orch.deterministic_goal_gate());
3569
3570 let empty = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3572 assert!(!empty.deterministic_goal_gate());
3573 }
3574
3575 fn seed_impl_plan(orch: &mut SRBNOrchestrator) {
3576 use perspt_core::types::PlannedTask;
3577 let plan = TaskPlan {
3578 tasks: vec![PlannedTask {
3579 id: "impl".into(),
3580 goal: "implement".into(),
3581 output_files: vec!["src/lib.rs".into()],
3582 ..PlannedTask::new("impl", "implement")
3583 }],
3584 };
3585 orch.create_nodes_from_plan(&plan).unwrap();
3586 }
3587
3588 #[tokio::test]
3589 async fn merge_amendment_appends_valid_tasks_and_edges() {
3590 use perspt_core::types::PlannedTask;
3591 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3592 seed_impl_plan(&mut orch);
3593
3594 let amend = TaskPlan {
3595 tasks: vec![PlannedTask {
3596 id: "tests".into(),
3597 goal: "write tests".into(),
3598 output_files: vec!["tests/test_lib.rs".into()],
3599 dependencies: vec!["impl".into()],
3600 ..PlannedTask::new("tests", "write tests")
3601 }],
3602 };
3603 let added = orch.merge_plan_amendment(&amend).unwrap();
3604 assert_eq!(added, 1);
3605 assert!(orch.node_indices.contains_key("tests"));
3606 let impl_idx = orch.node_indices["impl"];
3607 let test_idx = orch.node_indices["tests"];
3608 assert!(orch.graph.find_edge(impl_idx, test_idx).is_some());
3609 }
3610
3611 #[tokio::test]
3612 async fn merge_amendment_rejects_ownership_collision() {
3613 use perspt_core::types::PlannedTask;
3614 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3615 seed_impl_plan(&mut orch);
3616 let amend = TaskPlan {
3617 tasks: vec![PlannedTask {
3618 id: "extra".into(),
3619 goal: "g".into(),
3620 output_files: vec!["src/lib.rs".into()], ..PlannedTask::new("extra", "g")
3622 }],
3623 };
3624 assert!(orch.merge_plan_amendment(&amend).is_err());
3625 }
3626
3627 #[tokio::test]
3628 async fn merge_amendment_rejects_duplicate_id_and_unknown_dep() {
3629 use perspt_core::types::PlannedTask;
3630 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3631 seed_impl_plan(&mut orch);
3632
3633 let dup = TaskPlan {
3634 tasks: vec![PlannedTask {
3635 id: "impl".into(), goal: "g".into(),
3637 output_files: vec!["src/other.rs".into()],
3638 ..PlannedTask::new("impl", "g")
3639 }],
3640 };
3641 assert!(orch.merge_plan_amendment(&dup).is_err());
3642
3643 let bad_dep = TaskPlan {
3644 tasks: vec![PlannedTask {
3645 id: "more".into(),
3646 goal: "g".into(),
3647 output_files: vec!["src/other.rs".into()],
3648 dependencies: vec!["nonexistent".into()],
3649 ..PlannedTask::new("more", "g")
3650 }],
3651 };
3652 assert!(orch.merge_plan_amendment(&bad_dep).is_err());
3653 }
3654
3655 #[test]
3656 fn interface_node_runs_only_syntax_so_build_penalty_is_gated() {
3657 use perspt_core::plugin::VerifierStage;
3661 let mut node = SRBNNode::new("scaffold".into(), "g".into(), ModelTier::Actuator);
3662 node.node_class = perspt_core::types::NodeClass::Interface;
3663 node.output_targets = vec![PathBuf::from("pyproject.toml")];
3664 let stages = super::verification::verification_stages_for_node(&node);
3665 assert_eq!(stages, vec![VerifierStage::SyntaxCheck]);
3666 assert!(!stages.contains(&VerifierStage::Build));
3667 }
3668
3669 #[test]
3670 fn parse_goal_verdict_is_tolerant() {
3671 let v = super::parse_goal_verdict("sure thing: {\"achieved\": true, \"missing\": []} ok")
3672 .unwrap();
3673 assert!(v.achieved);
3674 let v2 = super::parse_goal_verdict(
3675 "{\"achieved\": false, \"missing\": [\"add tests\"], \"next_steps\": []}",
3676 )
3677 .unwrap();
3678 assert!(!v2.achieved);
3679 assert_eq!(v2.missing, vec!["add tests"]);
3680 assert!(super::parse_goal_verdict("no json here").is_none());
3681 }
3682
3683 #[tokio::test]
3688 async fn test_split_node_creates_children() {
3689 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3690 let mut node = SRBNNode::new("parent".into(), "Do everything".into(), ModelTier::Actuator);
3691 node.output_targets = vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")];
3692 orch.add_node(node);
3693
3694 let idx = orch.node_indices["parent"];
3695 let applied = orch.split_node(idx, &["handle a.rs".into(), "handle b.rs".into()]);
3696 assert!(!applied.is_empty());
3697 assert!(!orch.node_indices.contains_key("parent"));
3699 assert!(orch.node_indices.contains_key("parent__split_0"));
3701 assert!(orch.node_indices.contains_key("parent__split_1"));
3702 }
3703
3704 #[tokio::test]
3705 async fn test_split_node_empty_children_is_noop() {
3706 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3707 let node = SRBNNode::new("n".into(), "g".into(), ModelTier::Actuator);
3708 orch.add_node(node);
3709 let idx = orch.node_indices["n"];
3710 let applied = orch.split_node(idx, &[]);
3711 assert!(applied.is_empty());
3713 }
3714
3715 #[tokio::test]
3716 async fn test_insert_interface_node() {
3717 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3718 let n1 = SRBNNode::new("a".into(), "source".into(), ModelTier::Actuator);
3719 let n2 = SRBNNode::new("b".into(), "dest".into(), ModelTier::Actuator);
3720 orch.add_node(n1);
3721 orch.add_node(n2);
3722 orch.add_dependency("a", "b", "data_flow").unwrap();
3723
3724 let idx_a = orch.node_indices["a"];
3725 let applied = orch.insert_interface_node(idx_a, "API boundary");
3726 assert!(applied.is_some());
3727 assert!(orch.node_indices.contains_key("a__iface"));
3728 assert_eq!(orch.node_count(), 3);
3730 }
3731
3732 #[tokio::test]
3733 async fn test_replan_subgraph_resets_nodes() {
3734 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3735 let mut n1 = SRBNNode::new("trigger".into(), "g1".into(), ModelTier::Actuator);
3736 n1.state = NodeState::Coding;
3737 let mut n2 = SRBNNode::new("dep".into(), "g2".into(), ModelTier::Actuator);
3738 n2.state = NodeState::Completed;
3739 orch.add_node(n1);
3740 orch.add_node(n2);
3741
3742 let trigger_idx = orch.node_indices["trigger"];
3743 let applied = orch.replan_subgraph(trigger_idx, &["dep".into()]);
3744 assert!(applied);
3745
3746 let dep_idx = orch.node_indices["dep"];
3747 assert_eq!(orch.graph[dep_idx].state, NodeState::TaskQueued);
3748 assert_eq!(orch.graph[trigger_idx].state, NodeState::Retry);
3749 }
3750
3751 #[tokio::test]
3752 async fn test_select_validators_always_includes_dependency_graph() {
3753 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3754 let node = SRBNNode::new("n".into(), "g".into(), ModelTier::Actuator);
3755 orch.add_node(node);
3756 let idx = orch.node_indices["n"];
3757
3758 let validators = orch.select_validators(idx);
3759 assert!(validators.contains(&SheafValidatorClass::DependencyGraphConsistency));
3760 }
3761
3762 #[tokio::test]
3763 async fn test_select_validators_interface_node() {
3764 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3765 let mut node = SRBNNode::new("iface".into(), "g".into(), ModelTier::Actuator);
3766 node.node_class = perspt_core::types::NodeClass::Interface;
3767 orch.add_node(node);
3768 let idx = orch.node_indices["iface"];
3769
3770 let validators = orch.select_validators(idx);
3771 assert!(validators.contains(&SheafValidatorClass::ExportImportConsistency));
3772 }
3773
3774 #[tokio::test]
3775 async fn test_run_sheaf_validator_dependency_graph_no_cycles() {
3776 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3777 let n1 = SRBNNode::new("a".into(), "g".into(), ModelTier::Actuator);
3778 let n2 = SRBNNode::new("b".into(), "g".into(), ModelTier::Actuator);
3779 orch.add_node(n1);
3780 orch.add_node(n2);
3781 orch.add_dependency("a", "b", "dep").unwrap();
3782
3783 let idx = orch.node_indices["a"];
3784 let result = orch.run_sheaf_validator(idx, SheafValidatorClass::DependencyGraphConsistency);
3785 assert!(result.passed);
3786 assert_eq!(result.v_sheaf_contribution, 0.0);
3787 }
3788
3789 #[tokio::test]
3790 async fn test_classify_non_convergence_default() {
3791 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3792 let node = SRBNNode::new("n".into(), "g".into(), ModelTier::Actuator);
3793 orch.add_node(node);
3794 let idx = orch.node_indices["n"];
3795
3796 let category = orch.classify_non_convergence(idx);
3798 assert_eq!(category, EscalationCategory::ImplementationError);
3799 }
3800
3801 #[tokio::test]
3802 async fn test_affected_dependents() {
3803 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3804 let n1 = SRBNNode::new("root".into(), "g".into(), ModelTier::Actuator);
3805 let n2 = SRBNNode::new("child1".into(), "g".into(), ModelTier::Actuator);
3806 let n3 = SRBNNode::new("child2".into(), "g".into(), ModelTier::Actuator);
3807 orch.add_node(n1);
3808 orch.add_node(n2);
3809 orch.add_node(n3);
3810 orch.add_dependency("root", "child1", "dep").unwrap();
3811 orch.add_dependency("root", "child2", "dep").unwrap();
3812
3813 let idx = orch.node_indices["root"];
3814 let deps = orch.affected_dependents(idx);
3815 assert_eq!(deps.len(), 2);
3816 assert!(deps.contains(&"child1".to_string()));
3817 assert!(deps.contains(&"child2".to_string()));
3818 }
3819
3820 #[tokio::test]
3825 async fn test_maybe_create_provisional_branch_root_node() {
3826 let temp_dir =
3827 std::env::temp_dir().join(format!("perspt_root_branch_{}", uuid::Uuid::new_v4()));
3828 std::fs::create_dir_all(&temp_dir).unwrap();
3829
3830 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
3831 orch.context.session_id = "test_session".into();
3832 let node = SRBNNode::new("root".into(), "root goal".into(), ModelTier::Actuator);
3833 orch.add_node(node);
3834
3835 let idx = orch.node_indices["root"];
3836 let branch = orch.maybe_create_provisional_branch(idx);
3838 assert!(branch.is_some());
3839 assert!(orch.graph[idx].provisional_branch_id.is_some());
3840
3841 let _ = std::fs::remove_dir_all(&temp_dir);
3842 }
3843
3844 #[tokio::test]
3845 async fn test_maybe_create_provisional_branch_child_node() {
3846 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_phase6"));
3847 orch.context.session_id = "test_session".into();
3848 let parent = SRBNNode::new("parent".into(), "parent goal".into(), ModelTier::Actuator);
3849 let child = SRBNNode::new("child".into(), "child goal".into(), ModelTier::Actuator);
3850 orch.add_node(parent);
3851 orch.add_node(child);
3852 orch.add_dependency("parent", "child", "dep").unwrap();
3853
3854 let idx = orch.node_indices["child"];
3855 let branch = orch.maybe_create_provisional_branch(idx);
3856 assert!(branch.is_some());
3857 assert!(orch.graph[idx].provisional_branch_id.is_some());
3858 }
3859
3860 #[tokio::test]
3861 async fn test_collect_descendants() {
3862 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3863 let n1 = SRBNNode::new("a".into(), "g".into(), ModelTier::Actuator);
3864 let n2 = SRBNNode::new("b".into(), "g".into(), ModelTier::Actuator);
3865 let n3 = SRBNNode::new("c".into(), "g".into(), ModelTier::Actuator);
3866 let n4 = SRBNNode::new("d".into(), "g".into(), ModelTier::Actuator);
3867 orch.add_node(n1);
3868 orch.add_node(n2);
3869 orch.add_node(n3);
3870 orch.add_node(n4);
3871 orch.add_dependency("a", "b", "dep").unwrap();
3872 orch.add_dependency("b", "c", "dep").unwrap();
3873 orch.add_dependency("a", "d", "dep").unwrap();
3874
3875 let idx_a = orch.node_indices["a"];
3876 let descendants = orch.collect_descendants(idx_a);
3877 assert_eq!(descendants.len(), 3); }
3879
3880 #[tokio::test]
3881 async fn test_check_seal_prerequisites_no_interface_parent() {
3882 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3883 let parent = SRBNNode::new("parent".into(), "g".into(), ModelTier::Actuator);
3884 let child = SRBNNode::new("child".into(), "g".into(), ModelTier::Actuator);
3885 orch.add_node(parent);
3886 orch.add_node(child);
3887 orch.add_dependency("parent", "child", "dep").unwrap();
3888
3889 let idx = orch.node_indices["child"];
3890 assert!(!orch.check_seal_prerequisites(idx));
3892 assert!(orch.blocked_dependencies.is_empty());
3893 }
3894
3895 #[tokio::test]
3896 async fn test_check_seal_prerequisites_unsealed_interface() {
3897 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3898 let mut parent = SRBNNode::new("iface".into(), "g".into(), ModelTier::Actuator);
3899 parent.node_class = perspt_core::types::NodeClass::Interface;
3900 let child = SRBNNode::new("impl".into(), "g".into(), ModelTier::Actuator);
3901 orch.add_node(parent);
3902 orch.add_node(child);
3903 orch.add_dependency("iface", "impl", "dep").unwrap();
3904
3905 let idx = orch.node_indices["impl"];
3906 assert!(orch.check_seal_prerequisites(idx));
3908 assert_eq!(orch.blocked_dependencies.len(), 1);
3909 assert_eq!(orch.blocked_dependencies[0].parent_node_id, "iface");
3910 }
3911
3912 #[tokio::test]
3913 async fn test_check_seal_prerequisites_sealed_interface() {
3914 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3915 let mut parent = SRBNNode::new("iface".into(), "g".into(), ModelTier::Actuator);
3916 parent.node_class = perspt_core::types::NodeClass::Interface;
3917 parent.interface_seal_hash = Some([1u8; 32]); let child = SRBNNode::new("impl".into(), "g".into(), ModelTier::Actuator);
3919 orch.add_node(parent);
3920 orch.add_node(child);
3921 orch.add_dependency("iface", "impl", "dep").unwrap();
3922
3923 let idx = orch.node_indices["impl"];
3924 assert!(!orch.check_seal_prerequisites(idx));
3926 assert!(orch.blocked_dependencies.is_empty());
3927 }
3928
3929 #[tokio::test]
3930 async fn test_unblock_dependents() {
3931 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
3932 let parent = SRBNNode::new("parent".into(), "g".into(), ModelTier::Actuator);
3933 let child = SRBNNode::new("child".into(), "g".into(), ModelTier::Actuator);
3934 orch.add_node(parent);
3935 orch.add_node(child);
3936
3937 orch.blocked_dependencies
3939 .push(perspt_core::types::BlockedDependency::new(
3940 "child",
3941 "parent",
3942 vec!["src/api.rs".into()],
3943 ));
3944 assert_eq!(orch.blocked_dependencies.len(), 1);
3945
3946 let idx = orch.node_indices["parent"];
3947 orch.unblock_dependents(idx);
3948 assert!(orch.blocked_dependencies.is_empty());
3949 }
3950
3951 #[tokio::test]
3952 async fn test_flush_descendant_branches() {
3953 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_phase6_flush"));
3954 orch.context.session_id = "test_session".into();
3955
3956 let parent = SRBNNode::new("parent".into(), "g".into(), ModelTier::Actuator);
3957 let mut child1 = SRBNNode::new("child1".into(), "g".into(), ModelTier::Actuator);
3958 child1.provisional_branch_id = Some("branch_c1".into());
3959 let mut child2 = SRBNNode::new("child2".into(), "g".into(), ModelTier::Actuator);
3960 child2.provisional_branch_id = Some("branch_c2".into());
3961 let grandchild = SRBNNode::new("grandchild".into(), "g".into(), ModelTier::Actuator);
3962 orch.add_node(parent);
3963 orch.add_node(child1);
3964 orch.add_node(child2);
3965 orch.add_node(grandchild);
3966 orch.add_dependency("parent", "child1", "dep").unwrap();
3967 orch.add_dependency("parent", "child2", "dep").unwrap();
3968 orch.add_dependency("child1", "grandchild", "dep").unwrap();
3969
3970 let idx = orch.node_indices["parent"];
3971 orch.flush_descendant_branches(idx);
3974 }
3975
3976 #[tokio::test]
3981 async fn test_effective_working_dir_no_branch() {
3982 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/test/workspace"));
3983 let mut orch = orch;
3985 let node = SRBNNode::new("n1".into(), "goal".into(), ModelTier::Actuator);
3986 orch.add_node(node);
3987 let idx = orch.node_indices["n1"];
3988 assert_eq!(
3990 orch.effective_working_dir(idx),
3991 PathBuf::from("/test/workspace")
3992 );
3993 }
3994
3995 #[tokio::test]
3996 async fn test_sandbox_dir_for_node_none_without_branch() {
3997 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/test/workspace"));
3998 let mut orch = orch;
3999 let node = SRBNNode::new("n1".into(), "goal".into(), ModelTier::Actuator);
4000 orch.add_node(node);
4001 let idx = orch.node_indices["n1"];
4002 assert!(orch.sandbox_dir_for_node(idx).is_none());
4003 }
4004
4005 #[tokio::test]
4006 async fn test_rewrite_churn_guardrail() {
4007 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_churn"));
4008 let mut orch = orch;
4009 let node = SRBNNode::new("node_a".into(), "goal".into(), ModelTier::Actuator);
4010 orch.add_node(node);
4011 let count = orch.count_lineage_rewrites("node_a");
4013 assert_eq!(count, 0);
4014 }
4015
4016 #[tokio::test]
4017 async fn test_run_resumed_skips_terminal_nodes() {
4018 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_resume"));
4019
4020 let mut n1 = SRBNNode::new("done".into(), "completed".into(), ModelTier::Actuator);
4021 n1.state = NodeState::Completed;
4022 let mut n2 = SRBNNode::new("failed".into(), "failed".into(), ModelTier::Actuator);
4023 n2.state = NodeState::Failed;
4024 orch.add_node(n1);
4025 orch.add_node(n2);
4026
4027 let result = orch.run_resumed().await;
4029 assert!(result.is_ok());
4030 }
4031
4032 #[tokio::test]
4033 async fn test_persist_review_decision_no_panic() {
4034 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_review"));
4035 orch.persist_review_decision("node_x", "approved", None);
4038 }
4039
4040 #[tokio::test]
4045 async fn test_check_structural_dependencies_blocks_prose_only() {
4046 use perspt_core::types::{NodeClass, RestrictionMap};
4047
4048 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_struct_dep"));
4049
4050 let mut parent = SRBNNode::new("iface_1".into(), "Define API".into(), ModelTier::Architect);
4052 parent.node_class = NodeClass::Interface;
4053
4054 let mut child = SRBNNode::new("impl_1".into(), "Implement API".into(), ModelTier::Actuator);
4056 child.node_class = NodeClass::Implementation;
4057
4058 let parent_idx = orch.add_node(parent);
4059 let child_idx = orch.add_node(child.clone());
4060 orch.graph
4061 .add_edge(parent_idx, child_idx, Dependency { kind: "dep".into() });
4062
4063 let rmap = RestrictionMap::for_node("impl_1");
4065 let gaps = orch.check_structural_dependencies(&child, &rmap);
4066
4067 assert_eq!(gaps.len(), 1);
4068 assert_eq!(gaps[0].0, "iface_1");
4069 assert!(gaps[0].1.contains("no Signature/Schema/InterfaceSeal"));
4070 }
4071
4072 #[tokio::test]
4073 async fn test_check_structural_dependencies_passes_with_digest() {
4074 use perspt_core::types::{ArtifactKind, NodeClass, RestrictionMap, StructuralDigest};
4075
4076 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_struct_ok"));
4077
4078 let mut parent = SRBNNode::new("iface_2".into(), "Define API".into(), ModelTier::Architect);
4079 parent.node_class = NodeClass::Interface;
4080
4081 let mut child = SRBNNode::new("impl_2".into(), "Implement API".into(), ModelTier::Actuator);
4082 child.node_class = NodeClass::Implementation;
4083
4084 let parent_idx = orch.add_node(parent);
4085 let child_idx = orch.add_node(child.clone());
4086 orch.graph
4087 .add_edge(parent_idx, child_idx, Dependency { kind: "dep".into() });
4088
4089 let mut rmap = RestrictionMap::for_node("impl_2");
4091 rmap.structural_digests.push(StructuralDigest::from_content(
4092 "iface_2",
4093 "api.rs",
4094 ArtifactKind::Signature,
4095 b"fn do_thing(x: i32) -> bool;",
4096 ));
4097
4098 let gaps = orch.check_structural_dependencies(&child, &rmap);
4099 assert!(gaps.is_empty(), "Expected no gaps when digest present");
4100 }
4101
4102 #[tokio::test]
4103 async fn test_check_structural_dependencies_skips_non_implementation() {
4104 use perspt_core::types::{NodeClass, RestrictionMap};
4105
4106 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_struct_skip"));
4107
4108 let mut node = SRBNNode::new("integ_1".into(), "Wire modules".into(), ModelTier::Actuator);
4110 node.node_class = NodeClass::Integration;
4111 orch.add_node(node.clone());
4112
4113 let rmap = RestrictionMap::for_node("integ_1");
4114 let gaps = orch.check_structural_dependencies(&node, &rmap);
4115 assert!(gaps.is_empty(), "Integration nodes should skip the check");
4116 }
4117
4118 #[tokio::test]
4119 async fn test_tier_default_models_are_differentiated() {
4120 let arch = ModelTier::Architect.default_model();
4122 let act = ModelTier::Actuator.default_model();
4123 let spec = ModelTier::Speculator.default_model();
4124
4125 assert_ne!(arch, act, "Architect and Actuator defaults should differ");
4127 assert_ne!(spec, arch, "Speculator should differ from Architect");
4129 }
4130
4131 #[tokio::test]
4136 async fn test_orchestrator_stores_all_four_tier_models() {
4137 let orch = SRBNOrchestrator::new_with_models(
4138 PathBuf::from("/tmp/test_tiers"),
4139 false,
4140 Some("arch-model".into()),
4141 Some("act-model".into()),
4142 Some("ver-model".into()),
4143 Some("spec-model".into()),
4144 None,
4145 None,
4146 None,
4147 None,
4148 );
4149 assert_eq!(orch.architect_model, "arch-model");
4150 assert_eq!(orch.actuator_model, "act-model");
4151 assert_eq!(orch.verifier_model, "ver-model");
4152 assert_eq!(orch.speculator_model, "spec-model");
4153 }
4154
4155 #[tokio::test]
4156 async fn test_orchestrator_default_tier_models() {
4157 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_tier_defaults"));
4158 assert_eq!(orch.architect_model, ModelTier::Architect.default_model());
4159 assert_eq!(orch.actuator_model, ModelTier::Actuator.default_model());
4160 assert_eq!(orch.verifier_model, ModelTier::Verifier.default_model());
4161 assert_eq!(orch.speculator_model, ModelTier::Speculator.default_model());
4162 }
4163
4164 #[tokio::test]
4165 async fn test_deterministic_fallback_graph_is_valid_plan() {
4166 for task in [
4171 "build a python RPN calculator library",
4172 "build a rust command-line tool",
4173 "build a javascript utility package",
4174 ] {
4175 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
4176 let result = orch.create_deterministic_fallback_graph(task);
4177 assert!(
4178 result.is_ok(),
4179 "fallback graph for {task:?} must be a valid plan, got {result:?}"
4180 );
4181 assert_eq!(orch.node_count(), 3, "fallback plan should have 3 nodes");
4182 }
4183 }
4184
4185 #[tokio::test]
4186 async fn test_create_nodes_rejects_duplicate_output_files() {
4187 use perspt_core::types::PlannedTask;
4188
4189 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_dup_outputs"));
4190
4191 let plan = TaskPlan {
4192 tasks: vec![
4193 PlannedTask {
4194 id: "task_1".into(),
4195 goal: "Create math".into(),
4196 output_files: vec!["src/math.py".into(), "tests/test_math.py".into()],
4197 ..PlannedTask::new("task_1", "Create math")
4198 },
4199 PlannedTask {
4200 id: "task_2".into(),
4201 goal: "Create tests".into(),
4202 output_files: vec!["tests/test_math.py".into()],
4203 ..PlannedTask::new("task_2", "Create tests")
4204 },
4205 ],
4206 };
4207
4208 let result = orch.create_nodes_from_plan(&plan);
4209 assert!(result.is_err(), "Should reject duplicate output_files");
4210 let err = result.unwrap_err().to_string();
4211 assert!(
4212 err.contains("tests/test_math.py"),
4213 "Error should mention the duplicate file: {}",
4214 err
4215 );
4216 }
4217
4218 #[tokio::test]
4219 async fn test_create_nodes_accepts_unique_output_files() {
4220 use perspt_core::types::PlannedTask;
4221
4222 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_unique_outputs"));
4223
4224 let plan = TaskPlan {
4225 tasks: vec![
4226 PlannedTask {
4227 id: "task_1".into(),
4228 goal: "Create math".into(),
4229 output_files: vec!["src/math.py".into()],
4230 ..PlannedTask::new("task_1", "Create math")
4231 },
4232 PlannedTask {
4233 id: "test_1".into(),
4234 goal: "Test math".into(),
4235 output_files: vec!["tests/test_math.py".into()],
4236 dependencies: vec!["task_1".into()],
4237 ..PlannedTask::new("test_1", "Test math")
4238 },
4239 ],
4240 };
4241
4242 let result = orch.create_nodes_from_plan(&plan);
4243 assert!(result.is_ok(), "Should accept unique output_files");
4244 assert_eq!(orch.graph.node_count(), 2);
4245 }
4246
4247 #[tokio::test]
4248 async fn test_ownership_manifest_built_with_majority_plugin_vote() {
4249 use perspt_core::types::PlannedTask;
4250
4251 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_plugin_vote"));
4252
4253 let plan = TaskPlan {
4254 tasks: vec![PlannedTask {
4255 id: "task_1".into(),
4256 goal: "Create Python module".into(),
4257 output_files: vec![
4258 "src/main.py".into(),
4259 "src/helper.py".into(),
4260 "src/__init__.py".into(),
4261 ],
4262 ..PlannedTask::new("task_1", "Create Python module")
4263 }],
4264 };
4265
4266 orch.create_nodes_from_plan(&plan).unwrap();
4267
4268 assert_eq!(orch.context.ownership_manifest.len(), 3);
4270 let idx = orch.node_indices["task_1"];
4272 assert_eq!(orch.graph[idx].owner_plugin, "python");
4273 }
4274
4275 #[tokio::test]
4276 async fn test_apply_bundle_strips_paths_outside_node_output_targets() {
4277 use perspt_core::types::{ArtifactBundle, ArtifactOperation, PlannedTask};
4278
4279 let temp_dir = std::env::temp_dir().join(format!(
4280 "perspt_bundle_target_guard_{}",
4281 uuid::Uuid::new_v4()
4282 ));
4283 std::fs::create_dir_all(temp_dir.join("src")).unwrap();
4284
4285 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
4286 let plan = TaskPlan {
4287 tasks: vec![
4288 PlannedTask {
4289 id: "validate_module".into(),
4290 goal: "Create validation module".into(),
4291 output_files: vec!["src/validate.rs".into()],
4292 ..PlannedTask::new("validate_module", "Create validation module")
4293 },
4294 PlannedTask {
4295 id: "lib_module".into(),
4296 goal: "Export validation module".into(),
4297 output_files: vec!["src/lib.rs".into()],
4298 dependencies: vec!["validate_module".into()],
4299 ..PlannedTask::new("lib_module", "Export validation module")
4300 },
4301 ],
4302 };
4303
4304 orch.create_nodes_from_plan(&plan).unwrap();
4305
4306 let bundle = ArtifactBundle {
4307 artifacts: vec![
4308 ArtifactOperation::Write {
4309 path: "src/validate.rs".into(),
4310 content: "pub fn ok() {}".into(),
4311 },
4312 ArtifactOperation::Write {
4313 path: "src/lib.rs".into(),
4314 content: "pub mod validate;".into(),
4315 },
4316 ],
4317 commands: vec![],
4318 };
4319
4320 orch.apply_bundle_transactionally(
4323 &bundle,
4324 "validate_module",
4325 perspt_core::types::NodeClass::Implementation,
4326 )
4327 .await
4328 .expect("Should apply valid artifacts after stripping undeclared paths");
4329
4330 assert!(temp_dir.join("src/validate.rs").exists());
4332 assert!(!temp_dir.join("src/lib.rs").exists());
4334 }
4335
4336 #[tokio::test]
4337 async fn test_apply_bundle_keeps_legal_support_file() {
4338 use perspt_core::types::{ArtifactBundle, ArtifactOperation, PlannedTask};
4339
4340 let temp_dir = std::env::temp_dir().join(format!(
4341 "perspt_bundle_support_file_{}",
4342 uuid::Uuid::new_v4()
4343 ));
4344 std::fs::create_dir_all(temp_dir.join("src")).unwrap();
4345
4346 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
4347 let plan = TaskPlan {
4348 tasks: vec![PlannedTask {
4349 id: "main_module".into(),
4350 goal: "Create Rust main".into(),
4351 output_files: vec!["src/main.rs".into()],
4352 ..PlannedTask::new("main_module", "Create Rust main")
4353 }],
4354 };
4355 orch.create_nodes_from_plan(&plan).unwrap();
4356
4357 let bundle = ArtifactBundle {
4358 artifacts: vec![
4359 ArtifactOperation::Write {
4360 path: "src/main.rs".into(),
4361 content: "fn main() {}".into(),
4362 },
4363 ArtifactOperation::Write {
4364 path: "build.rs".into(),
4365 content: "fn main() {}".into(),
4366 },
4367 ],
4368 commands: vec![],
4369 };
4370
4371 orch.apply_bundle_transactionally(
4372 &bundle,
4373 "main_module",
4374 perspt_core::types::NodeClass::Implementation,
4375 )
4376 .await
4377 .expect("legal support files should survive semantic filtering");
4378
4379 assert!(temp_dir.join("src/main.rs").exists());
4380 assert!(temp_dir.join("build.rs").exists());
4381 let _ = std::fs::remove_dir_all(&temp_dir);
4382 }
4383
4384 #[tokio::test]
4385 async fn test_apply_bundle_denies_root_manifest_mutation() {
4386 use perspt_core::types::{ArtifactBundle, ArtifactOperation, PlannedTask};
4387
4388 let temp_dir = std::env::temp_dir().join(format!(
4389 "perspt_bundle_manifest_policy_{}",
4390 uuid::Uuid::new_v4()
4391 ));
4392 std::fs::create_dir_all(temp_dir.join("src")).unwrap();
4393
4394 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
4395 let plan = TaskPlan {
4396 tasks: vec![PlannedTask {
4397 id: "main_module".into(),
4398 goal: "Create Rust main".into(),
4399 output_files: vec!["src/main.rs".into()],
4400 ..PlannedTask::new("main_module", "Create Rust main")
4401 }],
4402 };
4403 orch.create_nodes_from_plan(&plan).unwrap();
4404
4405 let bundle = ArtifactBundle {
4406 artifacts: vec![
4407 ArtifactOperation::Write {
4408 path: "src/main.rs".into(),
4409 content: "fn main() {}".into(),
4410 },
4411 ArtifactOperation::Write {
4412 path: "Cargo.toml".into(),
4413 content: "[package]\nname = \"bad\"\n".into(),
4414 },
4415 ],
4416 commands: vec![],
4417 };
4418
4419 orch.apply_bundle_transactionally(
4420 &bundle,
4421 "main_module",
4422 perspt_core::types::NodeClass::Implementation,
4423 )
4424 .await
4425 .expect("declared artifact should still apply after denied manifest is stripped");
4426
4427 assert!(temp_dir.join("src/main.rs").exists());
4428 assert!(!temp_dir.join("Cargo.toml").exists());
4429 let _ = std::fs::remove_dir_all(&temp_dir);
4430 }
4431
4432 #[tokio::test]
4433 async fn test_apply_bundle_writes_into_branch_sandbox() {
4434 use perspt_core::types::{ArtifactBundle, ArtifactOperation, PlannedTask};
4435
4436 let temp_dir = std::env::temp_dir().join(format!(
4437 "perspt_branch_sandbox_write_{}",
4438 uuid::Uuid::new_v4()
4439 ));
4440 std::fs::create_dir_all(temp_dir.join("src")).unwrap();
4441 std::fs::write(temp_dir.join("src/lib.rs"), "pub fn old() {}\n").unwrap();
4442
4443 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
4444 orch.context.session_id = uuid::Uuid::new_v4().to_string();
4445
4446 let plan = TaskPlan {
4447 tasks: vec![
4448 PlannedTask {
4449 id: "parent".into(),
4450 goal: "Parent node".into(),
4451 output_files: vec!["src/lib.rs".into()],
4452 ..PlannedTask::new("parent", "Parent node")
4453 },
4454 PlannedTask {
4455 id: "child".into(),
4456 goal: "Child node".into(),
4457 context_files: vec!["src/lib.rs".into()],
4458 output_files: vec!["src/child.rs".into()],
4459 dependencies: vec!["parent".into()],
4460 ..PlannedTask::new("child", "Child node")
4461 },
4462 ],
4463 };
4464
4465 orch.create_nodes_from_plan(&plan).unwrap();
4466 let child_idx = orch.node_indices["child"];
4467 let branch_id = orch.maybe_create_provisional_branch(child_idx).unwrap();
4468 let sandbox_dir = orch.sandbox_dir_for_node(child_idx).unwrap();
4469
4470 let bundle = ArtifactBundle {
4471 artifacts: vec![ArtifactOperation::Write {
4472 path: "src/child.rs".into(),
4473 content: "pub fn child() {}\n".into(),
4474 }],
4475 commands: vec![],
4476 };
4477
4478 orch.apply_bundle_transactionally(
4479 &bundle,
4480 "child",
4481 perspt_core::types::NodeClass::Implementation,
4482 )
4483 .await
4484 .unwrap();
4485
4486 assert!(sandbox_dir.join("src/child.rs").exists());
4487 assert!(!temp_dir.join("src/child.rs").exists());
4488
4489 orch.merge_provisional_branch(&branch_id, child_idx);
4490 }
4491
4492 #[test]
4493 fn test_verification_stages_for_node_classes() {
4494 use perspt_core::plugin::VerifierStage;
4495
4496 let interface_node =
4498 SRBNNode::new("iface".into(), "Define trait".into(), ModelTier::Actuator);
4499 let mut interface_node = interface_node;
4501 interface_node.node_class = perspt_core::types::NodeClass::Interface;
4502 let stages = verification_stages_for_node(&interface_node);
4503 assert_eq!(stages, vec![VerifierStage::SyntaxCheck]);
4504
4505 let mut implementation_node = SRBNNode::new(
4507 "impl".into(),
4508 "Implement feature".into(),
4509 ModelTier::Actuator,
4510 );
4511 implementation_node.node_class = perspt_core::types::NodeClass::Implementation;
4512 let stages = verification_stages_for_node(&implementation_node);
4513 assert_eq!(
4514 stages,
4515 vec![VerifierStage::SyntaxCheck, VerifierStage::Build]
4516 );
4517
4518 implementation_node
4520 .contract
4521 .weighted_tests
4522 .push(perspt_core::types::WeightedTest {
4523 test_name: "test_feature".into(),
4524 criticality: perspt_core::types::Criticality::High,
4525 });
4526 let stages = verification_stages_for_node(&implementation_node);
4527 assert_eq!(
4528 stages,
4529 vec![
4530 VerifierStage::SyntaxCheck,
4531 VerifierStage::Build,
4532 VerifierStage::Test
4533 ]
4534 );
4535
4536 let mut integration_node =
4538 SRBNNode::new("test".into(), "Verify feature".into(), ModelTier::Actuator);
4539 integration_node.node_class = perspt_core::types::NodeClass::Integration;
4540 integration_node
4541 .contract
4542 .weighted_tests
4543 .push(perspt_core::types::WeightedTest {
4544 test_name: "test_feature".into(),
4545 criticality: perspt_core::types::Criticality::High,
4546 });
4547 let stages = verification_stages_for_node(&integration_node);
4548 assert_eq!(
4549 stages,
4550 vec![
4551 VerifierStage::SyntaxCheck,
4552 VerifierStage::Build,
4553 VerifierStage::Test,
4554 VerifierStage::Lint,
4555 ]
4556 );
4557 }
4558
4559 #[tokio::test]
4564 async fn test_classify_workspace_empty_dir() {
4565 let temp = tempfile::tempdir().unwrap();
4566 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
4567 let state = orch.classify_workspace("build a web app");
4568 assert!(matches!(state, WorkspaceState::Greenfield { .. }));
4570 }
4571
4572 #[tokio::test]
4573 async fn test_classify_workspace_empty_dir_no_lang() {
4574 let temp = tempfile::tempdir().unwrap();
4575 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
4576 let state = orch.classify_workspace("do something");
4577 match state {
4579 WorkspaceState::Greenfield { inferred_lang } => assert!(inferred_lang.is_none()),
4580 _ => panic!("expected Greenfield, got {:?}", state),
4581 }
4582 }
4583
4584 #[tokio::test]
4585 async fn test_classify_workspace_existing_rust_project() {
4586 let temp = tempfile::tempdir().unwrap();
4587 std::fs::write(
4589 temp.path().join("Cargo.toml"),
4590 "[package]\nname = \"test\"\nversion = \"0.1.0\"",
4591 )
4592 .unwrap();
4593 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
4594 let state = orch.classify_workspace("add a feature");
4595 match state {
4596 WorkspaceState::ExistingProject { plugins } => {
4597 assert!(plugins.contains(&"rust".to_string()));
4598 }
4599 _ => panic!("expected ExistingProject, got {:?}", state),
4600 }
4601 }
4602
4603 #[tokio::test]
4604 async fn test_classify_workspace_existing_python_project() {
4605 let temp = tempfile::tempdir().unwrap();
4606 std::fs::write(
4607 temp.path().join("pyproject.toml"),
4608 "[project]\nname = \"test\"",
4609 )
4610 .unwrap();
4611 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
4612 let state = orch.classify_workspace("add a feature");
4613 match state {
4614 WorkspaceState::ExistingProject { plugins } => {
4615 assert!(plugins.contains(&"python".to_string()));
4616 }
4617 _ => panic!("expected ExistingProject, got {:?}", state),
4618 }
4619 }
4620
4621 #[tokio::test]
4622 async fn test_classify_workspace_existing_js_project() {
4623 let temp = tempfile::tempdir().unwrap();
4624 std::fs::write(temp.path().join("package.json"), "{}").unwrap();
4625 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
4626 let state = orch.classify_workspace("add auth");
4627 match state {
4628 WorkspaceState::ExistingProject { plugins } => {
4629 assert!(plugins.contains(&"javascript".to_string()));
4630 }
4631 _ => panic!("expected ExistingProject, got {:?}", state),
4632 }
4633 }
4634
4635 #[tokio::test]
4636 async fn test_classify_workspace_ambiguous_with_misc_files() {
4637 let temp = tempfile::tempdir().unwrap();
4638 std::fs::write(temp.path().join("notes.txt"), "hello").unwrap();
4640 std::fs::write(temp.path().join("data.csv"), "a,b,c").unwrap();
4641 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
4642 let state = orch.classify_workspace("do something");
4643 assert!(matches!(state, WorkspaceState::Ambiguous));
4644 }
4645
4646 #[tokio::test]
4647 async fn test_classify_workspace_greenfield_with_rust_task() {
4648 let temp = tempfile::tempdir().unwrap();
4649 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
4650 let state = orch.classify_workspace("create a rust CLI tool");
4651 match state {
4652 WorkspaceState::Greenfield { inferred_lang } => {
4653 assert_eq!(inferred_lang, Some("rust".to_string()));
4654 }
4655 _ => panic!("expected Greenfield, got {:?}", state),
4656 }
4657 }
4658
4659 #[tokio::test]
4660 async fn test_classify_workspace_greenfield_with_python_task() {
4661 let temp = tempfile::tempdir().unwrap();
4662 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
4663 let state = orch.classify_workspace("build a python flask API");
4664 match state {
4665 WorkspaceState::Greenfield { inferred_lang } => {
4666 assert_eq!(inferred_lang, Some("python".to_string()));
4667 }
4668 _ => panic!("expected Greenfield, got {:?}", state),
4669 }
4670 }
4671
4672 #[tokio::test]
4677 async fn test_check_prerequisites_returns_true_when_tools_available() {
4678 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
4679 let registry = perspt_core::plugin::PluginRegistry::new();
4680 if let Some(plugin) = registry.get("rust") {
4682 let result = orch.check_tool_prerequisites(plugin);
4683 let _ = result;
4686 }
4687 }
4688
4689 #[test]
4690 fn test_required_binaries_rust_includes_cargo() {
4691 let registry = perspt_core::plugin::PluginRegistry::new();
4692 let plugin = registry.get("rust").unwrap();
4693 let bins = plugin.required_binaries();
4694 assert!(bins.iter().any(|(name, _, _)| *name == "cargo"));
4695 assert!(bins.iter().any(|(name, _, _)| *name == "rustc"));
4696 }
4697
4698 #[test]
4699 fn test_required_binaries_python_includes_uv() {
4700 let registry = perspt_core::plugin::PluginRegistry::new();
4701 let plugin = registry.get("python").unwrap();
4702 let bins = plugin.required_binaries();
4703 assert!(bins.iter().any(|(name, _, _)| *name == "uv"));
4704 assert!(bins.iter().any(|(name, _, _)| *name == "python3"));
4705 }
4706
4707 #[test]
4708 fn test_required_binaries_js_includes_node() {
4709 let registry = perspt_core::plugin::PluginRegistry::new();
4710 let plugin = registry.get("javascript").unwrap();
4711 let bins = plugin.required_binaries();
4712 assert!(bins.iter().any(|(name, _, _)| *name == "node"));
4713 assert!(bins.iter().any(|(name, _, _)| *name == "npm"));
4714 }
4715
4716 #[tokio::test]
4721 async fn test_fallback_defaults_to_none_without_explicit_config() {
4722 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
4723 assert!(orch.architect_fallback_model.is_none());
4724 assert!(orch.actuator_fallback_model.is_none());
4725 assert!(orch.verifier_fallback_model.is_none());
4726 assert!(orch.speculator_fallback_model.is_none());
4727 }
4728
4729 #[tokio::test]
4730 async fn test_explicit_fallback_stored_correctly() {
4731 let orch = SRBNOrchestrator::new_with_models(
4732 PathBuf::from("/tmp/test_fallback"),
4733 false,
4734 None,
4735 None,
4736 None,
4737 None,
4738 Some("gpt-4o".into()),
4739 Some("gpt-4o-mini".into()),
4740 Some("gpt-4o".into()),
4741 Some("gpt-4o-mini".into()),
4742 );
4743 assert_eq!(orch.architect_fallback_model, Some("gpt-4o".to_string()));
4744 assert_eq!(
4745 orch.actuator_fallback_model,
4746 Some("gpt-4o-mini".to_string())
4747 );
4748 assert_eq!(orch.verifier_fallback_model, Some("gpt-4o".to_string()));
4749 assert_eq!(
4750 orch.speculator_fallback_model,
4751 Some("gpt-4o-mini".to_string())
4752 );
4753 }
4754
4755 #[tokio::test]
4756 async fn test_per_tier_models_independent() {
4757 let orch = SRBNOrchestrator::new_with_models(
4758 PathBuf::from("/tmp/test_tiers_independent"),
4759 false,
4760 Some("arch".into()),
4761 Some("act".into()),
4762 Some("ver".into()),
4763 Some("spec".into()),
4764 None,
4765 None,
4766 None,
4767 None,
4768 );
4769 assert_ne!(orch.architect_model, orch.actuator_model);
4771 assert_ne!(orch.verifier_model, orch.speculator_model);
4772 }
4773
4774 #[test]
4779 fn test_extract_missing_python_modules_basic() {
4780 let output = r#"
4781FAILED tests/test_core.py::TestPipeline::test_run - ModuleNotFoundError: No module named 'httpx'
4782E ModuleNotFoundError: No module named 'pydantic'
4783ImportError: No module named 'pyarrow'
4784"#;
4785 let mut missing = SRBNOrchestrator::extract_missing_python_modules(output);
4786 missing.sort();
4787 assert_eq!(missing, vec!["httpx", "pyarrow", "pydantic"]);
4788 }
4789
4790 #[test]
4791 fn test_extract_missing_python_modules_subpackage() {
4792 let output = "ModuleNotFoundError: No module named 'foo.bar.baz'";
4793 let missing = SRBNOrchestrator::extract_missing_python_modules(output);
4794 assert_eq!(missing, vec!["foo"]);
4795 }
4796
4797 #[test]
4798 fn test_extract_missing_python_modules_stdlib_filtered() {
4799 let output = r#"
4800ModuleNotFoundError: No module named 'numpy'
4801ModuleNotFoundError: No module named 'os'
4802ModuleNotFoundError: No module named 'json'
4803"#;
4804 let missing = SRBNOrchestrator::extract_missing_python_modules(output);
4805 assert_eq!(missing, vec!["numpy"]);
4806 }
4807
4808 #[test]
4809 fn test_extract_missing_python_modules_empty() {
4810 let output = "All tests passed!\n3 passed in 0.5s";
4811 let missing = SRBNOrchestrator::extract_missing_python_modules(output);
4812 assert!(missing.is_empty());
4813 }
4814
4815 #[test]
4816 fn test_python_import_to_package_mapping() {
4817 assert_eq!(SRBNOrchestrator::python_import_to_package("PIL"), "pillow");
4818 assert_eq!(SRBNOrchestrator::python_import_to_package("yaml"), "pyyaml");
4819 assert_eq!(
4820 SRBNOrchestrator::python_import_to_package("cv2"),
4821 "opencv-python"
4822 );
4823 assert_eq!(
4824 SRBNOrchestrator::python_import_to_package("sklearn"),
4825 "scikit-learn"
4826 );
4827 assert_eq!(
4828 SRBNOrchestrator::python_import_to_package("bs4"),
4829 "beautifulsoup4"
4830 );
4831 assert_eq!(SRBNOrchestrator::python_import_to_package("httpx"), "httpx");
4833 assert_eq!(
4834 SRBNOrchestrator::python_import_to_package("fastapi"),
4835 "fastapi"
4836 );
4837 }
4838
4839 #[test]
4840 fn test_normalize_command_to_uv_pip_install() {
4841 assert_eq!(
4842 SRBNOrchestrator::normalize_command_to_uv("pip install httpx"),
4843 "uv add httpx"
4844 );
4845 assert_eq!(
4846 SRBNOrchestrator::normalize_command_to_uv("pip3 install httpx pydantic"),
4847 "uv add httpx pydantic"
4848 );
4849 assert_eq!(
4850 SRBNOrchestrator::normalize_command_to_uv("python -m pip install requests"),
4851 "uv add requests"
4852 );
4853 assert_eq!(
4854 SRBNOrchestrator::normalize_command_to_uv("python3 -m pip install flask"),
4855 "uv add flask"
4856 );
4857 }
4858
4859 #[test]
4860 fn test_normalize_command_to_uv_requirements_file() {
4861 assert_eq!(
4862 SRBNOrchestrator::normalize_command_to_uv("pip install -r requirements.txt"),
4863 "uv pip install -r requirements.txt"
4864 );
4865 }
4866
4867 #[test]
4868 fn test_normalize_command_to_uv_passthrough() {
4869 assert_eq!(
4871 SRBNOrchestrator::normalize_command_to_uv("uv add httpx"),
4872 "uv add httpx"
4873 );
4874 assert_eq!(
4876 SRBNOrchestrator::normalize_command_to_uv("cargo add serde"),
4877 "cargo add serde"
4878 );
4879 assert_eq!(
4880 SRBNOrchestrator::normalize_command_to_uv("npm install lodash"),
4881 "npm install lodash"
4882 );
4883 }
4884
4885 #[test]
4886 fn test_extract_commands_from_correction_rust_plugin_policy() {
4887 let response = r#"Here's the fix:
4888Commands:
4889```
4890uv add httpx
4891cargo add serde
4892pip install numpy
4893```
4894File: main.rs
4895```rust
4896use serde;
4897```"#;
4898 let commands = SRBNOrchestrator::extract_commands_from_correction(response, "rust");
4900 assert!(
4901 commands.contains(&"cargo add serde".to_string()),
4902 "{:?}",
4903 commands
4904 );
4905 assert!(
4906 !commands.contains(&"uv add httpx".to_string()),
4907 "Rust plugin should deny uv commands: {:?}",
4908 commands
4909 );
4910 assert!(
4911 !commands.contains(&"pip install numpy".to_string()),
4912 "Rust plugin should deny pip commands: {:?}",
4913 commands
4914 );
4915 }
4916
4917 #[test]
4918 fn test_extract_commands_from_correction_python_plugin_policy() {
4919 let response = r#"Commands:
4920```
4921uv add httpx
4922cargo add serde
4923pip install numpy
4924```"#;
4925 let commands = SRBNOrchestrator::extract_commands_from_correction(response, "python");
4927 assert!(
4928 commands.contains(&"uv add httpx".to_string()),
4929 "{:?}",
4930 commands
4931 );
4932 assert!(
4933 commands.contains(&"pip install numpy".to_string()),
4934 "{:?}",
4935 commands
4936 );
4937 assert!(
4938 !commands.contains(&"cargo add serde".to_string()),
4939 "Python plugin should deny cargo commands: {:?}",
4940 commands
4941 );
4942 }
4943
4944 #[test]
4945 fn test_typed_parse_pipeline_multiple_files() {
4946 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4947 let content = r#"Here are the files:
4948
4949File: src/etl_pipeline/core.py
4950```python
4951def run_pipeline():
4952 pass
4953```
4954
4955File: src/etl_pipeline/validator.py
4956```python
4957def validate(data):
4958 return True
4959```
4960
4961File: tests/test_core.py
4962```python
4963from etl_pipeline.core import run_pipeline
4964
4965def test_run():
4966 run_pipeline()
4967```
4968"#;
4969 let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4970 assert!(state.is_ok(), "Expected successful parse, got {}", state);
4971 let bundle = bundle_opt.unwrap();
4972 assert_eq!(bundle.artifacts.len(), 3, "Expected 3 artifacts");
4973 assert_eq!(bundle.artifacts[0].path(), "src/etl_pipeline/core.py");
4974 assert_eq!(bundle.artifacts[1].path(), "src/etl_pipeline/validator.py");
4975 assert_eq!(bundle.artifacts[2].path(), "tests/test_core.py");
4976 }
4977
4978 #[test]
4979 fn test_typed_parse_pipeline_single_file() {
4980 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4981 let content = r#"File: main.py
4982```python
4983print("hello")
4984```"#;
4985 let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
4986 assert!(state.is_ok());
4987 let bundle = bundle_opt.unwrap();
4988 assert_eq!(bundle.artifacts.len(), 1);
4989 assert_eq!(bundle.artifacts[0].path(), "main.py");
4990 }
4991
4992 #[test]
4993 fn test_typed_parse_pipeline_mixed_file_and_diff() {
4994 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
4995 let content = r#"File: new_module.py
4996```python
4997def new_fn():
4998 pass
4999```
5000
5001Diff: existing.py
5002```diff
5003--- existing.py
5004+++ existing.py
5005@@ -1 +1,2 @@
5006+import new_module
5007 def old_fn():
5008```"#;
5009 let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
5010 assert!(state.is_ok());
5011 let bundle = bundle_opt.unwrap();
5012 assert_eq!(bundle.artifacts.len(), 2);
5013 assert_eq!(bundle.artifacts[0].path(), "new_module.py");
5014 assert!(
5015 bundle.artifacts[0].is_write(),
5016 "new_module.py should be a write"
5017 );
5018 assert_eq!(bundle.artifacts[1].path(), "existing.py");
5019 assert!(
5020 bundle.artifacts[1].is_diff(),
5021 "existing.py should be a diff"
5022 );
5023 }
5024
5025 #[test]
5026 fn test_typed_parse_pipeline_legacy_multi_file() {
5027 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
5028 let content = r#"File: core.py
5029```python
5030def core():
5031 pass
5032```
5033
5034File: utils.py
5035```python
5036def util():
5037 pass
5038```"#;
5039 let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
5040 assert!(state.is_ok(), "Should parse multi-file response");
5041 let bundle = bundle_opt.unwrap();
5042 assert_eq!(bundle.artifacts.len(), 2, "Should have 2 artifacts");
5043 assert_eq!(bundle.artifacts[0].path(), "core.py");
5044 assert_eq!(bundle.artifacts[1].path(), "utils.py");
5045 }
5046
5047 #[test]
5052 fn test_typed_parse_pipeline_structured_json() {
5053 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
5054 let content = r#"Here is the output:
5055```json
5056{
5057 "artifacts": [
5058 {"operation": "write", "path": "src/main.py", "content": "print('hello')"},
5059 {"operation": "diff", "path": "src/lib.py", "patch": "--- a\n+++ b\n@@ -1 +1 @@\n-old\n+new"}
5060 ],
5061 "commands": ["uv add requests"]
5062}
5063```"#;
5064 let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
5065 assert!(state.is_ok(), "Should parse structured JSON bundle");
5066 let bundle = bundle_opt.unwrap();
5067 assert_eq!(bundle.artifacts.len(), 2);
5068 assert!(bundle.artifacts[0].is_write());
5069 assert_eq!(bundle.artifacts[0].path(), "src/main.py");
5070 assert!(bundle.artifacts[1].is_diff());
5071 assert_eq!(bundle.artifacts[1].path(), "src/lib.py");
5072 assert_eq!(bundle.commands, vec!["uv add requests"]);
5073 }
5074
5075 #[test]
5076 fn test_typed_parse_pipeline_schema_invalid_classified() {
5077 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
5078 let content = r#"```json
5079{"foo":"bar"}
5080```"#;
5081 let (bundle_opt, state, record_opt) = orch.parse_artifact_bundle_typed(content, "test", 1);
5082 assert!(bundle_opt.is_none());
5083 assert!(matches!(
5084 state,
5085 perspt_core::types::ParseResultState::SchemaInvalid
5086 ));
5087 let record = record_opt.expect("schema failure should be recorded");
5088 assert!(matches!(
5089 record.retry_classification,
5090 Some(perspt_core::types::RetryClassification::MalformedRetry)
5091 ));
5092 }
5093
5094 #[test]
5095 fn test_typed_parse_pipeline_semantic_rejection_classified() {
5096 use perspt_core::types::PlannedTask;
5097
5098 let mut orch = SRBNOrchestrator::new_for_testing(std::path::PathBuf::from("/tmp/test"));
5099 let plan = TaskPlan {
5100 tasks: vec![PlannedTask {
5101 id: "parser".into(),
5102 goal: "Create parser".into(),
5103 output_files: vec!["src/parser.rs".into()],
5104 ..PlannedTask::new("parser", "Create parser")
5105 }],
5106 };
5107 orch.create_nodes_from_plan(&plan).unwrap();
5108
5109 let content = r#"```json
5110{
5111 "artifacts": [
5112 {"operation": "write", "path": "src/wrong.rs", "content": "pub fn wrong() {}"}
5113 ],
5114 "commands": []
5115}
5116```"#;
5117 let (bundle_opt, state, record_opt) =
5118 orch.parse_artifact_bundle_typed(content, "parser", 1);
5119 assert!(bundle_opt.is_none());
5120 assert!(matches!(
5121 state,
5122 perspt_core::types::ParseResultState::SemanticallyRejected
5123 ));
5124 let record = record_opt.expect("semantic rejection should be recorded");
5125 assert!(matches!(
5126 record.retry_classification,
5127 Some(perspt_core::types::RetryClassification::Retarget)
5128 ));
5129 }
5130
5131 #[test]
5132 fn test_typed_parse_pipeline_json_empty_path_rejected() {
5133 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
5134 let content = r#"```json
5135{
5136 "artifacts": [
5137 {"operation": "write", "path": "", "content": "bad"}
5138 ],
5139 "commands": []
5140}
5141```"#;
5142 let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
5143 assert!(
5144 bundle_opt.is_none(),
5145 "Invalid bundle with empty path should be rejected"
5146 );
5147 assert!(
5148 !state.is_ok(),
5149 "Parse state should not be Ok for invalid bundle: {}",
5150 state
5151 );
5152 }
5153
5154 #[test]
5155 fn test_typed_parse_pipeline_json_absolute_path_rejected() {
5156 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
5157 let content = r#"```json
5158{
5159 "artifacts": [
5160 {"operation": "write", "path": "/etc/passwd", "content": "bad"}
5161 ],
5162 "commands": []
5163}
5164```"#;
5165 let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
5166 assert!(
5167 bundle_opt.is_none(),
5168 "Invalid bundle with absolute path should be rejected"
5169 );
5170 assert!(
5171 !state.is_ok(),
5172 "Parse state should not be Ok for path traversal: {}",
5173 state
5174 );
5175 }
5176
5177 #[test]
5178 fn test_typed_parse_pipeline_returns_no_payload_for_garbage() {
5179 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
5180 let content = "This is just a plain text response with no code blocks at all.";
5181 let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
5182 assert!(bundle_opt.is_none());
5183 assert!(
5184 matches!(
5185 state,
5186 perspt_core::types::ParseResultState::NoStructuredPayload
5187 ),
5188 "Expected NoStructuredPayload, got {}",
5189 state
5190 );
5191 }
5192
5193 #[tokio::test]
5194 async fn test_effective_working_dir_with_sandbox() {
5195 let temp_dir = std::env::temp_dir().join(format!(
5198 "perspt_eff_workdir_sandbox_{}",
5199 uuid::Uuid::new_v4()
5200 ));
5201 std::fs::create_dir_all(&temp_dir).unwrap();
5202
5203 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
5204 orch.context.session_id = "test_session".into();
5205
5206 let parent = SRBNNode::new("root".into(), "root goal".into(), ModelTier::Actuator);
5207 let child = SRBNNode::new("child".into(), "child goal".into(), ModelTier::Actuator);
5208 orch.add_node(parent);
5209 orch.add_node(child);
5210 orch.add_dependency("root", "child", "dep").unwrap();
5211
5212 let child_idx = orch.node_indices["child"];
5213 let branch_id = orch.maybe_create_provisional_branch(child_idx).unwrap();
5214
5215 let sandbox_path = temp_dir
5216 .join(".perspt")
5217 .join("sandboxes")
5218 .join("test_session")
5219 .join(&branch_id);
5220 assert!(sandbox_path.exists(), "Sandbox should have been created");
5221
5222 let eff = orch.effective_working_dir(child_idx);
5224 assert_eq!(eff, sandbox_path);
5225
5226 let _ = std::fs::remove_dir_all(&temp_dir);
5228 }
5229
5230 #[tokio::test]
5231 async fn test_sandbox_dir_for_node_returns_path_when_exists() {
5232 let temp_dir = std::env::temp_dir().join(format!(
5233 "perspt_sandbox_dir_exists_{}",
5234 uuid::Uuid::new_v4()
5235 ));
5236 std::fs::create_dir_all(&temp_dir).unwrap();
5237
5238 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
5239 orch.context.session_id = "sess".into();
5240
5241 let parent = SRBNNode::new("p".into(), "g".into(), ModelTier::Actuator);
5242 let child = SRBNNode::new("c".into(), "g".into(), ModelTier::Actuator);
5243 orch.add_node(parent);
5244 orch.add_node(child);
5245 orch.add_dependency("p", "c", "dep").unwrap();
5246
5247 let child_idx = orch.node_indices["c"];
5248 let branch_id = orch.maybe_create_provisional_branch(child_idx).unwrap();
5249
5250 let sandbox = orch.sandbox_dir_for_node(child_idx);
5251 assert!(sandbox.is_some());
5252 let sandbox_path = sandbox.unwrap();
5253 assert!(sandbox_path.ends_with(&branch_id));
5254
5255 let _ = std::fs::remove_dir_all(&temp_dir);
5256 }
5257
5258 #[tokio::test]
5259 async fn test_root_node_bypasses_sandbox() {
5260 let temp_dir =
5263 std::env::temp_dir().join(format!("perspt_root_bypass_{}", uuid::Uuid::new_v4()));
5264 std::fs::create_dir_all(&temp_dir).unwrap();
5265
5266 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
5267
5268 let root = SRBNNode::new("root".into(), "root goal".into(), ModelTier::Actuator);
5269 orch.add_node(root);
5270
5271 let root_idx = orch.node_indices["root"];
5272 let branch = orch.maybe_create_provisional_branch(root_idx);
5274 assert!(
5275 branch.is_some(),
5276 "Root node should now get a provisional branch for sandbox isolation"
5277 );
5278
5279 let wd = orch.effective_working_dir(root_idx);
5281 assert_ne!(wd, temp_dir, "Root should use sandbox, not raw workspace");
5282 assert!(wd.to_string_lossy().contains("sandboxes"));
5283
5284 let _ = std::fs::remove_dir_all(&temp_dir);
5285 }
5286
5287 #[tokio::test]
5288 async fn test_step_commit_copies_sandbox_to_workspace() {
5289 use perspt_core::types::{ArtifactBundle, ArtifactOperation, PlannedTask};
5292
5293 let temp_dir =
5294 std::env::temp_dir().join(format!("perspt_commit_copy_{}", uuid::Uuid::new_v4()));
5295 std::fs::create_dir_all(temp_dir.join("src")).unwrap();
5296
5297 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
5298 orch.context.session_id = uuid::Uuid::new_v4().to_string();
5299
5300 let plan = TaskPlan {
5301 tasks: vec![
5302 PlannedTask {
5303 id: "parent".into(),
5304 goal: "Parent".into(),
5305 output_files: vec!["src/parent.rs".into()],
5306 ..PlannedTask::new("parent", "Parent")
5307 },
5308 PlannedTask {
5309 id: "child".into(),
5310 goal: "Child".into(),
5311 output_files: vec!["src/child.rs".into()],
5312 dependencies: vec!["parent".into()],
5313 ..PlannedTask::new("child", "Child")
5314 },
5315 ],
5316 };
5317 orch.create_nodes_from_plan(&plan).unwrap();
5318
5319 let child_idx = orch.node_indices["child"];
5320 let _branch_id = orch.maybe_create_provisional_branch(child_idx).unwrap();
5321
5322 let bundle = ArtifactBundle {
5324 artifacts: vec![ArtifactOperation::Write {
5325 path: "src/child.rs".into(),
5326 content: "pub fn child_fn() {}\n".into(),
5327 }],
5328 commands: vec![],
5329 };
5330 orch.apply_bundle_transactionally(
5331 &bundle,
5332 "child",
5333 perspt_core::types::NodeClass::Implementation,
5334 )
5335 .await
5336 .unwrap();
5337
5338 let sandbox = orch.sandbox_dir_for_node(child_idx).unwrap();
5340 assert!(sandbox.join("src/child.rs").exists());
5341 assert!(!temp_dir.join("src/child.rs").exists());
5342
5343 let child_idx = orch.node_indices["child"];
5345 let _ = orch.step_commit(child_idx).await;
5346
5347 assert!(
5349 temp_dir.join("src/child.rs").exists(),
5350 "step_commit should copy sandbox files to workspace"
5351 );
5352 let content = std::fs::read_to_string(temp_dir.join("src/child.rs")).unwrap();
5353 assert_eq!(content, "pub fn child_fn() {}\n");
5354
5355 let _ = std::fs::remove_dir_all(&temp_dir);
5356 }
5357
5358 #[test]
5359 fn test_typed_parse_pipeline_json_path_traversal_rejected() {
5360 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
5361 let content = r#"```json
5362{
5363 "artifacts": [
5364 {"operation": "write", "path": "../../../etc/shadow", "content": "bad"}
5365 ],
5366 "commands": []
5367}
5368```"#;
5369 let (bundle_opt, state, _) = orch.parse_artifact_bundle_typed(content, "test", 0);
5370 assert!(
5371 bundle_opt.is_none(),
5372 "Invalid bundle with path traversal should be rejected"
5373 );
5374 assert!(
5375 !state.is_ok(),
5376 "Parse state should not be Ok for path traversal: {}",
5377 state
5378 );
5379 }
5380
5381 #[test]
5384 fn test_dependency_expectations_threaded_to_nodes() {
5385 use perspt_core::types::{DependencyExpectation, PlannedTask, TaskPlan};
5386
5387 let mut plan = TaskPlan::new();
5388 let mut t1 = PlannedTask::new("t1", "Create server module");
5389 t1.output_files = vec!["src/server.py".to_string()];
5390 t1.dependency_expectations = DependencyExpectation {
5391 required_packages: vec!["flask".to_string(), "pydantic".to_string()],
5392 setup_commands: vec![],
5393 min_toolchain_version: Some("3.11".to_string()),
5394 };
5395 plan.tasks.push(t1);
5396
5397 let mut orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
5398 orch.create_nodes_from_plan(&plan).unwrap();
5399
5400 let idx = orch.node_indices["t1"];
5402 let node = &orch.graph[idx];
5403 assert_eq!(node.dependency_expectations.required_packages.len(), 2);
5404 assert_eq!(node.dependency_expectations.required_packages[0], "flask");
5405 assert_eq!(
5406 node.dependency_expectations
5407 .min_toolchain_version
5408 .as_deref(),
5409 Some("3.11")
5410 );
5411 }
5412
5413 #[test]
5414 fn test_verifier_readiness_gate_no_plugins() {
5415 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
5416 orch.check_verifier_readiness_gate();
5418 }
5419
5420 #[test]
5421 fn test_architect_prompt_includes_dependency_expectations() {
5422 let ev = perspt_core::types::PromptEvidence {
5423 user_goal: Some("Build a web server".to_string()),
5424 project_summary: Some("empty project".to_string()),
5425 working_dir: Some("/tmp".to_string()),
5426 ..Default::default()
5427 };
5428 let prompt = crate::prompt_compiler::compile(
5429 perspt_core::types::PromptIntent::ArchitectExisting,
5430 &ev,
5431 )
5432 .text;
5433 assert!(
5434 prompt.contains("dependency_expectations"),
5435 "Architect prompt must include dependency_expectations in the JSON schema"
5436 );
5437 assert!(
5438 prompt.contains("required_packages"),
5439 "Architect prompt must mention required_packages"
5440 );
5441 assert!(
5442 prompt.contains("min_toolchain_version"),
5443 "Architect prompt must mention min_toolchain_version"
5444 );
5445 }
5446
5447 #[test]
5450 fn test_budget_gate_stops_execution_when_exhausted() {
5451 let mut orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
5452 orch.set_budget(Some(0), None, None);
5454 assert!(
5455 orch.budget.any_exhausted(),
5456 "Budget with max_steps=0 should be immediately exhausted"
5457 );
5458 }
5459
5460 #[test]
5461 fn test_budget_step_recording() {
5462 let mut budget = perspt_core::types::BudgetEnvelope::new("test-session");
5463 budget.max_steps = Some(3);
5464 assert!(!budget.any_exhausted());
5465 budget.record_step();
5466 budget.record_step();
5467 assert!(!budget.any_exhausted());
5468 budget.record_step();
5469 assert!(budget.steps_exhausted());
5470 assert!(budget.any_exhausted());
5471 }
5472
5473 #[test]
5474 fn test_set_budget_configures_envelope() {
5475 let mut orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
5476 orch.set_budget(Some(10), Some(5), Some(2.50));
5477 assert_eq!(orch.budget.max_steps, Some(10));
5478 assert_eq!(orch.budget.max_revisions, Some(5));
5479 assert_eq!(orch.budget.max_cost_usd, Some(2.50));
5480 assert!(!orch.budget.any_exhausted());
5481 }
5482
5483 #[test]
5484 fn test_node_outcome_equality() {
5485 assert_eq!(NodeOutcome::Completed, NodeOutcome::Completed);
5486 assert_eq!(NodeOutcome::Escalated, NodeOutcome::Escalated);
5487 assert_ne!(NodeOutcome::Completed, NodeOutcome::Escalated);
5488 }
5489
5490 #[test]
5491 fn test_session_outcome_from_counts() {
5492 fn derive_outcome(
5495 completed: usize,
5496 escalated: usize,
5497 total: usize,
5498 ) -> perspt_core::SessionOutcome {
5499 if escalated == 0 && completed >= total {
5500 perspt_core::SessionOutcome::Success
5501 } else if completed > 0 {
5502 perspt_core::SessionOutcome::PartialSuccess
5503 } else {
5504 perspt_core::SessionOutcome::Failed
5505 }
5506 }
5507
5508 assert_eq!(
5510 derive_outcome(3, 0, 3),
5511 perspt_core::SessionOutcome::Success,
5512 );
5513 assert_eq!(
5515 derive_outcome(2, 1, 3),
5516 perspt_core::SessionOutcome::PartialSuccess,
5517 );
5518 assert_eq!(derive_outcome(0, 3, 3), perspt_core::SessionOutcome::Failed,);
5520 assert_eq!(
5522 derive_outcome(5, 0, 20),
5523 perspt_core::SessionOutcome::PartialSuccess,
5524 );
5525 assert_eq!(
5527 derive_outcome(0, 0, 20),
5528 perspt_core::SessionOutcome::Failed,
5529 );
5530 }
5531
5532 #[test]
5533 fn test_resumed_outcome_from_counts() {
5534 fn derive_resumed_outcome(
5537 executed: usize,
5538 escalated: usize,
5539 terminal_count: usize,
5540 total: usize,
5541 ) -> perspt_core::SessionOutcome {
5542 if escalated == 0 && executed + terminal_count >= total {
5543 perspt_core::SessionOutcome::Success
5544 } else if executed > 0 {
5545 perspt_core::SessionOutcome::PartialSuccess
5546 } else {
5547 perspt_core::SessionOutcome::Failed
5548 }
5549 }
5550
5551 assert_eq!(
5553 derive_resumed_outcome(3, 0, 2, 5),
5554 perspt_core::SessionOutcome::Success,
5555 );
5556 assert_eq!(
5558 derive_resumed_outcome(2, 1, 2, 5),
5559 perspt_core::SessionOutcome::PartialSuccess,
5560 );
5561 assert_eq!(
5563 derive_resumed_outcome(1, 0, 2, 5),
5564 perspt_core::SessionOutcome::PartialSuccess,
5565 );
5566 assert_eq!(
5568 derive_resumed_outcome(0, 0, 5, 5),
5569 perspt_core::SessionOutcome::Success,
5570 );
5571 assert_eq!(
5573 derive_resumed_outcome(0, 0, 2, 5),
5574 perspt_core::SessionOutcome::Failed,
5575 );
5576 }
5577
5578 #[test]
5579 fn test_sheaf_pre_check_stub_escalates_after_retry() {
5580 let dir = tempfile::tempdir().unwrap();
5581 let stub_path = dir.path().join("stub.rs");
5582 std::fs::write(&stub_path, "fn main() {\n todo!()\n}\n").unwrap();
5583
5584 let (mut orch, idx) = orch_with_node(dir.path().to_path_buf());
5585 orch.graph[idx]
5586 .output_targets
5587 .push(std::path::PathBuf::from("stub.rs"));
5588 orch.graph[idx].owner_plugin = "rust".to_string();
5589
5590 let first = orch.sheaf_pre_check(idx);
5592 assert!(first.is_some(), "First pre-check should detect stub");
5593
5594 let second = orch.sheaf_pre_check(idx);
5597 assert!(
5598 second.is_some(),
5599 "Final guard should still detect stub after retry"
5600 );
5601 }
5602
5603 fn orch_with_node(
5605 working_dir: std::path::PathBuf,
5606 ) -> (SRBNOrchestrator, petgraph::graph::NodeIndex) {
5607 let mut orch = SRBNOrchestrator::new(working_dir, false);
5608 let node = SRBNNode::new(
5609 "test-node".to_string(),
5610 "test goal".to_string(),
5611 perspt_core::ModelTier::Actuator,
5612 );
5613 let idx = orch.add_node(node);
5614 (orch, idx)
5615 }
5616
5617 #[test]
5618 fn test_sheaf_pre_check_passes_when_no_outputs() {
5619 let (orch, idx) = orch_with_node(std::path::PathBuf::from("/tmp/test"));
5620 assert!(orch.sheaf_pre_check(idx).is_none());
5621 }
5622
5623 #[test]
5624 fn test_sheaf_pre_check_detects_missing_files() {
5625 let (mut orch, idx) = orch_with_node(std::path::PathBuf::from("/tmp/test"));
5626 orch.graph[idx]
5627 .output_targets
5628 .push(std::path::PathBuf::from("nonexistent_file_xyz.rs"));
5629 let result = orch.sheaf_pre_check(idx);
5630 assert!(result.is_some());
5631 assert!(result.unwrap().contains("missing"));
5632 }
5633
5634 #[test]
5635 fn test_sheaf_pre_check_detects_empty_files() {
5636 let dir = tempfile::tempdir().unwrap();
5637 std::fs::File::create(dir.path().join("empty.rs")).unwrap();
5638
5639 let (mut orch, idx) = orch_with_node(dir.path().to_path_buf());
5640 orch.graph[idx]
5641 .output_targets
5642 .push(std::path::PathBuf::from("empty.rs"));
5643 let result = orch.sheaf_pre_check(idx);
5644 assert!(result.is_some());
5645 assert!(result.unwrap().contains("empty"));
5646 }
5647
5648 #[test]
5649 fn test_sheaf_pre_check_passes_for_valid_files() {
5650 let dir = tempfile::tempdir().unwrap();
5651 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
5652
5653 let (mut orch, idx) = orch_with_node(dir.path().to_path_buf());
5654 orch.graph[idx]
5655 .output_targets
5656 .push(std::path::PathBuf::from("main.rs"));
5657 assert!(orch.sheaf_pre_check(idx).is_none());
5658 }
5659
5660 #[test]
5661 fn test_v_boot_energy_from_degraded_sensors() {
5662 use perspt_core::types::{
5663 EnergyComponents, SensorStatus, StageOutcome, VerificationResult,
5664 };
5665
5666 let vr = VerificationResult {
5668 syntax_ok: true,
5669 build_ok: true,
5670 tests_ok: true,
5671 lint_ok: true,
5672 diagnostics_count: 0,
5673 tests_passed: 5,
5674 tests_failed: 0,
5675 summary: String::new(),
5676 raw_output: None,
5677 degraded: true,
5678 degraded_reason: Some("test sensor fallback".into()),
5679 stage_outcomes: vec![
5680 StageOutcome {
5681 stage: "syntax_check".into(),
5682 passed: true,
5683 sensor_status: SensorStatus::Available,
5684 output: None,
5685 },
5686 StageOutcome {
5687 stage: "build".into(),
5688 passed: true,
5689 sensor_status: SensorStatus::Fallback {
5690 actual: "cargo check".into(),
5691 reason: "primary not found".into(),
5692 },
5693 output: None,
5694 },
5695 StageOutcome {
5696 stage: "test".into(),
5697 passed: true,
5698 sensor_status: SensorStatus::Unavailable {
5699 reason: "no test runner".into(),
5700 },
5701 output: None,
5702 },
5703 ],
5704 };
5705
5706 let mut energy = EnergyComponents::default();
5708 for so in &vr.stage_outcomes {
5709 match &so.sensor_status {
5710 SensorStatus::Unavailable { .. } => energy.v_boot += 3.0,
5711 SensorStatus::Fallback { .. } => energy.v_boot += 1.0,
5712 SensorStatus::Available => {}
5713 }
5714 }
5715 assert!(
5717 (energy.v_boot - 4.0).abs() < f32::EPSILON,
5718 "Expected V_boot=4.0, got {}",
5719 energy.v_boot
5720 );
5721 }
5722
5723 #[test]
5726 fn test_detect_stub_rust_todo() {
5727 let dir = tempfile::tempdir().unwrap();
5728 let path = dir.path().join("lib.rs");
5729 std::fs::write(&path, "fn main() {\n todo!()\n}\n").unwrap();
5730 let result = detect_stub_content(&path, "rust");
5731 assert!(result.is_some(), "Should detect todo!() stub");
5732 assert!(result.unwrap().contains("todo!()"));
5733 }
5734
5735 #[test]
5736 fn test_detect_stub_rust_unimplemented() {
5737 let dir = tempfile::tempdir().unwrap();
5738 let path = dir.path().join("lib.rs");
5739 std::fs::write(&path, "fn run() {\n unimplemented!()\n}\n").unwrap();
5740 let result = detect_stub_content(&path, "rust");
5741 assert!(result.is_some(), "Should detect unimplemented!() stub");
5742 }
5743
5744 #[test]
5745 fn test_detect_stub_rust_real_code_not_flagged() {
5746 let dir = tempfile::tempdir().unwrap();
5747 let path = dir.path().join("lib.rs");
5748 let real_code = r#"
5749use std::collections::HashMap;
5750
5751fn add(a: i32, b: i32) -> i32 {
5752 a + b
5753}
5754
5755fn multiply(a: i32, b: i32) -> i32 {
5756 a * b
5757}
5758
5759fn compute(data: &[i32]) -> i32 {
5760 data.iter().sum()
5761}
5762
5763fn transform(input: &str) -> String {
5764 input.to_uppercase()
5765}
5766
5767fn process() {
5768 let x = add(1, 2);
5769 let y = multiply(x, 3);
5770 println!("{}", y);
5771 // todo!() in a comment should not trigger
5772}
5773"#;
5774 std::fs::write(&path, real_code).unwrap();
5775 let result = detect_stub_content(&path, "rust");
5776 assert!(
5777 result.is_none(),
5778 "Real code with comment-only todo should not be flagged"
5779 );
5780 }
5781
5782 #[test]
5783 fn test_detect_stub_rust_real_code_with_one_todo_branch() {
5784 let dir = tempfile::tempdir().unwrap();
5785 let path = dir.path().join("lib.rs");
5786 let code = r#"
5787fn add(a: i32, b: i32) -> i32 { a + b }
5788fn sub(a: i32, b: i32) -> i32 { a - b }
5789fn mul(a: i32, b: i32) -> i32 { a * b }
5790fn div(a: i32, b: i32) -> i32 { a / b }
5791fn modulo(a: i32, b: i32) -> i32 { todo!() }
5792"#;
5793 std::fs::write(&path, code).unwrap();
5794 let result = detect_stub_content(&path, "rust");
5795 assert!(
5796 result.is_none(),
5797 "File with 5+ real lines and one todo!() should NOT be flagged"
5798 );
5799 }
5800
5801 #[test]
5802 fn test_detect_stub_python_pass_body() {
5803 let dir = tempfile::tempdir().unwrap();
5804 let path = dir.path().join("main.py");
5805 std::fs::write(&path, "def run():\n pass\n").unwrap();
5806 let result = detect_stub_content(&path, "python");
5807 assert!(result.is_some(), "Should detect pass-only Python function");
5808 }
5809
5810 #[test]
5811 fn test_detect_stub_python_not_implemented() {
5812 let dir = tempfile::tempdir().unwrap();
5813 let path = dir.path().join("main.py");
5814 std::fs::write(&path, "def run():\n raise NotImplementedError()\n").unwrap();
5815 let result = detect_stub_content(&path, "python");
5816 assert!(result.is_some(), "Should detect NotImplementedError stub");
5817 }
5818
5819 #[test]
5820 fn test_detect_stub_python_ellipsis_body() {
5821 let dir = tempfile::tempdir().unwrap();
5822 let path = dir.path().join("main.py");
5823 std::fs::write(&path, "def run():\n ...\n").unwrap();
5824 let result = detect_stub_content(&path, "python");
5825 assert!(
5826 result.is_some(),
5827 "Should detect ellipsis-only Python function"
5828 );
5829 }
5830
5831 #[test]
5832 fn test_detect_stub_python_real_code_not_flagged() {
5833 let dir = tempfile::tempdir().unwrap();
5834 let path = dir.path().join("main.py");
5835 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";
5836 std::fs::write(&path, code).unwrap();
5837 let result = detect_stub_content(&path, "python");
5838 assert!(result.is_none(), "Real Python code should not be flagged");
5839 }
5840
5841 #[test]
5842 fn test_detect_stub_js_throw_not_implemented() {
5843 let dir = tempfile::tempdir().unwrap();
5844 let path = dir.path().join("index.js");
5845 std::fs::write(
5846 &path,
5847 "function run() {\n throw new Error(\"not implemented\");\n}\n",
5848 )
5849 .unwrap();
5850 let result = detect_stub_content(&path, "javascript");
5851 assert!(
5852 result.is_some(),
5853 "Should detect JS throw not-implemented stub"
5854 );
5855 }
5856
5857 #[test]
5858 fn test_detect_stub_universal_comment() {
5859 let dir = tempfile::tempdir().unwrap();
5860 let path = dir.path().join("lib.rs");
5861 std::fs::write(&path, "// stub — will be replaced by agent\n").unwrap();
5862 let result = detect_stub_content(&path, "rust");
5863 assert!(result.is_some(), "Should detect universal stub comment");
5864 }
5865
5866 #[test]
5867 fn test_detect_stub_extension_fallback() {
5868 let dir = tempfile::tempdir().unwrap();
5869 let path = dir.path().join("main.py");
5870 std::fs::write(&path, "# placeholder\ndef run():\n pass\n").unwrap();
5871 let result = detect_stub_content(&path, "unknown");
5873 assert!(
5874 result.is_some(),
5875 "Should detect stub via extension fallback"
5876 );
5877 }
5878
5879 #[test]
5880 fn test_detect_stub_empty_file_returns_none() {
5881 let dir = tempfile::tempdir().unwrap();
5882 let path = dir.path().join("empty.rs");
5883 std::fs::write(&path, "").unwrap();
5884 let result = detect_stub_content(&path, "rust");
5887 assert!(result.is_none(), "Empty file has no stub pattern to match");
5888 }
5889}