1use crate::agent::{ActuatorAgent, Agent, ArchitectAgent, SpeculatorAgent, VerifierAgent};
6use crate::context_retriever::ContextRetriever;
7use crate::lsp::LspClient;
8use crate::test_runner::{self, PythonTestRunner, TestResults};
9use crate::tools::{AgentTools, ToolCall};
10use crate::types::{AgentContext, EnergyComponents, ModelTier, NodeState, SRBNNode, TaskPlan};
11use anyhow::{Context, Result};
12use perspt_core::types::{
13 EscalationCategory, EscalationReport, NodeClass, ProvisionalBranch, ProvisionalBranchState,
14 RewriteAction, RewriteRecord, SheafValidationResult, SheafValidatorClass, WorkspaceState,
15};
16use petgraph::graph::{DiGraph, NodeIndex};
17use petgraph::visit::{EdgeRef, Topo, Walker};
18use std::collections::HashMap;
19use std::path::PathBuf;
20use std::time::Instant;
21
22#[derive(Debug, Clone)]
24pub struct Dependency {
25 pub kind: String,
27}
28
29#[derive(Debug, Clone)]
31pub enum ApprovalResult {
32 Approved,
34 ApprovedWithEdit(String),
36 Rejected,
38}
39
40pub struct SRBNOrchestrator {
42 pub graph: DiGraph<SRBNNode, Dependency>,
44 node_indices: HashMap<String, NodeIndex>,
46 pub context: AgentContext,
48 pub auto_approve: bool,
50 lsp_clients: HashMap<String, LspClient>,
52 agents: Vec<Box<dyn Agent>>,
54 tools: AgentTools,
56 last_written_file: Option<PathBuf>,
58 file_version: i32,
60 provider: std::sync::Arc<perspt_core::llm_provider::GenAIProvider>,
62 architect_model: String,
64 actuator_model: String,
66 verifier_model: String,
68 speculator_model: String,
70 architect_fallback_model: Option<String>,
72 actuator_fallback_model: Option<String>,
74 verifier_fallback_model: Option<String>,
76 speculator_fallback_model: Option<String>,
78 event_sender: Option<perspt_core::events::channel::EventSender>,
80 action_receiver: Option<perspt_core::events::channel::ActionReceiver>,
82 pub ledger: crate::ledger::MerkleLedger,
84 pub last_tool_failure: Option<String>,
86 last_context_provenance: Option<perspt_core::types::ContextProvenance>,
88 last_formatted_context: String,
90 last_verification_result: Option<perspt_core::types::VerificationResult>,
92 last_applied_bundle: Option<perspt_core::types::ArtifactBundle>,
94 blocked_dependencies: Vec<perspt_core::types::BlockedDependency>,
96}
97
98fn epoch_seconds() -> i64 {
100 use std::time::{SystemTime, UNIX_EPOCH};
101 SystemTime::now()
102 .duration_since(UNIX_EPOCH)
103 .unwrap()
104 .as_secs() as i64
105}
106
107impl SRBNOrchestrator {
108 pub fn new(working_dir: PathBuf, auto_approve: bool) -> Self {
110 Self::new_with_models(
111 working_dir,
112 auto_approve,
113 None,
114 None,
115 None,
116 None,
117 None,
118 None,
119 None,
120 None,
121 )
122 }
123
124 #[allow(clippy::too_many_arguments)]
126 pub fn new_with_models(
127 working_dir: PathBuf,
128 auto_approve: bool,
129 architect_model: Option<String>,
130 actuator_model: Option<String>,
131 verifier_model: Option<String>,
132 speculator_model: Option<String>,
133 architect_fallback_model: Option<String>,
134 actuator_fallback_model: Option<String>,
135 verifier_fallback_model: Option<String>,
136 speculator_fallback_model: Option<String>,
137 ) -> Self {
138 let context = AgentContext {
139 working_dir: working_dir.clone(),
140 auto_approve,
141 ..Default::default()
142 };
143
144 let provider = std::sync::Arc::new(
147 perspt_core::llm_provider::GenAIProvider::new().unwrap_or_else(|e| {
148 log::warn!("Failed to create GenAIProvider: {}, using default", e);
149 perspt_core::llm_provider::GenAIProvider::new().expect("GenAI must initialize")
150 }),
151 );
152
153 let tools = AgentTools::new(working_dir.clone(), !auto_approve);
155
156 let stored_architect_model = architect_model
158 .clone()
159 .unwrap_or_else(|| ModelTier::Architect.default_model().to_string());
160 let stored_actuator_model = actuator_model
161 .clone()
162 .unwrap_or_else(|| ModelTier::Actuator.default_model().to_string());
163 let stored_verifier_model = verifier_model
164 .clone()
165 .unwrap_or_else(|| ModelTier::Verifier.default_model().to_string());
166 let stored_speculator_model = speculator_model
167 .clone()
168 .unwrap_or_else(|| ModelTier::Speculator.default_model().to_string());
169
170 Self {
171 graph: DiGraph::new(),
172 node_indices: HashMap::new(),
173 context,
174 auto_approve,
175 lsp_clients: HashMap::new(),
176 agents: vec![
177 Box::new(ArchitectAgent::new(provider.clone(), architect_model)),
178 Box::new(ActuatorAgent::new(provider.clone(), actuator_model)),
179 Box::new(VerifierAgent::new(provider.clone(), verifier_model)),
180 Box::new(SpeculatorAgent::new(provider.clone(), speculator_model)),
181 ],
182 tools,
183 last_written_file: None,
184 file_version: 0,
185 provider,
186 architect_model: stored_architect_model,
187 actuator_model: stored_actuator_model,
188 verifier_model: stored_verifier_model,
189 speculator_model: stored_speculator_model,
190 architect_fallback_model,
191 actuator_fallback_model,
192 verifier_fallback_model,
193 speculator_fallback_model,
194 event_sender: None,
195 action_receiver: None,
196 #[cfg(test)]
197 ledger: crate::ledger::MerkleLedger::in_memory().expect("Failed to create test ledger"),
198 #[cfg(not(test))]
199 ledger: crate::ledger::MerkleLedger::new().expect("Failed to create ledger"),
200 last_tool_failure: None,
201 last_context_provenance: None,
202 last_formatted_context: String::new(),
203 last_verification_result: None,
204 last_applied_bundle: None,
205 blocked_dependencies: Vec::new(),
206 }
207 }
208
209 #[cfg(test)]
211 pub fn new_for_testing(working_dir: PathBuf) -> Self {
212 let context = AgentContext {
213 working_dir: working_dir.clone(),
214 auto_approve: true,
215 ..Default::default()
216 };
217
218 let provider = std::sync::Arc::new(
219 perspt_core::llm_provider::GenAIProvider::new().unwrap_or_else(|e| {
220 log::warn!("Failed to create GenAIProvider: {}, using default", e);
221 perspt_core::llm_provider::GenAIProvider::new().expect("GenAI must initialize")
222 }),
223 );
224
225 let tools = AgentTools::new(working_dir.clone(), false);
226
227 Self {
228 graph: DiGraph::new(),
229 node_indices: HashMap::new(),
230 context,
231 auto_approve: true,
232 lsp_clients: HashMap::new(),
233 agents: vec![
234 Box::new(ArchitectAgent::new(provider.clone(), None)),
235 Box::new(ActuatorAgent::new(provider.clone(), None)),
236 Box::new(VerifierAgent::new(provider.clone(), None)),
237 Box::new(SpeculatorAgent::new(provider.clone(), None)),
238 ],
239 tools,
240 last_written_file: None,
241 file_version: 0,
242 provider,
243 architect_model: ModelTier::Architect.default_model().to_string(),
244 actuator_model: ModelTier::Actuator.default_model().to_string(),
245 verifier_model: ModelTier::Verifier.default_model().to_string(),
246 speculator_model: ModelTier::Speculator.default_model().to_string(),
247 architect_fallback_model: None,
248 actuator_fallback_model: None,
249 verifier_fallback_model: None,
250 speculator_fallback_model: None,
251 event_sender: None,
252 action_receiver: None,
253 ledger: crate::ledger::MerkleLedger::in_memory().expect("Failed to create test ledger"),
254 last_tool_failure: None,
255 last_context_provenance: None,
256 last_formatted_context: String::new(),
257 last_verification_result: None,
258 last_applied_bundle: None,
259 blocked_dependencies: Vec::new(),
260 }
261 }
262
263 pub fn add_node(&mut self, node: SRBNNode) -> NodeIndex {
265 let node_id = node.node_id.clone();
266 let idx = self.graph.add_node(node);
267 self.node_indices.insert(node_id, idx);
268 idx
269 }
270
271 pub fn connect_tui(
273 &mut self,
274 event_sender: perspt_core::events::channel::EventSender,
275 action_receiver: perspt_core::events::channel::ActionReceiver,
276 ) {
277 self.tools.set_event_sender(event_sender.clone());
278 self.event_sender = Some(event_sender);
279 self.action_receiver = Some(action_receiver);
280 }
281
282 pub fn rehydrate_session(
297 &mut self,
298 session_id: &str,
299 ) -> Result<crate::ledger::SessionSnapshot> {
300 self.context.session_id = session_id.to_string();
302 self.ledger.current_session = Some(crate::ledger::SessionRecordLegacy {
303 session_id: session_id.to_string(),
304 task: String::new(),
305 started_at: epoch_seconds(),
306 ended_at: None,
307 status: "RESUMING".to_string(),
308 total_nodes: 0,
309 completed_nodes: 0,
310 });
311
312 let snapshot = self.ledger.load_session_snapshot()?;
313
314 if snapshot.node_details.is_empty() {
316 anyhow::bail!(
317 "Session {} has no persisted nodes — cannot resume",
318 session_id
319 );
320 }
321
322 let node_ids: std::collections::HashSet<&str> = snapshot
324 .node_details
325 .iter()
326 .map(|d| d.record.node_id.as_str())
327 .collect();
328 let orphaned_edges = snapshot
329 .graph_edges
330 .iter()
331 .filter(|e| {
332 !node_ids.contains(e.parent_node_id.as_str())
333 || !node_ids.contains(e.child_node_id.as_str())
334 })
335 .count();
336 if orphaned_edges > 0 {
337 log::warn!(
338 "Session {} has {} orphaned edge(s) referencing unknown nodes — \
339 edges will be dropped during resume",
340 session_id,
341 orphaned_edges
342 );
343 self.emit_log(format!(
344 "⚠️ Resume: dropping {} orphaned graph edge(s)",
345 orphaned_edges
346 ));
347 }
348
349 let mut node_map: HashMap<String, NodeIndex> = HashMap::new();
351
352 for detail in &snapshot.node_details {
353 let rec = &detail.record;
354
355 let state = parse_node_state(&rec.state);
356 let node_class = rec
357 .node_class
358 .as_deref()
359 .map(parse_node_class)
360 .unwrap_or_default();
361
362 let mut node = SRBNNode::new(
363 rec.node_id.clone(),
364 rec.goal.clone().unwrap_or_default(),
365 ModelTier::Actuator,
366 );
367 node.state = state;
368 node.node_class = node_class;
369 node.owner_plugin = rec.owner_plugin.clone().unwrap_or_default();
370 node.parent_id = rec.parent_id.clone();
371 node.children = rec
372 .children
373 .as_deref()
374 .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
375 .unwrap_or_default();
376 node.monitor.attempt_count = rec.attempt_count as usize;
377
378 if let Some(last_energy) = detail.energy_history.last() {
380 node.monitor.energy_history.push(last_energy.v_total);
381 }
382
383 if let Some(seal) = detail.interface_seals.last() {
385 if seal.seal_hash.len() == 32 {
386 let mut hash = [0u8; 32];
387 hash.copy_from_slice(&seal.seal_hash);
388 node.interface_seal_hash = Some(hash);
389 }
390 }
391
392 let idx = self.add_node(node);
393 node_map.insert(rec.node_id.clone(), idx);
394 }
395
396 for edge in &snapshot.graph_edges {
398 if let (Some(&from_idx), Some(&to_idx)) = (
399 node_map.get(&edge.parent_node_id),
400 node_map.get(&edge.child_node_id),
401 ) {
402 self.graph.add_edge(
403 from_idx,
404 to_idx,
405 Dependency {
406 kind: edge.edge_type.clone(),
407 },
408 );
409 }
410 }
411
412 for (child_id, &child_idx) in &node_map {
414 let parents: Vec<NodeIndex> = self
415 .graph
416 .neighbors_directed(child_idx, petgraph::Direction::Incoming)
417 .collect();
418
419 for parent_idx in parents {
420 let parent = &self.graph[parent_idx];
421 if parent.node_class == NodeClass::Interface
422 && parent.interface_seal_hash.is_none()
423 && !parent.state.is_terminal()
424 {
425 self.blocked_dependencies
426 .push(perspt_core::types::BlockedDependency {
427 child_node_id: child_id.clone(),
428 parent_node_id: parent.node_id.clone(),
429 required_seal_paths: Vec::new(),
430 blocked_at: epoch_seconds(),
431 });
432 }
433 }
434 }
435
436 let terminal = snapshot
437 .node_details
438 .iter()
439 .filter(|d| {
440 let s = parse_node_state(&d.record.state);
441 s.is_terminal()
442 })
443 .count();
444 let resumable = snapshot.node_details.len() - terminal;
445
446 log::info!(
447 "Rehydrated session {}: {} nodes ({} terminal, {} resumable), {} edges",
448 session_id,
449 snapshot.node_details.len(),
450 terminal,
451 resumable,
452 snapshot.graph_edges.len()
453 );
454
455 if let Some(ref mut sess) = self.ledger.current_session {
457 sess.total_nodes = snapshot.node_details.len();
458 sess.completed_nodes = terminal;
459 sess.status = "RUNNING".to_string();
460 }
461
462 for detail in &snapshot.node_details {
466 let state = parse_node_state(&detail.record.state);
467 if state.is_terminal() {
468 continue;
469 }
470
471 if let Some(ref prov) = detail.context_provenance {
472 let retriever = ContextRetriever::new(self.context.working_dir.clone());
473 let drift = retriever.validate_provenance_record(prov);
474 if !drift.is_empty() {
475 log::warn!(
476 "Provenance drift for node '{}': {} file(s) missing: {}",
477 detail.record.node_id,
478 drift.len(),
479 drift.join(", ")
480 );
481 self.emit_log(format!(
482 "⚠️ Provenance drift: node '{}' has {} missing file(s)",
483 detail.record.node_id,
484 drift.len()
485 ));
486 self.emit_event(perspt_core::AgentEvent::ProvenanceDrift {
487 node_id: detail.record.node_id.clone(),
488 missing_files: drift,
489 reason: "Files referenced in persisted context no longer exist".to_string(),
490 });
491 }
492 }
493 }
494
495 Ok(snapshot)
496 }
497
498 pub async fn run_resumed(&mut self) -> Result<()> {
505 let topo = Topo::new(&self.graph);
506 let indices: Vec<_> = topo.iter(&self.graph).collect();
507 let total_nodes = indices.len();
508 let mut executed = 0;
509
510 let terminal_count = indices
512 .iter()
513 .filter(|i| self.graph[**i].state.is_terminal())
514 .count();
515 let blocked_count = indices
516 .iter()
517 .filter(|i| !self.graph[**i].state.is_terminal() && self.check_seal_prerequisites(**i))
518 .count();
519 let resumable_count = total_nodes - terminal_count - blocked_count;
520 self.emit_log(format!(
521 "📊 Differential resume: {} total, {} skipped (terminal), {} blocked (seal), {} to execute",
522 total_nodes, terminal_count, blocked_count, resumable_count
523 ));
524
525 for (i, idx) in indices.iter().enumerate() {
526 let node = &self.graph[*idx];
527
528 if node.state.is_terminal() {
530 log::debug!("Skipping terminal node {} ({:?})", node.node_id, node.state);
531 continue;
532 }
533
534 if self.check_seal_prerequisites(*idx) {
536 log::warn!(
537 "Node {} blocked on seal prerequisite — skipping",
538 self.graph[*idx].node_id
539 );
540 continue;
541 }
542
543 let node = &self.graph[*idx];
544 self.emit_log(format!(
545 "📝 [resume {}/{}] {}",
546 i + 1,
547 total_nodes,
548 node.goal
549 ));
550 self.emit_event(perspt_core::AgentEvent::NodeSelected {
551 node_id: node.node_id.clone(),
552 goal: node.goal.clone(),
553 node_class: node.node_class.to_string(),
554 });
555 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
556 node_id: node.node_id.clone(),
557 status: perspt_core::NodeStatus::Running,
558 });
559
560 match self.execute_node(*idx).await {
561 Ok(()) => {
562 if let Some(node) = self.graph.node_weight(*idx) {
563 self.emit_event(perspt_core::AgentEvent::NodeCompleted {
564 node_id: node.node_id.clone(),
565 goal: node.goal.clone(),
566 });
567 }
568 executed += 1;
569 }
570 Err(e) => {
571 let node_id = self.graph[*idx].node_id.clone();
572 log::error!("Node {} failed on resume: {}", node_id, e);
573 self.emit_log(format!("❌ Node {} failed: {}", node_id, e));
574 self.graph[*idx].state = NodeState::Escalated;
575 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
576 node_id,
577 status: perspt_core::NodeStatus::Escalated,
578 });
579 continue;
580 }
581 }
582 }
583
584 log::info!(
585 "Resumed execution completed: {} of {} nodes executed",
586 executed,
587 total_nodes
588 );
589 self.emit_event(perspt_core::AgentEvent::Complete {
590 success: true,
591 message: format!("Resumed: executed {} of {} nodes", executed, total_nodes),
592 });
593 Ok(())
594 }
595
596 fn emit_event(&self, event: perspt_core::AgentEvent) {
598 if let Some(ref sender) = self.event_sender {
599 let _ = sender.send(event);
600 }
601 }
602
603 fn emit_log(&self, msg: impl Into<String>) {
605 self.emit_event(perspt_core::AgentEvent::Log(msg.into()));
606 }
607
608 async fn await_approval(
612 &mut self,
613 action_type: perspt_core::ActionType,
614 description: String,
615 diff: Option<String>,
616 ) -> ApprovalResult {
617 self.await_approval_for_node(action_type, description, diff, None)
618 .await
619 }
620
621 async fn await_approval_for_node(
623 &mut self,
624 action_type: perspt_core::ActionType,
625 description: String,
626 diff: Option<String>,
627 review_node_id: Option<&str>,
628 ) -> ApprovalResult {
629 if self.auto_approve {
631 if let Some(nid) = review_node_id {
632 self.persist_review_decision(nid, "auto_approved", None);
633 }
634 return ApprovalResult::Approved;
635 }
636
637 if self.action_receiver.is_none() {
639 if let Some(nid) = review_node_id {
640 self.persist_review_decision(nid, "auto_approved", None);
641 }
642 return ApprovalResult::Approved;
643 }
644
645 let request_id = uuid::Uuid::new_v4().to_string();
647
648 self.emit_event(perspt_core::AgentEvent::ApprovalRequest {
650 request_id: request_id.clone(),
651 node_id: review_node_id.unwrap_or("current").to_string(),
652 action_type,
653 description,
654 diff,
655 });
656
657 if let Some(ref mut receiver) = self.action_receiver {
659 while let Some(action) = receiver.recv().await {
660 match action {
661 perspt_core::AgentAction::Approve { request_id: rid } if rid == request_id => {
662 self.emit_log("✓ Approved by user");
663 if let Some(nid) = review_node_id {
664 self.persist_review_decision(nid, "approved", None);
665 }
666 return ApprovalResult::Approved;
667 }
668 perspt_core::AgentAction::ApproveWithEdit {
669 request_id: rid,
670 edited_value,
671 } if rid == request_id => {
672 self.emit_log(format!("✓ Approved with edit: {}", edited_value));
673 if let Some(nid) = review_node_id {
674 self.persist_review_decision(nid, "approved_with_edit", None);
675 }
676 return ApprovalResult::ApprovedWithEdit(edited_value);
677 }
678 perspt_core::AgentAction::Reject {
679 request_id: rid,
680 reason,
681 } if rid == request_id => {
682 let msg = reason.unwrap_or_else(|| "User rejected".to_string());
683 self.emit_log(format!("✗ Rejected: {}", msg));
684 if let Some(nid) = review_node_id {
685 self.persist_review_decision(nid, "rejected", Some(&msg));
686 }
687 return ApprovalResult::Rejected;
688 }
689 perspt_core::AgentAction::RequestCorrection {
690 request_id: rid,
691 feedback,
692 } if rid == request_id => {
693 self.emit_log(format!("🔄 Correction requested: {}", feedback));
694 if let Some(nid) = review_node_id {
695 self.persist_review_decision(
696 nid,
697 "correction_requested",
698 Some(&feedback),
699 );
700 }
701 return ApprovalResult::Rejected;
702 }
703 perspt_core::AgentAction::Abort => {
704 self.emit_log("⚠️ Session aborted by user");
705 if let Some(nid) = review_node_id {
706 self.persist_review_decision(nid, "aborted", None);
707 }
708 return ApprovalResult::Rejected;
709 }
710 _ => {
711 continue;
713 }
714 }
715 }
716 }
717
718 ApprovalResult::Rejected }
720
721 fn persist_review_decision(&self, node_id: &str, outcome: &str, note: Option<&str>) {
723 let degraded = self.last_verification_result.as_ref().map(|vr| vr.degraded);
724 if let Err(e) = self
725 .ledger
726 .record_review_outcome(node_id, outcome, note, None, degraded, None)
727 {
728 log::warn!("Failed to persist review decision for {}: {}", node_id, e);
729 }
730 }
731
732 pub fn add_dependency(&mut self, from_id: &str, to_id: &str, kind: &str) -> Result<()> {
734 let from_idx = self
735 .node_indices
736 .get(from_id)
737 .context(format!("Node not found: {}", from_id))?;
738 let to_idx = self
739 .node_indices
740 .get(to_id)
741 .context(format!("Node not found: {}", to_id))?;
742
743 self.graph.add_edge(
744 *from_idx,
745 *to_idx,
746 Dependency {
747 kind: kind.to_string(),
748 },
749 );
750 Ok(())
751 }
752
753 pub async fn run(&mut self, task: String) -> Result<()> {
755 log::info!("Starting SRBN execution for task: {}", task);
756 self.emit_log(format!("🚀 Starting task: {}", task));
757
758 let session_id = uuid::Uuid::new_v4().to_string();
760 self.context.session_id = session_id.clone();
761 self.ledger.start_session(
762 &session_id,
763 &task,
764 &self.context.working_dir.to_string_lossy(),
765 )?;
766
767 if self.context.log_llm {
769 self.emit_log("📝 LLM request logging enabled".to_string());
770 }
771
772 let execution_mode = self.detect_execution_mode(&task);
774 self.context.execution_mode = execution_mode;
775 self.emit_log(format!("🎯 Execution mode: {}", execution_mode));
776
777 if execution_mode == perspt_core::types::ExecutionMode::Solo {
778 log::info!("Using Solo Mode for explicit single-file task");
780 self.emit_log("⚡ Solo Mode: Single-file execution".to_string());
781 return self.run_solo_mode(task).await;
782 }
783
784 let workspace_state = self.classify_workspace(&task);
786 self.context.workspace_state = workspace_state.clone();
787 self.emit_log(format!("📋 Workspace: {}", workspace_state));
788
789 if let WorkspaceState::ExistingProject { ref plugins } = workspace_state {
792 self.context.active_plugins = plugins.clone();
793 self.emit_log(format!("🔌 Detected plugins: {}", plugins.join(", ")));
794 self.emit_plugin_readiness();
795 }
796
797 self.step_init_project(&task).await?;
799
800 if !matches!(workspace_state, WorkspaceState::ExistingProject { .. }) {
803 self.redetect_plugins_after_init();
804 }
805
806 {
809 let plugin_refs: Vec<String> = self.context.active_plugins.clone();
810 let refs: Vec<&str> = plugin_refs.iter().map(|s| s.as_str()).collect();
811 if !refs.is_empty() {
812 self.emit_log("🔍 Starting language servers...".to_string());
813 if let Err(e) = self.start_lsp_for_plugins(&refs).await {
814 log::warn!("Failed to start LSP: {}", e);
815 self.emit_log("⚠️ Continuing without LSP".to_string());
816 } else {
817 self.emit_log("✅ Language servers ready".to_string());
818 }
819 }
820 }
821
822 self.step_sheafify(task).await?;
823
824 let node_count = self.graph.node_count();
826 self.emit_event(perspt_core::AgentEvent::PlanReady {
827 nodes: node_count,
828 plugins: self.context.active_plugins.clone(),
829 execution_mode: execution_mode.to_string(),
830 });
831
832 for node_id in self.node_indices.keys() {
834 if let Some(idx) = self.node_indices.get(node_id) {
835 if let Some(node) = self.graph.node_weight(*idx) {
836 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
837 node_id: node.node_id.clone(),
838 status: perspt_core::NodeStatus::Pending,
839 });
840 }
841 }
842 }
843
844 let topo = Topo::new(&self.graph);
846 let indices: Vec<_> = topo.iter(&self.graph).collect();
847 let total_nodes = indices.len();
848
849 for (i, idx) in indices.iter().enumerate() {
850 if self.check_seal_prerequisites(*idx) {
855 log::warn!(
856 "Node {} blocked on seal prerequisite — skipping in this iteration",
857 self.graph[*idx].node_id
858 );
859 continue;
860 }
861
862 if let Some(node) = self.graph.node_weight(*idx) {
864 self.emit_log(format!("📝 [{}/{}] {}", i + 1, total_nodes, node.goal));
865 self.emit_event(perspt_core::AgentEvent::NodeSelected {
866 node_id: node.node_id.clone(),
867 goal: node.goal.clone(),
868 node_class: node.node_class.to_string(),
869 });
870 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
871 node_id: node.node_id.clone(),
872 status: perspt_core::NodeStatus::Running,
873 });
874 }
875
876 match self.execute_node(*idx).await {
877 Ok(()) => {
878 if let Some(node) = self.graph.node_weight(*idx) {
880 self.emit_event(perspt_core::AgentEvent::NodeCompleted {
881 node_id: node.node_id.clone(),
882 goal: node.goal.clone(),
883 });
884 }
885 }
886 Err(e) => {
887 let node_id = self.graph[*idx].node_id.clone();
888 log::error!("Node {} failed: {}", node_id, e);
889 self.emit_log(format!("❌ Node {} failed: {}", node_id, e));
890 self.graph[*idx].state = NodeState::Escalated;
891 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
892 node_id: node_id.clone(),
893 status: perspt_core::NodeStatus::Escalated,
894 });
895 continue;
897 }
898 }
899 }
900
901 log::info!("SRBN execution completed");
902
903 if let Err(e) = crate::tools::cleanup_session_sandboxes(
905 &self.context.working_dir,
906 &self.context.session_id,
907 ) {
908 log::warn!("Failed to clean up session sandboxes: {}", e);
909 }
910
911 self.emit_event(perspt_core::AgentEvent::Complete {
912 success: true,
913 message: format!("Completed {} nodes", total_nodes),
914 });
915 Ok(())
916 }
917
918 fn check_tool_prerequisites(&self, plugin: &dyn perspt_core::plugin::LanguagePlugin) -> bool {
926 let common_tools: &[(&str, &str)] = &[
928 (
929 "grep",
930 "Install coreutils: brew install grep (macOS) or apt install grep (Linux)",
931 ),
932 (
933 "sed",
934 "Install coreutils: brew install gnu-sed (macOS) or apt install sed (Linux)",
935 ),
936 (
937 "awk",
938 "Install coreutils: brew install gawk (macOS) or apt install gawk (Linux)",
939 ),
940 ];
941
942 let mut missing_critical = Vec::new();
943 let mut missing_optional = Vec::new();
944 let mut install_instructions = Vec::new();
945
946 for (binary, hint) in common_tools {
948 if !perspt_core::plugin::host_binary_available(binary) {
949 missing_optional.push((*binary, "OS utility"));
950 install_instructions.push(format!(" {} ({}): {}", binary, "OS utility", hint));
951 }
952 }
953
954 let required = plugin.required_binaries();
956 for (binary, role, hint) in &required {
957 if !perspt_core::plugin::host_binary_available(binary) {
958 if *role == "language server" || role.contains("lint") {
960 missing_optional.push((*binary, role));
961 } else {
962 missing_critical.push((*binary, role));
963 }
964 install_instructions.push(format!(" {} ({}): {}", binary, role, hint));
965 }
966 }
967
968 if !missing_critical.is_empty() {
970 let names: Vec<String> = missing_critical
971 .iter()
972 .map(|(b, r)| format!("{} ({})", b, r))
973 .collect();
974 self.emit_log(format!("🚫 Missing critical tools: {}", names.join(", ")));
975 }
976 if !missing_optional.is_empty() {
977 let names: Vec<String> = missing_optional
978 .iter()
979 .map(|(b, r)| format!("{} ({})", b, r))
980 .collect();
981 self.emit_log(format!(
982 "⚠️ Missing optional tools (degraded mode): {}",
983 names.join(", ")
984 ));
985 }
986 if !install_instructions.is_empty() {
987 self.emit_log(format!(
988 "📋 Install instructions:\n{}",
989 install_instructions.join("\n")
990 ));
991 }
992
993 if missing_critical.is_empty() {
994 if missing_optional.is_empty() {
995 self.emit_log(format!("✅ All {} tools available", plugin.name()));
996 }
997 true
998 } else {
999 self.emit_log("❌ Cannot proceed with project initialization — install missing critical tools first.".to_string());
1000 false
1001 }
1002 }
1003
1004 async fn step_init_project(&mut self, task: &str) -> Result<()> {
1009 let registry = perspt_core::plugin::PluginRegistry::new();
1010 log::info!(
1011 "step_init_project: workspace_state={}",
1012 self.context.workspace_state
1013 );
1014
1015 match self.context.workspace_state.clone() {
1016 WorkspaceState::ExistingProject { ref plugins } => {
1017 let plugin_name = plugins.first().map(|s| s.as_str()).unwrap_or("");
1019 if let Some(plugin) = registry.get(plugin_name) {
1020 self.emit_log(format!("📂 Detected existing {} project", plugin.name()));
1021
1022 self.check_tool_prerequisites(plugin);
1024
1025 match plugin.check_tooling_action(&self.context.working_dir) {
1026 perspt_core::plugin::ProjectAction::ExecCommand {
1027 command,
1028 description,
1029 } => {
1030 self.emit_log(format!("🔧 Tooling action needed: {}", description));
1031
1032 let approval_result = self
1033 .await_approval(
1034 perspt_core::ActionType::Command {
1035 command: command.clone(),
1036 },
1037 description.clone(),
1038 None,
1039 )
1040 .await;
1041
1042 if matches!(
1043 approval_result,
1044 ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
1045 ) {
1046 let mut args = HashMap::new();
1047 args.insert("command".to_string(), command.clone());
1048 let call = ToolCall {
1049 name: "run_command".to_string(),
1050 arguments: args,
1051 };
1052 let result = self.tools.execute(&call).await;
1053 if result.success {
1054 self.emit_log(format!("✅ {}", description));
1055 } else {
1056 self.emit_log(format!("❌ Failed: {:?}", result.error));
1057 }
1058 }
1059 }
1060 perspt_core::plugin::ProjectAction::NoAction => {
1061 self.emit_log("✓ Project tooling is up to date".to_string());
1062 }
1063 }
1064 }
1065 }
1066
1067 WorkspaceState::Greenfield { ref inferred_lang } => {
1068 let lang = match inferred_lang.as_deref() {
1069 Some(l) => l,
1070 None => {
1071 match self.detect_language_from_task(task) {
1073 Some(l) => l,
1074 None => {
1075 self.emit_log(
1076 "ℹ️ No language detected, skipping project init".to_string(),
1077 );
1078 return Ok(());
1079 }
1080 }
1081 }
1082 };
1083
1084 if self.context.execution_mode == perspt_core::types::ExecutionMode::Solo {
1089 self.emit_log("ℹ️ Solo mode, skipping project scaffolding.".to_string());
1090 return Ok(());
1091 }
1092
1093 if let Some(plugin) = registry.get(lang) {
1094 log::info!(
1095 "step_init_project: Greenfield lang={}, initializing project",
1096 lang
1097 );
1098 self.emit_log(format!("🌱 Initializing new {} project", lang));
1099
1100 if !self.check_tool_prerequisites(plugin) {
1102 log::warn!(
1103 "step_init_project: tool prerequisites check failed for {}",
1104 lang
1105 );
1106 return Ok(());
1107 }
1108
1109 let is_empty = std::fs::read_dir(&self.context.working_dir)
1111 .map(|mut i| i.next().is_none())
1112 .unwrap_or(true);
1113
1114 let project_name = if is_empty {
1117 ".".to_string()
1118 } else {
1119 self.suggest_project_name(task).await
1120 };
1121
1122 let opts = perspt_core::plugin::InitOptions {
1123 name: project_name.clone(),
1124 is_empty_dir: is_empty,
1125 ..Default::default()
1126 };
1127
1128 match plugin.get_init_action(&opts) {
1129 perspt_core::plugin::ProjectAction::ExecCommand {
1130 command,
1131 description,
1132 } => {
1133 log::info!(
1134 "step_init_project: init command='{}', awaiting approval",
1135 command
1136 );
1137 let result = self
1138 .await_approval(
1139 perspt_core::ActionType::ProjectInit {
1140 command: command.clone(),
1141 suggested_name: project_name.clone(),
1142 },
1143 description.clone(),
1144 None,
1145 )
1146 .await;
1147
1148 let final_name = match &result {
1149 ApprovalResult::ApprovedWithEdit(edited) => edited.clone(),
1150 _ => project_name.clone(),
1151 };
1152 log::info!(
1153 "step_init_project: approval result={:?}, final_name={}",
1154 result,
1155 final_name
1156 );
1157
1158 if matches!(
1159 result,
1160 ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
1161 ) {
1162 let final_command = if final_name != project_name {
1163 let edited_opts = perspt_core::plugin::InitOptions {
1164 name: final_name.clone(),
1165 is_empty_dir: is_empty,
1166 ..Default::default()
1167 };
1168 match plugin.get_init_action(&edited_opts) {
1169 perspt_core::plugin::ProjectAction::ExecCommand {
1170 command,
1171 ..
1172 } => command,
1173 _ => command.clone(),
1174 }
1175 } else {
1176 command.clone()
1177 };
1178
1179 let mut args = HashMap::new();
1180 args.insert("command".to_string(), final_command.clone());
1181 let call = ToolCall {
1182 name: "run_command".to_string(),
1183 arguments: args,
1184 };
1185 let exec_result = self.tools.execute(&call).await;
1186 if exec_result.success {
1187 self.emit_log(format!(
1188 "✅ Project '{}' initialized",
1189 final_name
1190 ));
1191
1192 if final_name != "." {
1195 let new_dir = self.context.working_dir.join(&final_name);
1196 if new_dir.is_dir() {
1197 self.context.working_dir = new_dir.clone();
1198 self.tools =
1199 AgentTools::new(new_dir, !self.auto_approve);
1200 if let Some(ref sender) = self.event_sender {
1201 self.tools.set_event_sender(sender.clone());
1202 }
1203 self.emit_log(format!(
1204 "📁 Working directory: {}",
1205 self.context.working_dir.display()
1206 ));
1207 }
1208 }
1209 } else {
1210 self.emit_log(format!(
1211 "❌ Init failed: {:?}",
1212 exec_result.error
1213 ));
1214 }
1215 }
1216 }
1217 perspt_core::plugin::ProjectAction::NoAction => {
1218 self.emit_log("ℹ️ No initialization action needed".to_string());
1219 }
1220 }
1221 }
1222 }
1223
1224 WorkspaceState::Ambiguous => {
1225 if let Some(lang) = self.detect_language_from_task(task) {
1228 if self.context.execution_mode == perspt_core::types::ExecutionMode::Solo {
1230 self.emit_log("ℹ️ Solo mode, skipping project scaffolding.".to_string());
1231 return Ok(());
1232 }
1233
1234 if let Some(plugin) = registry.get(lang) {
1235 let project_name = self.suggest_project_name(task).await;
1236 self.emit_log(format!(
1237 "🌱 Ambiguous workspace — creating isolated {} project '{}'",
1238 lang, project_name
1239 ));
1240
1241 if !self.check_tool_prerequisites(plugin) {
1243 return Ok(());
1244 }
1245
1246 let opts = perspt_core::plugin::InitOptions {
1247 name: project_name.clone(),
1248 is_empty_dir: false,
1249 ..Default::default()
1250 };
1251
1252 match plugin.get_init_action(&opts) {
1253 perspt_core::plugin::ProjectAction::ExecCommand {
1254 command,
1255 description,
1256 } => {
1257 let result = self
1258 .await_approval(
1259 perspt_core::ActionType::ProjectInit {
1260 command: command.clone(),
1261 suggested_name: project_name.clone(),
1262 },
1263 description.clone(),
1264 None,
1265 )
1266 .await;
1267
1268 let final_name = match &result {
1269 ApprovalResult::ApprovedWithEdit(edited) => edited.clone(),
1270 _ => project_name.clone(),
1271 };
1272
1273 if matches!(
1274 result,
1275 ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
1276 ) {
1277 let final_command = if final_name != project_name {
1278 let edited_opts = perspt_core::plugin::InitOptions {
1279 name: final_name.clone(),
1280 is_empty_dir: false,
1281 ..Default::default()
1282 };
1283 match plugin.get_init_action(&edited_opts) {
1284 perspt_core::plugin::ProjectAction::ExecCommand {
1285 command,
1286 ..
1287 } => command,
1288 _ => command.clone(),
1289 }
1290 } else {
1291 command.clone()
1292 };
1293
1294 let mut args = HashMap::new();
1295 args.insert("command".to_string(), final_command.clone());
1296 let call = ToolCall {
1297 name: "run_command".to_string(),
1298 arguments: args,
1299 };
1300 let exec_result = self.tools.execute(&call).await;
1301 if exec_result.success {
1302 self.emit_log(format!(
1303 "✅ Project '{}' initialized",
1304 final_name
1305 ));
1306
1307 let new_dir = self.context.working_dir.join(&final_name);
1308 if new_dir.is_dir() {
1309 self.context.working_dir = new_dir.clone();
1310 self.tools =
1311 AgentTools::new(new_dir, !self.auto_approve);
1312 if let Some(ref sender) = self.event_sender {
1313 self.tools.set_event_sender(sender.clone());
1314 }
1315 self.emit_log(format!(
1316 "📁 Working directory: {}",
1317 self.context.working_dir.display()
1318 ));
1319 }
1320 } else {
1321 self.emit_log(format!(
1322 "❌ Init failed: {:?}",
1323 exec_result.error
1324 ));
1325 }
1326 }
1327 }
1328 perspt_core::plugin::ProjectAction::NoAction => {
1329 self.emit_log("ℹ️ No initialization action needed".to_string());
1330 }
1331 }
1332 }
1333 } else {
1334 self.emit_log(
1335 "ℹ️ Ambiguous workspace and no language detected, skipping project init"
1336 .to_string(),
1337 );
1338 }
1339 }
1340 }
1341
1342 Ok(())
1343 }
1344
1345 fn detect_execution_mode(&self, task: &str) -> perspt_core::types::ExecutionMode {
1352 if self.context.execution_mode != perspt_core::types::ExecutionMode::Project {
1354 return self.context.execution_mode;
1355 }
1356
1357 let task_lower = task.to_lowercase();
1358
1359 let solo_keywords = [
1361 "single file",
1362 "single-file",
1363 "snippet",
1364 "standalone script",
1365 "standalone file",
1366 "one file only",
1367 "just a file",
1368 ];
1369
1370 if solo_keywords.iter().any(|&k| task_lower.contains(k)) {
1371 log::debug!("Task contains explicit solo keyword, using Solo Mode");
1372 return perspt_core::types::ExecutionMode::Solo;
1373 }
1374
1375 log::debug!("Defaulting to Project Mode (PSP-5)");
1377 perspt_core::types::ExecutionMode::Project
1378 }
1379
1380 fn classify_workspace(&self, task: &str) -> WorkspaceState {
1385 let registry = perspt_core::plugin::PluginRegistry::new();
1386 let detected: Vec<String> = registry
1387 .detect_all(&self.context.working_dir)
1388 .iter()
1389 .map(|p| p.name().to_string())
1390 .collect();
1391
1392 if !detected.is_empty() {
1393 return WorkspaceState::ExistingProject { plugins: detected };
1394 }
1395
1396 let inferred = self.detect_language_from_task(task).map(|s| s.to_string());
1398
1399 if inferred.is_some() {
1400 return WorkspaceState::Greenfield {
1401 inferred_lang: inferred,
1402 };
1403 }
1404
1405 let is_empty = std::fs::read_dir(&self.context.working_dir)
1407 .map(|mut entries| entries.next().is_none())
1408 .unwrap_or(true);
1409
1410 if is_empty {
1411 return WorkspaceState::Greenfield {
1412 inferred_lang: None,
1413 };
1414 }
1415
1416 WorkspaceState::Ambiguous
1418 }
1419
1420 fn emit_plugin_readiness(&self) {
1425 let registry = perspt_core::plugin::PluginRegistry::new();
1426 let mut plugin_readiness = Vec::new();
1427
1428 for plugin_name in &self.context.active_plugins {
1429 if let Some(plugin) = registry.get(plugin_name) {
1430 let profile = plugin.verifier_profile();
1431 let available: Vec<String> = profile
1432 .capabilities
1433 .iter()
1434 .filter(|c| c.available)
1435 .map(|c| c.stage.to_string())
1436 .collect();
1437 let degraded: Vec<String> = profile
1438 .capabilities
1439 .iter()
1440 .filter(|c| !c.available && c.fallback_available)
1441 .map(|c| format!("{} (fallback)", c.stage))
1442 .chain(
1443 profile
1444 .capabilities
1445 .iter()
1446 .filter(|c| !c.any_available())
1447 .map(|c| format!("{} (unavailable)", c.stage)),
1448 )
1449 .collect();
1450 let lsp_status = if profile.lsp.primary_available {
1451 format!("{} (primary)", profile.lsp.primary.server_binary)
1452 } else if profile.lsp.fallback_available {
1453 profile
1454 .lsp
1455 .fallback
1456 .as_ref()
1457 .map(|f| format!("{} (fallback)", f.server_binary))
1458 .unwrap_or_else(|| "fallback".to_string())
1459 } else {
1460 "unavailable".to_string()
1461 };
1462
1463 if !degraded.is_empty() {
1464 self.emit_log(format!(
1465 "⚠️ Plugin '{}' degraded: {}",
1466 plugin_name,
1467 degraded.join(", ")
1468 ));
1469 }
1470
1471 plugin_readiness.push(perspt_core::events::PluginReadiness {
1472 plugin_name: plugin_name.clone(),
1473 available_stages: available,
1474 degraded_stages: degraded,
1475 lsp_status,
1476 });
1477 }
1478 }
1479
1480 self.emit_event_ref(perspt_core::AgentEvent::ToolReadiness {
1481 plugins: plugin_readiness,
1482 strictness: format!("{:?}", self.context.verifier_strictness),
1483 });
1484 }
1485
1486 fn redetect_plugins_after_init(&mut self) {
1491 let registry = perspt_core::plugin::PluginRegistry::new();
1492 let detected: Vec<String> = registry
1493 .detect_all(&self.context.working_dir)
1494 .iter()
1495 .map(|p| p.name().to_string())
1496 .collect();
1497
1498 if !detected.is_empty() {
1499 self.emit_log(format!("🔌 Post-init plugins: {}", detected.join(", ")));
1500 self.context.active_plugins = detected.clone();
1501 self.context.workspace_state = WorkspaceState::ExistingProject { plugins: detected };
1502 } else {
1503 self.emit_log("⚠️ No plugins detected after project init".to_string());
1504 }
1505
1506 self.emit_plugin_readiness();
1507 }
1508
1509 async fn run_solo_mode(&mut self, task: String) -> Result<()> {
1514 const MAX_ATTEMPTS: usize = 3;
1515 const EPSILON: f32 = 0.1;
1516
1517 let mut current_prompt = self.build_solo_prompt(&task);
1519 let mut attempt = 0;
1520
1521 let mut last_filename: String;
1523 let mut last_code: String;
1524
1525 loop {
1526 attempt += 1;
1527
1528 if attempt > MAX_ATTEMPTS {
1529 self.emit_log(format!(
1530 "Solo Mode failed after {} attempts, consider Team Mode",
1531 MAX_ATTEMPTS
1532 ));
1533 self.emit_event(perspt_core::AgentEvent::Complete {
1534 success: false,
1535 message: "Solo Mode exhausted retries".to_string(),
1536 });
1537 return Ok(());
1538 }
1539
1540 self.emit_log(format!("Solo Mode attempt {}/{}", attempt, MAX_ATTEMPTS));
1541
1542 let response = self
1544 .call_llm_with_logging(&self.actuator_model.clone(), ¤t_prompt, Some("solo"))
1545 .await?;
1546
1547 let (filename, code) = match self.extract_code_from_response(&response) {
1549 Some((f, c, _)) => (f, c),
1550 None => {
1551 self.emit_log("No code block found in LLM response".to_string());
1552 continue;
1553 }
1554 };
1555
1556 last_filename = filename.clone();
1557 last_code = code.clone();
1558
1559 let full_path = self.context.working_dir.join(&filename);
1561
1562 let mut args = HashMap::new();
1563 args.insert("path".to_string(), filename.clone());
1564 args.insert("content".to_string(), code.clone());
1565
1566 let call = ToolCall {
1567 name: "write_file".to_string(),
1568 arguments: args,
1569 };
1570
1571 let result = self.tools.execute(&call).await;
1572 if !result.success {
1573 self.emit_log(format!("Failed to write {}: {:?}", filename, result.error));
1574 continue;
1575 }
1576
1577 self.emit_log(format!("Created: {}", filename));
1578 self.last_written_file = Some(full_path.clone());
1579
1580 let energy = self.solo_verify(&full_path).await;
1582 let v_total = energy.total_simple();
1583
1584 self.emit_log(format!(
1585 "V(x) = {:.2} (V_syn={:.2}, V_log={:.2}, V_boot={:.2})",
1586 v_total, energy.v_syn, energy.v_log, energy.v_boot
1587 ));
1588
1589 if v_total < EPSILON {
1591 self.emit_log(format!(
1592 "Solo Mode complete! V(x)={:.2} < epsilon={:.2}",
1593 v_total, EPSILON
1594 ));
1595 self.emit_event(perspt_core::AgentEvent::Complete {
1596 success: true,
1597 message: format!("Created {}", filename),
1598 });
1599 return Ok(());
1600 }
1601
1602 self.emit_log(format!(
1604 "Unstable (V={:.2} > epsilon={:.2}), building correction prompt...",
1605 v_total, EPSILON
1606 ));
1607
1608 current_prompt =
1609 self.build_solo_correction_prompt(&task, &last_filename, &last_code, &energy);
1610 }
1611 }
1612
1613 async fn solo_verify(&mut self, path: &std::path::Path) -> EnergyComponents {
1615 let mut energy = EnergyComponents::default();
1616
1617 let lsp_key = self.lsp_key_for_file(&path.to_string_lossy());
1619 if let Some(client) = lsp_key.as_deref().and_then(|k| self.lsp_clients.get(k)) {
1620 tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
1621 let path_str = path.to_string_lossy().to_string();
1622
1623 let diagnostics = client.get_diagnostics(&path_str).await;
1624 energy.v_syn = LspClient::calculate_syntactic_energy(&diagnostics);
1625
1626 if !diagnostics.is_empty() {
1627 self.emit_log(format!(
1628 "LSP: {} diagnostics (V_syn={:.2})",
1629 diagnostics.len(),
1630 energy.v_syn
1631 ));
1632 self.context.last_diagnostics = diagnostics;
1633 }
1634 }
1635
1636 energy.v_log = self.run_doctest(path).await;
1638
1639 energy.v_boot = self.run_script_check(path).await;
1641
1642 energy
1643 }
1644
1645 async fn run_script_check(&mut self, path: &std::path::Path) -> f32 {
1647 let output = tokio::process::Command::new("python")
1648 .arg(path)
1649 .current_dir(&self.context.working_dir)
1650 .output()
1651 .await;
1652
1653 match output {
1654 Ok(out) if out.status.success() => {
1655 self.emit_log("Script execution: OK".to_string());
1656 0.0
1657 }
1658 Ok(out) => {
1659 let stderr = String::from_utf8_lossy(&out.stderr);
1660 let stdout = String::from_utf8_lossy(&out.stdout);
1661 let error_output = if !stderr.is_empty() {
1662 stderr.to_string()
1663 } else {
1664 stdout.to_string()
1665 };
1666
1667 let truncated = if error_output.len() > 500 {
1669 format!("{}...(truncated)", &error_output[..500])
1670 } else {
1671 error_output.clone()
1672 };
1673
1674 self.emit_log(format!("Script execution: FAILED\n{}", truncated));
1675 self.context.last_test_output = Some(error_output);
1676 5.0 }
1678 Err(e) => {
1679 self.emit_log(format!("Script execution: ERROR ({})", e));
1680 5.0
1681 }
1682 }
1683 }
1684
1685 fn build_solo_prompt(&self, task: &str) -> String {
1687 format!(
1688 r#"You are an expert Python developer. Complete this task with a SINGLE, self-contained Python file.
1689
1690## Task
1691{task}
1692
1693## Requirements
16941. Choose a DESCRIPTIVE filename based on the task (e.g., `fibonacci.py` for a fibonacci script, `prime_checker.py` for checking primes)
16952. Write ONE Python file that accomplishes the task
16963. Include docstrings with doctest examples for all functions
16974. Make the file directly runnable with `if __name__ == "__main__":` block
16985. Use type hints for all function parameters and return values
1699
1700## Output Format
1701File: <your_descriptive_filename.py>
1702```python
1703# your complete code here
1704```
1705
1706IMPORTANT: Do NOT use generic names like `script.py` or `main.py`. Choose a name that reflects the task."#,
1707 task = task
1708 )
1709 }
1710
1711 fn build_solo_correction_prompt(
1713 &self,
1714 task: &str,
1715 filename: &str,
1716 current_code: &str,
1717 energy: &EnergyComponents,
1718 ) -> String {
1719 let mut errors = Vec::new();
1720
1721 for diag in &self.context.last_diagnostics {
1723 let severity = match diag.severity {
1724 Some(lsp_types::DiagnosticSeverity::ERROR) => "ERROR",
1725 Some(lsp_types::DiagnosticSeverity::WARNING) => "WARNING",
1726 Some(lsp_types::DiagnosticSeverity::INFORMATION) => "INFO",
1727 Some(lsp_types::DiagnosticSeverity::HINT) => "HINT",
1728 _ => "DIAGNOSTIC",
1729 };
1730 errors.push(format!(
1731 "- Line {}: {} [{}]",
1732 diag.range.start.line + 1,
1733 diag.message,
1734 severity
1735 ));
1736 }
1737
1738 if let Some(ref output) = self.context.last_test_output {
1740 if !output.is_empty() {
1741 let truncated = if output.len() > 1000 {
1743 format!("{}...(truncated)", &output[..1000])
1744 } else {
1745 output.clone()
1746 };
1747 errors.push(format!("- Runtime/Test Error:\n{}", truncated));
1748 }
1749 }
1750
1751 let error_list = if errors.is_empty() {
1752 "No specific errors captured, but energy is still too high.".to_string()
1753 } else {
1754 errors.join("\n")
1755 };
1756
1757 format!(
1758 r#"## Code Correction Required
1759
1760The code you generated has errors. Fix ALL of them.
1761
1762### Original Task
1763{task}
1764
1765### Current Code ({filename})
1766```python
1767{current_code}
1768```
1769
1770### Errors Found
1771Energy: V_syn={v_syn:.2}, V_log={v_log:.2}, V_boot={v_boot:.2}
1772
1773{error_list}
1774
1775### Instructions
17761. Fix ALL errors listed above
17772. Maintain the original functionality
17783. Ensure the script runs without errors
17794. Ensure all doctests pass
17805. Return the COMPLETE corrected file
1781
1782### Output Format
1783File: {filename}
1784```python
1785[complete corrected code]
1786```"#,
1787 task = task,
1788 filename = filename,
1789 current_code = current_code,
1790 v_syn = energy.v_syn,
1791 v_log = energy.v_log,
1792 v_boot = energy.v_boot,
1793 error_list = error_list
1794 )
1795 }
1796
1797 async fn run_doctest(&mut self, file_path: &std::path::Path) -> f32 {
1799 let output = tokio::process::Command::new("python")
1800 .args(["-m", "doctest", "-v"])
1801 .arg(file_path)
1802 .current_dir(&self.context.working_dir)
1803 .output()
1804 .await;
1805
1806 match output {
1807 Ok(out) => {
1808 let stdout = String::from_utf8_lossy(&out.stdout);
1809 let stderr = String::from_utf8_lossy(&out.stderr);
1810
1811 let failed = stderr.matches("FAILED").count() + stdout.matches("FAILED").count();
1813 let passed = stdout.matches("ok").count();
1814
1815 if failed > 0 {
1816 self.emit_log(format!("Doctest: {} passed, {} failed", passed, failed));
1817 let doctest_output = format!("{}\n{}", stdout, stderr);
1819 self.context.last_test_output = Some(doctest_output);
1820 2.0 * (failed as f32)
1822 } else if passed > 0 {
1823 self.emit_log(format!("Doctest: {} passed", passed));
1824 0.0
1825 } else {
1826 log::debug!("No doctests found in file");
1828 0.0
1829 }
1830 }
1831 Err(e) => {
1832 log::warn!("Failed to run doctest: {}", e);
1833 0.0 }
1835 }
1836 }
1837
1838 fn detect_language_from_task(&self, task: &str) -> Option<&'static str> {
1840 let task_lower = task.to_lowercase();
1841
1842 if task_lower.contains("rust") || task_lower.contains("cargo") {
1843 Some("rust")
1844 } else if task_lower.contains("python")
1845 || task_lower.contains("flask")
1846 || task_lower.contains("django")
1847 || task_lower.contains("fastapi")
1848 || task_lower.contains("pytorch")
1849 || task_lower.contains("tensorflow")
1850 || task_lower.contains("pandas")
1851 || task_lower.contains("numpy")
1852 || task_lower.contains("scikit")
1853 || task_lower.contains("sklearn")
1854 || task_lower.contains("ml ")
1855 || task_lower.contains("machine learning")
1856 || task_lower.contains("deep learning")
1857 || task_lower.contains("neural")
1858 || task_lower.contains("dcf")
1859 || task_lower.contains("data science")
1860 || task_lower.contains("jupyter")
1861 || task_lower.contains("notebook")
1862 || task_lower.contains("streamlit")
1863 || task_lower.contains("gradio")
1864 || task_lower.contains("huggingface")
1865 || task_lower.contains("transformers")
1866 || task_lower.contains("llm")
1867 || task_lower.contains("langchain")
1868 || task_lower.contains("pydantic")
1869 {
1870 Some("python")
1871 } else if task_lower.contains("javascript")
1872 || task_lower.contains("typescript")
1873 || task_lower.contains("node")
1874 || task_lower.contains("react")
1875 || task_lower.contains("vue")
1876 || task_lower.contains("angular")
1877 || task_lower.contains("next.js")
1878 || task_lower.contains("nextjs")
1879 {
1880 Some("javascript")
1881 } else if task_lower.contains("app") || task_lower.contains("application") {
1882 Some("python")
1884 } else {
1885 None
1886 }
1887 }
1888
1889 async fn suggest_project_name(&self, task: &str) -> String {
1891 if let Some(name) = self.extract_name_heuristic(task) {
1893 self.emit_log(format!("📁 Suggested project folder: {}", name));
1894 return name;
1895 }
1896
1897 let prompt = format!(
1899 r#"Extract a short project name from this task description.
1900Rules:
1901- Use snake_case (lowercase with underscores)
1902- Maximum 30 characters
1903- Must be a valid folder name (letters, numbers, underscores only)
1904- Return ONLY the name, nothing else
1905
1906Task: "{}"
1907
1908Project name:"#,
1909 task
1910 );
1911
1912 match self
1913 .call_llm_with_logging(&self.actuator_model.clone(), &prompt, None)
1914 .await
1915 {
1916 Ok(response) => {
1917 let suggested = response.trim().to_lowercase();
1918 if let Some(validated) = self.validate_project_name(&suggested) {
1919 self.emit_log(format!("📁 Suggested project folder: {}", validated));
1920 return validated;
1921 }
1922 }
1923 Err(e) => {
1924 log::warn!("Failed to get project name from LLM: {}", e);
1925 }
1926 }
1927
1928 let fallback = "perspt_app".to_string();
1930 self.emit_log(format!("📁 Using default folder: {}", fallback));
1931 fallback
1932 }
1933
1934 fn extract_name_heuristic(&self, task: &str) -> Option<String> {
1936 let task_lower = task.to_lowercase();
1937
1938 let stop_words = [
1940 "create",
1942 "build",
1943 "make",
1944 "implement",
1945 "develop",
1946 "write",
1947 "design",
1948 "add",
1949 "setup",
1950 "set",
1951 "up",
1952 "generate",
1953 "please",
1954 "can",
1955 "you",
1956 "a",
1958 "an",
1959 "the",
1960 "this",
1961 "that",
1962 "in",
1964 "on",
1965 "for",
1966 "with",
1967 "using",
1968 "to",
1969 "from",
1970 "python",
1972 "rust",
1973 "javascript",
1974 "typescript",
1975 "node",
1976 "nodejs",
1977 "react",
1978 "vue",
1979 "angular",
1980 "django",
1981 "flask",
1982 "fastapi",
1983 "simple",
1985 "basic",
1986 "new",
1987 "my",
1988 "our",
1989 "your",
1990 ];
1991
1992 let words: Vec<&str> = task_lower
1994 .split(|c: char| !c.is_alphanumeric())
1995 .filter(|w| !w.is_empty())
1996 .filter(|w| !stop_words.contains(w))
1997 .filter(|w| w.len() > 1) .take(3) .collect();
2000
2001 if words.is_empty() {
2002 return None;
2003 }
2004
2005 let name = words.join("_");
2007
2008 self.validate_project_name(&name)
2010 }
2011
2012 fn validate_project_name(&self, name: &str) -> Option<String> {
2014 let cleaned: String = name
2016 .chars()
2017 .filter(|c| c.is_alphanumeric() || *c == '_')
2018 .take(30)
2019 .collect();
2020
2021 if cleaned.is_empty() {
2022 return None;
2023 }
2024
2025 let first = cleaned.chars().next()?;
2027 if !first.is_alphabetic() {
2028 return None;
2029 }
2030
2031 Some(cleaned)
2032 }
2033
2034 async fn step_sheafify(&mut self, task: String) -> Result<()> {
2038 log::info!("Step 1: Sheafification - Planning task decomposition");
2039 self.emit_log("🏗️ Architect is analyzing the task...".to_string());
2040
2041 const MAX_ATTEMPTS: usize = 3;
2042 let mut last_error: Option<String> = None;
2043
2044 for attempt in 1..=MAX_ATTEMPTS {
2045 log::info!(
2046 "Sheafification attempt {}/{}: requesting structured plan",
2047 attempt,
2048 MAX_ATTEMPTS
2049 );
2050
2051 let prompt = self.build_architect_prompt(&task, last_error.as_deref())?;
2053
2054 let response = self
2056 .call_llm_with_tier_fallback(
2057 &self.get_architect_model(),
2058 &prompt,
2059 None,
2060 ModelTier::Architect,
2061 |resp| {
2062 if resp.contains("tasks") && (resp.contains('{') && resp.contains('}')) {
2064 Ok(())
2065 } else {
2066 Err("Response does not contain a JSON task plan".to_string())
2067 }
2068 },
2069 )
2070 .await
2071 .context("Failed to get Architect response")?;
2072
2073 log::debug!("Architect response length: {} chars", response.len());
2074
2075 match self.parse_task_plan(&response) {
2077 Ok(plan) => {
2078 if let Err(e) = plan.validate() {
2080 log::warn!("Plan validation failed (attempt {}): {}", attempt, e);
2081 last_error = Some(format!("Validation error: {}", e));
2082
2083 if attempt >= MAX_ATTEMPTS {
2084 self.emit_log(format!(
2085 "❌ Failed to get valid plan after {} attempts",
2086 MAX_ATTEMPTS
2087 ));
2088 return self.create_deterministic_fallback_graph(&task);
2090 }
2091 continue;
2092 }
2093
2094 if plan.len() > self.context.complexity_k && !self.auto_approve {
2096 self.emit_log(format!(
2097 "⚠️ Plan has {} tasks (exceeds K={})",
2098 plan.len(),
2099 self.context.complexity_k
2100 ));
2101 }
2104
2105 self.emit_log(format!(
2106 "✅ Architect produced plan with {} task(s)",
2107 plan.len()
2108 ));
2109
2110 self.emit_event(perspt_core::AgentEvent::PlanGenerated(plan.clone()));
2112
2113 self.create_nodes_from_plan(&plan)?;
2115 return Ok(());
2116 }
2117 Err(e) => {
2118 log::warn!("Plan parsing failed (attempt {}): {}", attempt, e);
2119 last_error = Some(format!("JSON parse error: {}", e));
2120
2121 if attempt >= MAX_ATTEMPTS {
2122 self.emit_log(
2123 "⚠️ Could not parse structured plan, using single task".to_string(),
2124 );
2125 return self.create_deterministic_fallback_graph(&task);
2126 }
2127 }
2128 }
2129 }
2130
2131 self.create_deterministic_fallback_graph(&task)
2133 }
2134
2135 fn build_architect_prompt(&self, task: &str, last_error: Option<&str>) -> Result<String> {
2140 let mut project_context = self.gather_project_context();
2141
2142 if matches!(
2145 self.context.workspace_state,
2146 WorkspaceState::ExistingProject { .. }
2147 ) {
2148 let retriever = ContextRetriever::new(self.context.working_dir.clone());
2149 let summary = retriever.get_project_summary();
2150 if !summary.is_empty() {
2151 project_context = format!("{}\n\n{}", summary, project_context);
2152 }
2153 }
2154
2155 Ok(
2156 crate::agent::ArchitectAgent::build_task_decomposition_prompt(
2157 task,
2158 &self.context.working_dir,
2159 &project_context,
2160 last_error,
2161 ),
2162 )
2163 }
2164
2165 fn gather_project_context(&self) -> String {
2168 let mut context_parts = Vec::new();
2169 let working_dir = &self.context.working_dir;
2170 let retriever = ContextRetriever::new(working_dir.clone())
2171 .with_max_file_bytes(8 * 1024) .with_max_context_bytes(32 * 1024); let config_files = [
2176 "pyproject.toml",
2177 "Cargo.toml",
2178 "package.json",
2179 "requirements.txt",
2180 ];
2181
2182 let mut found_configs = Vec::new();
2184 for file in &config_files {
2185 let path = working_dir.join(file);
2186 if path.exists() {
2187 if let Ok(content) = retriever.read_file_truncated(&path) {
2188 context_parts.push(format!("### {}\n```\n{}\n```", file, content));
2189 found_configs.push(*file);
2190 }
2191 }
2192 }
2193
2194 if let Ok(entries) = std::fs::read_dir(working_dir) {
2196 let mut dirs = Vec::new();
2197 let mut files = Vec::new();
2198 for entry in entries.flatten() {
2199 let name = entry.file_name().to_string_lossy().to_string();
2200 if name.starts_with('.') {
2201 continue; }
2203 if entry.path().is_dir() {
2204 dirs.push(name);
2205 } else if !found_configs.contains(&name.as_str()) {
2206 files.push(name);
2207 }
2208 }
2209
2210 if !dirs.is_empty() {
2211 context_parts.push(format!("### Directories\n{}", dirs.join(", ")));
2212 }
2213 if !files.is_empty() && files.len() <= 15 {
2214 context_parts.push(format!("### Other Files\n{}", files.join(", ")));
2215 } else if !files.is_empty() {
2216 context_parts.push(format!(
2217 "### Other Files\n{} files (not listed)",
2218 files.len()
2219 ));
2220 }
2221 }
2222
2223 if context_parts.is_empty() {
2224 "Empty directory (greenfield project)".to_string()
2225 } else {
2226 context_parts.join("\n\n")
2227 }
2228 }
2229
2230 fn parse_task_plan(&self, content: &str) -> Result<TaskPlan> {
2237 match perspt_core::normalize::extract_and_deserialize::<TaskPlan>(content) {
2239 Ok((plan, method)) => {
2240 log::info!("Parsed TaskPlan via normalization ({})", method);
2241 return Ok(plan);
2242 }
2243 Err(e) => {
2244 log::warn!(
2245 "Normalization could not extract TaskPlan: {}. Attempting raw parse.",
2246 e
2247 );
2248 }
2249 }
2250
2251 let trimmed = content.trim();
2253 log::debug!(
2254 "Attempting legacy JSON parse: {}...",
2255 &trimmed[..trimmed.len().min(200)]
2256 );
2257 serde_json::from_str(trimmed).context("Failed to parse TaskPlan JSON")
2258 }
2259
2260 fn create_nodes_from_plan(&mut self, plan: &TaskPlan) -> Result<()> {
2262 plan.validate()
2264 .map_err(|e| anyhow::anyhow!("Plan validation failed: {}", e))?;
2265
2266 log::info!("Creating {} nodes from plan", plan.len());
2267
2268 let mut node_map: HashMap<String, NodeIndex> = HashMap::new();
2270
2271 for task in &plan.tasks {
2272 let node = task.to_srbn_node(ModelTier::Actuator);
2273 let idx = self.add_node(node);
2274 node_map.insert(task.id.clone(), idx);
2275 log::info!(" Created node: {} - {}", task.id, task.goal);
2276 }
2277
2278 for task in &plan.tasks {
2280 for dep_id in &task.dependencies {
2281 if let (Some(&from_idx), Some(&to_idx)) =
2282 (node_map.get(dep_id), node_map.get(&task.id))
2283 {
2284 self.graph.add_edge(
2285 from_idx,
2286 to_idx,
2287 Dependency {
2288 kind: "depends_on".to_string(),
2289 },
2290 );
2291 log::debug!(" Wired dependency: {} -> {}", dep_id, task.id);
2292
2293 if let Err(e) =
2295 self.ledger
2296 .record_task_graph_edge(dep_id, &task.id, "depends_on")
2297 {
2298 log::warn!(
2299 "Failed to persist graph edge {} -> {}: {}",
2300 dep_id,
2301 task.id,
2302 e
2303 );
2304 }
2305 }
2306 }
2307 }
2308
2309 self.build_ownership_manifest_from_plan(plan);
2311
2312 Ok(())
2313 }
2314
2315 fn build_ownership_manifest_from_plan(&mut self, plan: &TaskPlan) {
2321 let registry = perspt_core::plugin::PluginRegistry::new();
2322
2323 for task in &plan.tasks {
2324 let mut plugin_votes: HashMap<String, usize> = HashMap::new();
2326 for f in &task.output_files {
2327 let detected = registry
2328 .all()
2329 .iter()
2330 .find(|p| p.owns_file(f))
2331 .map(|p| p.name().to_string())
2332 .unwrap_or_else(|| "unknown".to_string());
2333 *plugin_votes.entry(detected).or_insert(0) += 1;
2334 }
2335
2336 let plugin_name = plugin_votes
2337 .into_iter()
2338 .max_by_key(|(_, count)| *count)
2339 .map(|(name, _)| name)
2340 .unwrap_or_else(|| "unknown".to_string());
2341
2342 if task.node_class != perspt_core::types::NodeClass::Integration {
2344 let mixed: Vec<String> = task
2345 .output_files
2346 .iter()
2347 .filter_map(|f| {
2348 let det = registry
2349 .all()
2350 .iter()
2351 .find(|p| p.owns_file(f))
2352 .map(|p| p.name().to_string())
2353 .unwrap_or_else(|| "unknown".to_string());
2354 if det != plugin_name {
2355 Some(format!("'{}' ({})", f, det))
2356 } else {
2357 None
2358 }
2359 })
2360 .collect();
2361 if !mixed.is_empty() {
2362 log::warn!(
2363 "Task '{}' has mixed-plugin outputs (primary: {}): {}",
2364 task.id,
2365 plugin_name,
2366 mixed.join(", ")
2367 );
2368 }
2369 }
2370
2371 if let Some(idx) = self.node_indices.get(&task.id) {
2373 self.graph[*idx].owner_plugin = plugin_name.clone();
2374 }
2375
2376 for file in &task.output_files {
2378 let file_plugin = registry
2380 .all()
2381 .iter()
2382 .find(|p| p.owns_file(file))
2383 .map(|p| p.name().to_string())
2384 .unwrap_or_else(|| plugin_name.clone());
2385
2386 self.context.ownership_manifest.assign(
2387 file.clone(),
2388 task.id.clone(),
2389 file_plugin,
2390 task.node_class,
2391 );
2392 }
2393 }
2394
2395 log::info!(
2396 "Built ownership manifest: {} entries",
2397 self.context.ownership_manifest.len()
2398 );
2399 }
2400
2401 fn get_architect_model(&self) -> String {
2403 self.architect_model.clone()
2404 }
2405
2406 async fn execute_node(&mut self, idx: NodeIndex) -> Result<()> {
2408 let node = &self.graph[idx];
2409 log::info!("Executing node: {} ({})", node.node_id, node.goal);
2410
2411 let branch_id = self.maybe_create_provisional_branch(idx);
2413
2414 self.graph[idx].state = NodeState::Coding;
2416 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
2417 node_id: self.graph[idx].node_id.clone(),
2418 status: perspt_core::NodeStatus::Coding,
2419 });
2420
2421 self.step_speculate(idx).await?;
2423
2424 let energy = self.step_verify(idx).await?;
2426
2427 if !self.step_converge(idx, energy).await? {
2429 let category = self.classify_non_convergence(idx);
2431 let action = self.choose_repair_action(idx, &category);
2432
2433 let node = &self.graph[idx];
2435 let report = EscalationReport {
2436 node_id: node.node_id.clone(),
2437 session_id: self.context.session_id.clone(),
2438 category,
2439 action: action.clone(),
2440 energy_snapshot: EnergyComponents {
2441 v_syn: node.monitor.current_energy(),
2442 ..Default::default()
2443 },
2444 stage_outcomes: self
2445 .last_verification_result
2446 .as_ref()
2447 .map(|vr| vr.stage_outcomes.clone())
2448 .unwrap_or_default(),
2449 evidence: self.build_escalation_evidence(idx),
2450 affected_node_ids: self.affected_dependents(idx),
2451 timestamp: epoch_seconds(),
2452 };
2453
2454 if let Err(e) = self.ledger.record_escalation_report(&report) {
2455 log::warn!("Failed to persist escalation report: {}", e);
2456 }
2457
2458 if let Some(bundle) = self.last_applied_bundle.take() {
2460 if let Err(e) = self
2461 .ledger
2462 .record_artifact_bundle(&self.graph[idx].node_id, &bundle)
2463 {
2464 log::warn!(
2465 "Failed to persist artifact bundle on escalation for {}: {}",
2466 self.graph[idx].node_id,
2467 e
2468 );
2469 }
2470 }
2471
2472 self.emit_event(perspt_core::AgentEvent::EscalationClassified {
2473 node_id: report.node_id.clone(),
2474 category: report.category.to_string(),
2475 action: report.action.to_string(),
2476 });
2477
2478 let node_id_for_flush = self.graph[idx].node_id.clone();
2480 if let Some(ref bid) = branch_id {
2481 self.flush_provisional_branch(bid, &node_id_for_flush);
2482 }
2483 self.flush_descendant_branches(idx);
2484
2485 let applied = self.apply_repair_action(idx, &action).await;
2487
2488 if !applied {
2489 self.graph[idx].state = NodeState::Escalated;
2490 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
2491 node_id: self.graph[idx].node_id.clone(),
2492 status: perspt_core::NodeStatus::Escalated,
2493 });
2494 log::warn!(
2495 "Node {} escalated to user: {} → {}",
2496 self.graph[idx].node_id,
2497 category,
2498 action
2499 );
2500 }
2501
2502 return Ok(());
2503 }
2504
2505 self.step_sheaf_validate(idx).await?;
2507
2508 self.step_commit(idx).await?;
2510
2511 if let Some(ref bid) = branch_id {
2513 self.merge_provisional_branch(bid, idx);
2514 }
2515
2516 Ok(())
2517 }
2518
2519 async fn step_speculate(&mut self, idx: NodeIndex) -> Result<()> {
2521 log::info!("Step 3: Speculation - Generating implementation");
2522
2523 let retriever = ContextRetriever::new(self.context.working_dir.clone())
2525 .with_max_file_bytes(8 * 1024)
2526 .with_max_context_bytes(100 * 1024); let node = &self.graph[idx];
2529 let mut restriction_map =
2530 retriever.build_restriction_map(node, &self.context.ownership_manifest);
2531
2532 self.inject_sealed_interfaces(idx, &mut restriction_map);
2537
2538 let node = &self.graph[idx];
2539 let context_package = retriever.assemble_context_package(node, &restriction_map);
2540 let formatted_context = retriever.format_context_package(&context_package);
2541
2542 let node = &self.graph[idx];
2545 let missing_owned: Vec<String> = restriction_map
2546 .owned_files
2547 .iter()
2548 .filter(|f| {
2549 !context_package.included_files.contains_key(*f)
2551 && !node
2552 .output_targets
2553 .iter()
2554 .any(|ot| ot.to_string_lossy() == **f)
2555 })
2556 .cloned()
2557 .collect();
2558
2559 if context_package.budget_exceeded || !missing_owned.is_empty() {
2560 let reason = if context_package.budget_exceeded && !missing_owned.is_empty() {
2561 format!(
2562 "Budget exceeded and {} owned file(s) missing",
2563 missing_owned.len()
2564 )
2565 } else if context_package.budget_exceeded {
2566 "Context budget exceeded; some files replaced with structural digests".to_string()
2567 } else {
2568 format!(
2569 "{} owned file(s) could not be read: {}",
2570 missing_owned.len(),
2571 missing_owned.join(", ")
2572 )
2573 };
2574
2575 log::warn!("Context degraded for node '{}': {}", node.node_id, reason);
2576 self.emit_log(format!("⚠️ Context degraded: {}", reason));
2577 self.emit_event(perspt_core::AgentEvent::ContextDegraded {
2578 node_id: node.node_id.clone(),
2579 budget_exceeded: context_package.budget_exceeded,
2580 missing_owned_files: missing_owned.clone(),
2581 included_file_count: context_package.included_files.len(),
2582 total_bytes: context_package.total_bytes,
2583 reason: reason.clone(),
2584 });
2585
2586 if !missing_owned.is_empty() {
2589 self.emit_event(perspt_core::AgentEvent::ContextBlocked {
2590 node_id: node.node_id.clone(),
2591 missing_owned_files: missing_owned,
2592 reason: reason.clone(),
2593 });
2594 self.graph[idx].state = NodeState::Escalated;
2595 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
2596 node_id: self.graph[idx].node_id.clone(),
2597 status: perspt_core::NodeStatus::Escalated,
2598 });
2599 return Err(anyhow::anyhow!(
2600 "Context blocked for node '{}': {}. Node escalated.",
2601 self.graph[idx].node_id,
2602 reason
2603 ));
2604 }
2605 }
2606
2607 {
2610 let node = &self.graph[idx];
2611 let prose_only_deps = self.check_structural_dependencies(node, &restriction_map);
2612 if !prose_only_deps.is_empty() {
2613 for (dep_node_id, dep_reason) in &prose_only_deps {
2614 self.emit_event(perspt_core::AgentEvent::StructuralDependencyMissing {
2615 node_id: node.node_id.clone(),
2616 dependency_node_id: dep_node_id.clone(),
2617 reason: dep_reason.clone(),
2618 });
2619 }
2620 let dep_names: Vec<&str> =
2621 prose_only_deps.iter().map(|(id, _)| id.as_str()).collect();
2622 let block_reason = format!(
2623 "Required structural dependencies lack machine-verifiable digests (only prose summaries): [{}]",
2624 dep_names.join(", ")
2625 );
2626 self.emit_log(format!("🚫 {}", block_reason));
2627 self.graph[idx].state = NodeState::Escalated;
2628 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
2629 node_id: self.graph[idx].node_id.clone(),
2630 status: perspt_core::NodeStatus::Escalated,
2631 });
2632 return Err(anyhow::anyhow!(
2633 "Structural dependency check failed for node '{}': {}",
2634 self.graph[idx].node_id,
2635 block_reason
2636 ));
2637 }
2638 }
2639
2640 self.last_context_provenance = Some(context_package.provenance());
2642 self.last_formatted_context = formatted_context.clone();
2644
2645 let speculator_hints = {
2649 let node = &self.graph[idx];
2650 let child_goals: Vec<String> = self
2651 .graph
2652 .edges(idx)
2653 .filter_map(|edge| {
2654 let child = &self.graph[edge.target()];
2655 if child.state == NodeState::TaskQueued {
2656 Some(format!("- {}: {}", child.node_id, child.goal))
2657 } else {
2658 None
2659 }
2660 })
2661 .collect();
2662
2663 if !child_goals.is_empty() {
2664 let speculator_prompt = format!(
2665 "You are a Speculator agent. Given this task and its downstream dependents, \
2666 produce a brief (3-5 bullet) list of:\n\
2667 1. Interface contracts the current task must satisfy for dependents\n\
2668 2. Common pitfalls (e.g., import paths, missing exports)\n\
2669 3. Edge cases dependents may need\n\n\
2670 Current task: {} — {}\n\
2671 Downstream tasks:\n{}\n\n\
2672 Be concise. No code.",
2673 node.node_id,
2674 node.goal,
2675 child_goals.join("\n")
2676 );
2677
2678 log::debug!(
2679 "Speculator lookahead for node {} using model {}",
2680 node.node_id,
2681 self.speculator_model
2682 );
2683 self.call_llm_with_logging(
2684 &self.speculator_model.clone(),
2685 &speculator_prompt,
2686 Some(&node.node_id),
2687 )
2688 .await
2689 .unwrap_or_else(|e| {
2690 log::warn!(
2691 "Speculator lookahead failed ({}), proceeding without hints",
2692 e
2693 );
2694 String::new()
2695 })
2696 } else {
2697 String::new()
2698 }
2699 };
2700
2701 let actuator = &self.agents[1];
2702 let node = &self.graph[idx];
2703 let node_id = node.node_id.clone();
2704
2705 let base_prompt = actuator.build_prompt(node, &self.context);
2707 let mut prompt = if formatted_context.is_empty() {
2708 base_prompt
2709 } else {
2710 format!(
2711 "{}\n\n## Node Context (PSP-5 Restriction Map)\n\n{}",
2712 base_prompt, formatted_context
2713 )
2714 };
2715
2716 if !speculator_hints.is_empty() {
2717 prompt = format!(
2718 "{}\n\n## Speculator Lookahead Hints\n\n{}",
2719 prompt, speculator_hints
2720 );
2721 }
2722 let model = actuator.model().to_string();
2723
2724 let response = self
2725 .call_llm_with_logging(&model, &prompt, Some(&node_id))
2726 .await?;
2727
2728 let message = crate::types::AgentMessage::new(crate::types::ModelTier::Actuator, response);
2729 let content = &message.content;
2730
2731 if let Some(command) = self.extract_command_from_response(content) {
2733 log::info!("Extracted command: {}", command);
2734 self.emit_log(format!("🔧 Command proposed: {}", command));
2735
2736 let node_id = self.graph[idx].node_id.clone();
2738 let approval_result = self
2739 .await_approval_for_node(
2740 perspt_core::ActionType::Command {
2741 command: command.clone(),
2742 },
2743 format!("Execute shell command: {}", command),
2744 None,
2745 Some(&node_id),
2746 )
2747 .await;
2748
2749 if !matches!(
2750 approval_result,
2751 ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
2752 ) {
2753 self.emit_log("⏭️ Command skipped (not approved)");
2754 return Ok(());
2755 }
2756
2757 let mut args = HashMap::new();
2759 args.insert("command".to_string(), command.clone());
2760
2761 let call = ToolCall {
2762 name: "run_command".to_string(),
2763 arguments: args,
2764 };
2765
2766 let result = self.tools.execute(&call).await;
2767 if result.success {
2768 log::info!("✓ Command succeeded: {}", command);
2769 self.emit_log(format!("✅ Command succeeded: {}", command));
2770 self.emit_log(result.output);
2771 } else {
2772 log::warn!("Command failed: {:?}", result.error);
2773 self.emit_log(format!("❌ Command failed: {:?}", result.error));
2774 }
2775 }
2776 else if let Some(bundle) = self.parse_artifact_bundle(content) {
2778 let affected_files: Vec<String> = bundle
2779 .affected_paths()
2780 .into_iter()
2781 .map(ToString::to_string)
2782 .collect();
2783 log::info!(
2784 "Parsed artifact bundle for node {}: {} artifacts, {} commands",
2785 node_id,
2786 bundle.artifacts.len(),
2787 bundle.commands.len()
2788 );
2789 self.emit_log(format!(
2790 "📝 Bundle proposed: {} artifact(s) across {} file(s)",
2791 bundle.artifacts.len(),
2792 affected_files.len()
2793 ));
2794
2795 let approval_result = self
2796 .await_approval_for_node(
2797 perspt_core::ActionType::BundleWrite {
2798 node_id: node_id.clone(),
2799 files: affected_files.clone(),
2800 },
2801 format!("Apply bundle touching: {}", affected_files.join(", ")),
2802 serde_json::to_string_pretty(&bundle).ok(),
2803 Some(&node_id),
2804 )
2805 .await;
2806
2807 if !matches!(
2808 approval_result,
2809 ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
2810 ) {
2811 self.emit_log("⏭️ Bundle application skipped (not approved)");
2812 return Ok(());
2813 }
2814
2815 let node_class = self.graph[idx].node_class;
2816 self.apply_bundle_transactionally(&bundle, &node_id, node_class)
2817 .await?;
2818 self.last_tool_failure = None;
2819
2820 self.last_applied_bundle = Some(bundle.clone());
2822
2823 if !bundle.commands.is_empty() {
2825 self.emit_log(format!(
2826 "🔧 Executing {} bundle command(s)...",
2827 bundle.commands.len()
2828 ));
2829 let work_dir = self.effective_working_dir(idx);
2830 let is_python = self.graph[idx].owner_plugin == "python";
2831 for raw_command in &bundle.commands {
2832 let command = if is_python {
2834 Self::normalize_command_to_uv(raw_command)
2835 } else {
2836 raw_command.clone()
2837 };
2838
2839 let cmd_approval = self
2841 .await_approval_for_node(
2842 perspt_core::ActionType::Command {
2843 command: command.clone(),
2844 },
2845 format!("Execute bundle command: {}", command),
2846 None,
2847 Some(&node_id),
2848 )
2849 .await;
2850
2851 if !matches!(
2852 cmd_approval,
2853 ApprovalResult::Approved | ApprovalResult::ApprovedWithEdit(_)
2854 ) {
2855 self.emit_log(format!(
2856 "⏭️ Bundle command skipped (not approved): {}",
2857 command
2858 ));
2859 continue;
2860 }
2861
2862 let mut args = HashMap::new();
2863 args.insert("command".to_string(), command.clone());
2864 args.insert(
2865 "working_dir".to_string(),
2866 work_dir.to_string_lossy().to_string(),
2867 );
2868
2869 let call = ToolCall {
2870 name: "run_command".to_string(),
2871 arguments: args,
2872 };
2873
2874 let result = self.tools.execute(&call).await;
2875 if result.success {
2876 log::info!("✓ Bundle command succeeded: {}", command);
2877 self.emit_log(format!("✅ {}", command));
2878 if !result.output.is_empty() {
2879 let truncated: String = result.output.chars().take(500).collect();
2881 self.emit_log(truncated);
2882 }
2883 } else {
2884 let err_msg = result.error.unwrap_or_else(|| result.output.clone());
2885 log::warn!("Bundle command failed: {} — {}", command, err_msg);
2886 self.emit_log(format!("❌ Command failed: {} — {}", command, err_msg));
2887 self.last_tool_failure =
2889 Some(format!("Bundle command '{}' failed: {}", command, err_msg));
2890 }
2891 }
2892
2893 if is_python {
2895 log::info!("Running uv sync --dev after bundle commands...");
2896 let sync_result = tokio::process::Command::new("uv")
2897 .args(["sync", "--dev"])
2898 .current_dir(&work_dir)
2899 .stdout(std::process::Stdio::piped())
2900 .stderr(std::process::Stdio::piped())
2901 .output()
2902 .await;
2903 match sync_result {
2904 Ok(output) if output.status.success() => {
2905 self.emit_log("🐍 uv sync --dev completed".to_string());
2906 }
2907 Ok(output) => {
2908 let stderr = String::from_utf8_lossy(&output.stderr);
2909 log::warn!("uv sync --dev failed: {}", stderr);
2910 }
2911 Err(e) => {
2912 log::warn!("Failed to run uv sync --dev: {}", e);
2913 }
2914 }
2915 }
2916 }
2917 } else {
2918 log::debug!(
2919 "No code block or command found in response, response length: {}",
2920 content.len()
2921 );
2922 self.emit_log("ℹ️ No file changes detected in response".to_string());
2923 }
2924
2925 self.context.history.push(message);
2926 Ok(())
2927 }
2928
2929 fn extract_command_from_response(&self, content: &str) -> Option<String> {
2932 for line in content.lines() {
2933 let trimmed = line.trim();
2934 if trimmed.starts_with("[COMMAND]") {
2935 return Some(trimmed.trim_start_matches("[COMMAND]").trim().to_string());
2936 }
2937 if trimmed.starts_with("$ ") || trimmed.starts_with("➜ ") {
2939 return Some(
2940 trimmed
2941 .trim_start_matches("$ ")
2942 .trim_start_matches("➜ ")
2943 .trim()
2944 .to_string(),
2945 );
2946 }
2947 }
2948 None
2949 }
2950
2951 fn extract_code_from_response(&self, content: &str) -> Option<(String, String, bool)> {
2956 self.extract_all_code_blocks_from_response(content)
2958 .into_iter()
2959 .next()
2960 }
2961
2962 fn extract_all_code_blocks_from_response(&self, content: &str) -> Vec<(String, String, bool)> {
2968 let lines: Vec<&str> = content.lines().collect();
2969 let mut results: Vec<(String, String, bool)> = Vec::new();
2970 let mut file_path: Option<String> = None;
2971 let mut is_diff_marker = false;
2972 let mut in_code_block = false;
2973 let mut code_lines: Vec<&str> = Vec::new();
2974 let mut code_lang = String::new();
2975
2976 for line in &lines {
2977 if line.starts_with("File:") || line.starts_with("**File:") || line.starts_with("file:")
2979 {
2980 let path = line
2981 .trim_start_matches("File:")
2982 .trim_start_matches("**File:")
2983 .trim_start_matches("file:")
2984 .trim_start_matches("**")
2985 .trim_end_matches("**")
2986 .trim();
2987 if !path.is_empty() {
2988 file_path = Some(path.to_string());
2989 is_diff_marker = false;
2990 }
2991 }
2992
2993 if line.starts_with("Diff:") || line.starts_with("**Diff:") || line.starts_with("diff:")
2995 {
2996 let path = line
2997 .trim_start_matches("Diff:")
2998 .trim_start_matches("**Diff:")
2999 .trim_start_matches("diff:")
3000 .trim_start_matches("**")
3001 .trim_end_matches("**")
3002 .trim();
3003 if !path.is_empty() {
3004 file_path = Some(path.to_string());
3005 is_diff_marker = true;
3006 }
3007 }
3008
3009 if line.starts_with("```") && !in_code_block {
3011 in_code_block = true;
3012 code_lang = line.trim_start_matches('`').to_string();
3013 continue;
3014 }
3015
3016 if line.starts_with("```") && in_code_block {
3017 in_code_block = false;
3018 if !code_lines.is_empty() {
3019 let code = code_lines.join("\n");
3020 let filename = match file_path.take() {
3021 Some(p) => p,
3022 None => match code_lang.as_str() {
3023 "python" | "py" => "main.py".to_string(),
3024 "rust" | "rs" => "main.rs".to_string(),
3025 "javascript" | "js" => "index.js".to_string(),
3026 "typescript" | "ts" => "index.ts".to_string(),
3027 "toml" => "Cargo.toml".to_string(),
3028 "json" => "config.json".to_string(),
3029 "yaml" | "yml" => "config.yaml".to_string(),
3030 other => {
3031 log::warn!(
3032 "Skipping unnamed code block with unrecognized language tag '{}'",
3033 other
3034 );
3035 code_lines.clear();
3036 code_lang.clear();
3037 is_diff_marker = false;
3038 continue;
3039 }
3040 },
3041 };
3042 let is_diff = is_diff_marker || code_lang == "diff" || code.starts_with("---");
3043 results.push((filename, code, is_diff));
3044 }
3045 code_lines.clear();
3046 code_lang.clear();
3047 is_diff_marker = false;
3048 continue;
3049 }
3050
3051 if in_code_block {
3052 code_lines.push(line);
3053 }
3054 }
3055
3056 results
3057 }
3058
3059 async fn step_verify(&mut self, idx: NodeIndex) -> Result<EnergyComponents> {
3063 log::info!("Step 4: Verification - Computing stability energy");
3064
3065 self.graph[idx].state = NodeState::Verifying;
3066 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
3067 node_id: self.graph[idx].node_id.clone(),
3068 status: perspt_core::NodeStatus::Verifying,
3069 });
3070
3071 let mut energy = EnergyComponents::default();
3073
3074 if let Some(ref err) = self.last_tool_failure {
3076 energy.v_syn = 10.0; log::warn!("Tool failure detected, V_syn set to 10.0: {}", err);
3078 self.emit_log(format!("⚠️ Tool failure prevents verification: {}", err));
3079 self.context.last_diagnostics = vec![lsp_types::Diagnostic {
3083 range: lsp_types::Range::default(),
3084 severity: Some(lsp_types::DiagnosticSeverity::ERROR),
3085 code: None,
3086 code_description: None,
3087 source: Some("tool".to_string()),
3088 message: format!("Failed to apply changes: {}", err),
3089 related_information: None,
3090 tags: None,
3091 data: None,
3092 }];
3093 }
3094
3095 if let Some(ref path) = self.last_written_file {
3097 let node_plugin = self.graph[idx].owner_plugin.clone();
3099 let lsp_key = if node_plugin.is_empty() || node_plugin == "unknown" {
3100 "python".to_string() } else {
3102 node_plugin
3103 };
3104
3105 if let Some(client) = self.lsp_clients.get(&lsp_key) {
3106 tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
3108
3109 let path_str = path.to_string_lossy().to_string();
3110 let diagnostics = client.get_diagnostics(&path_str).await;
3111
3112 if !diagnostics.is_empty() {
3113 energy.v_syn = LspClient::calculate_syntactic_energy(&diagnostics);
3114 log::info!(
3115 "LSP found {} diagnostics, V_syn={:.2}",
3116 diagnostics.len(),
3117 energy.v_syn
3118 );
3119 self.emit_log(format!("🔍 LSP found {} diagnostics:", diagnostics.len()));
3120 for d in &diagnostics {
3121 self.emit_log(format!(
3122 " - [{}] {}",
3123 severity_to_str(d.severity),
3124 d.message
3125 ));
3126 }
3127
3128 self.context.last_diagnostics = diagnostics;
3130 } else {
3131 log::info!("LSP reports no errors (diagnostics vector is empty)");
3132 }
3133 } else {
3134 log::debug!("No LSP client available for plugin '{}'", lsp_key);
3135 }
3136
3137 let node = &self.graph[idx];
3139 if !node.contract.forbidden_patterns.is_empty() {
3140 if let Ok(content) = std::fs::read_to_string(path) {
3141 for pattern in &node.contract.forbidden_patterns {
3142 if content.contains(pattern) {
3143 energy.v_str += 0.5;
3144 log::warn!("Forbidden pattern found: '{}'", pattern);
3145 self.emit_log(format!("⚠️ Forbidden pattern: '{}'", pattern));
3146 }
3147 }
3148 }
3149 }
3150
3151 let node = &self.graph[idx];
3154 if self.context.defer_tests {
3155 self.emit_log("⏭️ Tests deferred (--defer-tests enabled)".to_string());
3156 } else {
3157 let plugin_name = node.owner_plugin.clone();
3158 let verify_dir = self.effective_working_dir(idx);
3159 let stages = verification_stages_for_node(node);
3160
3161 if !stages.is_empty() && !plugin_name.is_empty() && plugin_name != "unknown" {
3162 self.emit_log(format!(
3163 "🔬 Running verification ({} stages) for {} node '{}'...",
3164 stages.len(),
3165 node.node_class,
3166 node.node_id
3167 ));
3168
3169 let mut vr = self
3170 .run_plugin_verification(&plugin_name, &stages, verify_dir.clone())
3171 .await;
3172
3173 if !vr.syntax_ok || !vr.build_ok {
3176 if let Some(ref raw) = vr.raw_output {
3177 let missing = Self::extract_missing_crates(raw);
3178 if !missing.is_empty() {
3179 self.emit_log(format!(
3180 "📦 Auto-installing missing dependencies: {}",
3181 missing.join(", ")
3182 ));
3183 let dep_ok =
3184 Self::auto_install_crate_deps(&missing, &verify_dir).await;
3185 if dep_ok > 0 {
3186 self.emit_log(format!(
3187 "📦 Installed {} crate(s), re-running verification...",
3188 dep_ok
3189 ));
3190 vr = self
3192 .run_plugin_verification(
3193 &plugin_name,
3194 &stages,
3195 verify_dir.clone(),
3196 )
3197 .await;
3198 }
3199 }
3200 }
3201 }
3202
3203 if plugin_name == "python" && (!vr.syntax_ok || !vr.tests_ok) {
3207 let all_output: String = vr
3211 .stage_outcomes
3212 .iter()
3213 .filter_map(|so| so.output.as_deref())
3214 .collect::<Vec<_>>()
3215 .join("\n");
3216 let combined = match vr.raw_output.as_deref() {
3217 Some(raw) => format!("{}\n{}", raw, all_output),
3218 None => all_output,
3219 };
3220
3221 let missing = Self::extract_missing_python_modules(&combined);
3222 if !missing.is_empty() {
3223 self.emit_log(format!(
3224 "🐍 Auto-installing missing Python packages: {}",
3225 missing.join(", ")
3226 ));
3227 let dep_ok =
3228 Self::auto_install_python_deps(&missing, &verify_dir).await;
3229 if dep_ok > 0 {
3230 self.emit_log(format!(
3231 "🐍 Installed {} package(s), re-running verification...",
3232 dep_ok
3233 ));
3234 vr = self
3235 .run_plugin_verification(
3236 &plugin_name,
3237 &stages,
3238 verify_dir.clone(),
3239 )
3240 .await;
3241 }
3242 }
3243 }
3244
3245 if !vr.syntax_ok && energy.v_syn < 5.0 {
3248 energy.v_syn = 5.0;
3249 }
3250 if !vr.build_ok && energy.v_syn < 8.0 {
3252 energy.v_syn = 8.0;
3253 }
3254 if !vr.tests_ok && vr.tests_failed > 0 {
3256 let node = &self.graph[idx];
3257 if !node.contract.weighted_tests.is_empty() {
3258 let py_runner = PythonTestRunner::new(verify_dir);
3260 let test_results = TestResults {
3261 passed: vr.tests_passed,
3262 failed: vr.tests_failed,
3263 total: vr.tests_passed + vr.tests_failed,
3264 output: vr.raw_output.clone().unwrap_or_default(),
3265 failures: Vec::new(),
3266 run_succeeded: true,
3267 skipped: 0,
3268 duration_ms: 0,
3269 };
3270 energy.v_log = py_runner.calculate_v_log(&test_results, &node.contract);
3271 } else {
3272 let total = (vr.tests_passed + vr.tests_failed) as f32;
3274 if total > 0.0 {
3275 energy.v_log = (vr.tests_failed as f32 / total) * 10.0;
3276 }
3277 }
3278 }
3279 if !vr.lint_ok
3281 && self.context.verifier_strictness
3282 == perspt_core::types::VerifierStrictness::Strict
3283 {
3284 energy.v_str += 0.3;
3285 }
3286
3287 if let Some(ref raw) = vr.raw_output {
3289 self.context.last_test_output = Some(raw.clone());
3290 }
3291
3292 self.emit_log(format!("📊 Verification: {}", vr.summary));
3293 }
3294 }
3295 }
3296
3297 let node = &self.graph[idx];
3298 if let Err(e) =
3300 self.ledger
3301 .record_energy(&node.node_id, &energy, energy.total(&node.contract))
3302 {
3303 log::error!("Failed to record energy: {}", e);
3304 }
3305
3306 log::info!(
3307 "Energy for {}: V_syn={:.2}, V_str={:.2}, V_log={:.2}, V_boot={:.2}, V_sheaf={:.2}, Total={:.2}",
3308 node.node_id,
3309 energy.v_syn,
3310 energy.v_str,
3311 energy.v_log,
3312 energy.v_boot,
3313 energy.v_sheaf,
3314 energy.total(&node.contract)
3315 );
3316
3317 {
3319 let node = &self.graph[idx];
3320 let total = energy.total(&node.contract);
3321 let (
3322 stage_outcomes,
3323 degraded,
3324 degraded_reasons,
3325 summary,
3326 lint_ok,
3327 tests_passed,
3328 tests_failed,
3329 ) = if let Some(ref vr) = self.last_verification_result {
3330 (
3331 vr.stage_outcomes.clone(),
3332 vr.degraded,
3333 vr.degraded_stage_reasons(),
3334 vr.summary.clone(),
3335 vr.lint_ok,
3336 vr.tests_passed,
3337 vr.tests_failed,
3338 )
3339 } else {
3340 let diag_count = self.context.last_diagnostics.len();
3341 (
3342 Vec::new(),
3343 false,
3344 Vec::new(),
3345 format!("V(x)={:.2} | {} diagnostics", total, diag_count),
3346 true,
3347 0,
3348 0,
3349 )
3350 };
3351
3352 self.emit_event(perspt_core::AgentEvent::VerificationComplete {
3353 node_id: node.node_id.clone(),
3354 syntax_ok: energy.v_syn == 0.0,
3355 build_ok: energy.v_syn < 5.0,
3356 tests_ok: energy.v_log == 0.0,
3357 lint_ok,
3358 diagnostics_count: self.context.last_diagnostics.len(),
3359 tests_passed,
3360 tests_failed,
3361 energy: total,
3362 energy_components: energy.clone(),
3363 stage_outcomes,
3364 degraded,
3365 degraded_reasons,
3366 summary,
3367 node_class: node.node_class.to_string(),
3368 });
3369 }
3370
3371 Ok(energy)
3372 }
3373
3374 async fn step_converge(&mut self, idx: NodeIndex, energy: EnergyComponents) -> Result<bool> {
3378 log::info!("Step 5: Convergence check");
3379
3380 let total = {
3382 let node = &self.graph[idx];
3383 energy.total(&node.contract)
3384 };
3385
3386 let node = &mut self.graph[idx];
3388 node.monitor.record_energy(total);
3389 let node_id = node.node_id.clone();
3390 let goal = node.goal.clone();
3391 let epsilon = node.monitor.stability_epsilon;
3392 let attempt_count = node.monitor.attempt_count;
3393 let stable = node.monitor.stable;
3394 let should_escalate = node.monitor.should_escalate();
3395
3396 if stable {
3397 if let Some(ref vr) = self.last_verification_result {
3399 if vr.has_degraded_stages() {
3400 let reasons = vr.degraded_stage_reasons();
3401 log::warn!(
3402 "Node {} energy is below ε but verification was degraded: {:?}",
3403 node_id,
3404 reasons
3405 );
3406 self.emit_log(format!(
3407 "⚠️ V(x)={:.2} < ε but stability unconfirmed — degraded sensors: {}",
3408 total,
3409 reasons.join(", ")
3410 ));
3411 self.emit_event(perspt_core::AgentEvent::DegradedVerification {
3412 node_id: node_id.clone(),
3413 degraded_stages: reasons,
3414 stability_blocked: true,
3415 });
3416 } else {
3420 log::info!(
3421 "Node {} is stable (V(x)={:.2} < ε={:.2})",
3422 node_id,
3423 total,
3424 epsilon
3425 );
3426 self.emit_log(format!("✅ Stable! V(x)={:.2} < ε={:.2}", total, epsilon));
3427 return Ok(true);
3428 }
3429 } else {
3430 log::info!(
3431 "Node {} is stable (V(x)={:.2} < ε={:.2})",
3432 node_id,
3433 total,
3434 epsilon
3435 );
3436 self.emit_log(format!("✅ Stable! V(x)={:.2} < ε={:.2}", total, epsilon));
3437 return Ok(true);
3438 }
3439 }
3440
3441 if should_escalate {
3442 log::warn!(
3443 "Node {} failed to converge after {} attempts (V(x)={:.2})",
3444 node_id,
3445 attempt_count,
3446 total
3447 );
3448 self.emit_log(format!(
3449 "⚠️ Escalating: failed to converge after {} attempts",
3450 attempt_count
3451 ));
3452 return Ok(false);
3453 }
3454
3455 self.graph[idx].state = NodeState::Retry;
3457 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
3458 node_id: self.graph[idx].node_id.clone(),
3459 status: perspt_core::NodeStatus::Retrying,
3460 });
3461 log::info!(
3462 "V(x)={:.2} > ε={:.2}, regenerating with feedback (attempt {})",
3463 total,
3464 epsilon,
3465 attempt_count
3466 );
3467 self.emit_log(format!(
3468 "🔄 V(x)={:.2} > ε={:.2}, sending errors to LLM (attempt {})",
3469 total, epsilon, attempt_count
3470 ));
3471
3472 let correction_prompt = self.build_correction_prompt(&node_id, &goal, &energy)?;
3474
3475 log::info!(
3476 "--- CORRECTION PROMPT ---\n{}\n-------------------------",
3477 correction_prompt
3478 );
3479 self.emit_log("📤 Sending correction prompt to LLM...".to_string());
3481
3482 let corrected = self.call_llm_for_correction(&correction_prompt).await?;
3484
3485 if let Some((filename, new_code, is_diff)) = self.extract_code_from_response(&corrected) {
3487 let full_path = self.context.working_dir.join(&filename);
3488
3489 let mut args = HashMap::new();
3491 args.insert("path".to_string(), filename.clone());
3492
3493 let call = if is_diff {
3494 args.insert("diff".to_string(), new_code.clone());
3495 ToolCall {
3496 name: "apply_diff".to_string(),
3497 arguments: args,
3498 }
3499 } else {
3500 args.insert("content".to_string(), new_code.clone());
3501 ToolCall {
3502 name: "write_file".to_string(),
3503 arguments: args,
3504 }
3505 };
3506
3507 let result = self.tools.execute(&call).await;
3508 if result.success {
3509 log::info!("✓ Applied correction to: {}", filename);
3510 self.emit_log(format!("📝 Applied correction to: {}", filename));
3511 self.last_tool_failure = None;
3512
3513 self.last_written_file = Some(full_path.clone());
3515 self.file_version += 1;
3516
3517 let lsp_key = self.lsp_key_for_file(&full_path.to_string_lossy());
3519 if let Some(client) = lsp_key.and_then(|k| self.lsp_clients.get_mut(&k)) {
3520 if let Ok(content) = std::fs::read_to_string(&full_path) {
3521 let _ = client
3522 .did_change(&full_path, &content, self.file_version)
3523 .await;
3524 }
3525 }
3526 } else {
3527 self.last_tool_failure = result.error;
3528 }
3529 }
3530
3531 let correction_cmds = Self::extract_commands_from_correction(&corrected);
3533 if !correction_cmds.is_empty() {
3534 self.emit_log(format!(
3535 "📦 Running {} dependency command(s) from correction...",
3536 correction_cmds.len()
3537 ));
3538 let work_dir = self.effective_working_dir(idx);
3539 for cmd in &correction_cmds {
3540 log::info!("Running correction command: {}", cmd);
3541 let parts: Vec<&str> = cmd.split_whitespace().collect();
3542 if parts.is_empty() {
3543 continue;
3544 }
3545 let output = tokio::process::Command::new(parts[0])
3546 .args(&parts[1..])
3547 .current_dir(&work_dir)
3548 .stdout(std::process::Stdio::piped())
3549 .stderr(std::process::Stdio::piped())
3550 .output()
3551 .await;
3552 match output {
3553 Ok(o) if o.status.success() => {
3554 self.emit_log(format!("✅ {}", cmd));
3555 }
3556 Ok(o) => {
3557 let stderr = String::from_utf8_lossy(&o.stderr);
3558 log::warn!("Command failed: {} — {}", cmd, stderr);
3559 }
3560 Err(e) => {
3561 log::warn!("Failed to run command: {} — {}", cmd, e);
3562 }
3563 }
3564 }
3565 }
3566
3567 let new_energy = self.step_verify(idx).await?;
3569 Box::pin(self.step_converge(idx, new_energy)).await
3570 }
3571
3572 fn build_correction_prompt(
3578 &self,
3579 _node_id: &str,
3580 goal: &str,
3581 energy: &EnergyComponents,
3582 ) -> Result<String> {
3583 let diagnostics = &self.context.last_diagnostics;
3584
3585 let current_code = if let Some(ref path) = self.last_written_file {
3587 std::fs::read_to_string(path).unwrap_or_default()
3588 } else {
3589 String::new()
3590 };
3591
3592 let file_path = self
3593 .last_written_file
3594 .as_ref()
3595 .map(|p| {
3596 p.file_name()
3597 .unwrap_or_default()
3598 .to_string_lossy()
3599 .to_string()
3600 })
3601 .unwrap_or_else(|| "unknown".to_string());
3602
3603 let lang = self
3605 .last_written_file
3606 .as_ref()
3607 .and_then(|p| p.extension())
3608 .and_then(|e| e.to_str())
3609 .map(|ext| match ext {
3610 "py" => "python",
3611 "rs" => "rust",
3612 "ts" | "tsx" => "typescript",
3613 "js" | "jsx" => "javascript",
3614 "go" => "go",
3615 "java" => "java",
3616 "rb" => "ruby",
3617 "c" | "h" => "c",
3618 "cpp" | "cc" | "cxx" | "hpp" => "cpp",
3619 "cs" => "csharp",
3620 other => other,
3621 })
3622 .unwrap_or("text");
3623
3624 let mut prompt = format!(
3625 r#"## Code Correction Required
3626
3627The code you generated has {} error(s) detected by the language toolchain.
3628Your task is to fix ALL errors and return the complete corrected file.
3629
3630### Original Goal
3631{}
3632
3633### Current Code (with errors)
3634File: {}
3635```{}
3636{}
3637```
3638
3639### Detected Errors (V_syn = {:.2})
3640"#,
3641 diagnostics.len(),
3642 goal,
3643 file_path,
3644 lang,
3645 current_code,
3646 energy.v_syn
3647 );
3648
3649 for (i, diag) in diagnostics.iter().enumerate() {
3651 let fix_direction = self.get_fix_direction(diag);
3652 prompt.push_str(&format!(
3653 r#"
3654#### Error {}
3655- **Location**: Line {}, Column {}
3656- **Severity**: {}
3657- **Message**: {}
3658- **How to fix**: {}
3659"#,
3660 i + 1,
3661 diag.range.start.line + 1,
3662 diag.range.start.character + 1,
3663 severity_to_str(diag.severity),
3664 diag.message,
3665 fix_direction
3666 ));
3667 }
3668
3669 if !self.last_formatted_context.is_empty() {
3673 prompt.push_str(&format!(
3674 "\n### Restriction Map Context\n\n{}\n",
3675 self.last_formatted_context
3676 ));
3677 }
3678
3679 if let Some(ref test_output) = self.context.last_test_output {
3683 if !test_output.is_empty() {
3684 let truncated = if test_output.len() > 3000 {
3686 &test_output[..3000]
3687 } else {
3688 test_output.as_str()
3689 };
3690 prompt.push_str(&format!(
3691 "\n### Build / Test Output\nThe following is the raw output from the build toolchain (e.g. `cargo check` / `cargo build`). \
3692 Use this to identify missing dependencies, unresolved imports, or type errors:\n```\n{}\n```\n",
3693 truncated
3694 ));
3695 }
3696 }
3697
3698 prompt.push_str(&format!(
3699 r#"
3700### Fix Requirements
37011. Fix ALL errors listed above - do not leave any unfixed
37022. Maintain the original functionality and goal
37033. Follow {} language conventions and idioms
37044. Import any missing modules or dependencies
37055. Return the COMPLETE corrected file, not just snippets
37066. If errors mention missing crates/packages (e.g. "can't find crate", "unresolved import" for an external dependency, "ModuleNotFoundError", "No module named"), list the required install commands
3707
3708### Output Format
3709Provide the complete corrected file followed by any dependency commands needed:
3710
3711File: [same filename]
3712```{}
3713[complete corrected code]
3714```
3715
3716Commands: [optional, one per line]
3717```
3718cargo add thiserror
3719cargo add clap --features derive
3720uv add httpx
3721uv add --dev pytest
3722```
3723"#,
3724 lang, lang
3725 ));
3726
3727 Ok(prompt)
3728 }
3729
3730 fn get_fix_direction(&self, diag: &lsp_types::Diagnostic) -> String {
3732 let msg = diag.message.to_lowercase();
3733
3734 if msg.contains("undefined") || msg.contains("unresolved") || msg.contains("not defined") {
3735 if msg.contains("crate") || msg.contains("module") {
3736 "The crate may not be in Cargo.toml. Add it with `cargo add <crate>` in the Commands section, or use `crate::` for intra-crate imports".into()
3737 } else {
3738 "Define the missing variable/function, or import it from the correct module".into()
3739 }
3740 } else if msg.contains("type") && (msg.contains("expected") || msg.contains("incompatible"))
3741 {
3742 "Change the value or add a type conversion to match the expected type".into()
3743 } else if msg.contains("import") || msg.contains("no module named") {
3744 "Add the correct import statement at the top of the file. For Python: use `uv add <pkg>` for external packages; use relative imports (`from . import mod`) inside package modules.".into()
3745 } else if msg.contains("argument") && (msg.contains("missing") || msg.contains("expected"))
3746 {
3747 "Provide all required arguments to the function call".into()
3748 } else if msg.contains("return") && msg.contains("type") {
3749 "Ensure the return statement returns a value of the declared return type".into()
3750 } else if msg.contains("attribute") {
3751 "Check if the object has this attribute, or fix the object type".into()
3752 } else if msg.contains("syntax") {
3753 "Fix the syntax error - check for missing colons, parentheses, or indentation".into()
3754 } else if msg.contains("indentation") {
3755 "Fix the indentation to match Python's indentation rules (4 spaces per level)".into()
3756 } else if msg.contains("parameter") {
3757 "Check the function signature and update parameter types/names".into()
3758 } else {
3759 format!("Review and fix: {}", diag.message)
3760 }
3761 }
3762
3763 async fn call_llm_for_correction(&self, prompt: &str) -> Result<String> {
3772 let verifier_prompt = format!(
3774 "You are a Verifier agent. Analyze the following correction request and produce \
3775 concise, structured guidance for the code fixer. Identify:\n\
3776 1. Root cause of each failure\n\
3777 2. Which specific functions/lines need changes\n\
3778 3. Constraints that must be preserved\n\
3779 Do NOT produce code — only analysis and guidance.\n\n{}",
3780 prompt
3781 );
3782
3783 log::debug!(
3784 "Stage 1: Sending analysis to verifier model: {}",
3785 self.verifier_model
3786 );
3787 let guidance = self
3788 .call_llm_with_logging(&self.verifier_model.clone(), &verifier_prompt, None)
3789 .await
3790 .unwrap_or_else(|e| {
3791 log::warn!(
3792 "Verifier analysis failed ({}), falling back to actuator-only correction",
3793 e
3794 );
3795 String::new()
3796 });
3797
3798 let actuator_prompt = if guidance.is_empty() {
3800 prompt.to_string()
3801 } else {
3802 format!(
3803 "{}\n\n## Verifier Analysis\n{}\n\nApply the above analysis to produce corrected code.",
3804 prompt, guidance
3805 )
3806 };
3807
3808 log::debug!(
3809 "Stage 2: Sending correction to actuator model: {}",
3810 self.actuator_model
3811 );
3812 let response = self
3813 .call_llm_with_logging(&self.actuator_model.clone(), &actuator_prompt, None)
3814 .await?;
3815 log::debug!("Received correction response with {} chars", response.len());
3816
3817 Ok(response)
3818 }
3819
3820 async fn call_llm_with_logging(
3822 &self,
3823 model: &str,
3824 prompt: &str,
3825 node_id: Option<&str>,
3826 ) -> Result<String> {
3827 let start = Instant::now();
3828
3829 let response = self
3830 .provider
3831 .generate_response_simple(model, prompt)
3832 .await?;
3833
3834 if self.context.log_llm {
3836 let latency_ms = start.elapsed().as_millis() as i32;
3837 if let Err(e) = self
3838 .ledger
3839 .record_llm_request(model, prompt, &response, node_id, latency_ms)
3840 {
3841 log::warn!("Failed to persist LLM request: {}", e);
3842 } else {
3843 log::debug!(
3844 "Persisted LLM request: model={}, latency={}ms",
3845 model,
3846 latency_ms
3847 );
3848 }
3849 }
3850
3851 Ok(response)
3852 }
3853
3854 async fn call_llm_with_tier_fallback<F>(
3861 &self,
3862 primary_model: &str,
3863 prompt: &str,
3864 node_id: Option<&str>,
3865 tier: ModelTier,
3866 validator: F,
3867 ) -> Result<String>
3868 where
3869 F: Fn(&str) -> std::result::Result<(), String>,
3870 {
3871 let response = self
3873 .call_llm_with_logging(primary_model, prompt, node_id)
3874 .await?;
3875
3876 if validator(&response).is_ok() {
3878 return Ok(response);
3879 }
3880
3881 let validation_err = validator(&response).unwrap_err();
3882 log::warn!(
3883 "Primary model '{}' failed structured-output contract for {:?}: {}",
3884 primary_model,
3885 tier,
3886 validation_err
3887 );
3888
3889 let fallback = match tier {
3891 ModelTier::Architect => self.architect_fallback_model.as_deref(),
3892 ModelTier::Actuator => self.actuator_fallback_model.as_deref(),
3893 ModelTier::Verifier => self.verifier_fallback_model.as_deref(),
3894 ModelTier::Speculator => self.speculator_fallback_model.as_deref(),
3895 };
3896
3897 let fallback_model = fallback.unwrap_or(primary_model);
3901
3902 log::info!(
3903 "Falling back to model '{}' for {:?} tier",
3904 fallback_model,
3905 tier
3906 );
3907 self.emit_event_ref(perspt_core::AgentEvent::ModelFallback {
3908 node_id: node_id.unwrap_or("").to_string(),
3909 tier: format!("{:?}", tier),
3910 primary_model: primary_model.to_string(),
3911 fallback_model: fallback_model.to_string(),
3912 reason: validation_err,
3913 });
3914
3915 self.call_llm_with_logging(fallback_model, prompt, node_id)
3916 .await
3917 }
3918
3919 fn emit_event_ref(&self, event: perspt_core::AgentEvent) {
3921 if let Some(sender) = &self.event_sender {
3922 let _ = sender.send(event);
3923 }
3924 }
3925
3926 async fn step_sheaf_validate(&mut self, idx: NodeIndex) -> Result<()> {
3928 log::info!("Step 6: Sheaf Validation - Cross-node consistency check");
3929
3930 self.graph[idx].state = NodeState::SheafCheck;
3931 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
3932 node_id: self.graph[idx].node_id.clone(),
3933 status: perspt_core::NodeStatus::SheafCheck,
3934 });
3935
3936 let validators = self.select_validators(idx);
3938 if validators.is_empty() {
3939 log::info!("No targeted validators selected — skipping sheaf check");
3940 return Ok(());
3941 }
3942
3943 log::info!(
3944 "Running {} sheaf validators for node {}",
3945 validators.len(),
3946 self.graph[idx].node_id
3947 );
3948
3949 let mut results = Vec::new();
3950 for class in &validators {
3951 let result = self.run_sheaf_validator(idx, *class);
3952 results.push(result);
3953 }
3954
3955 let persist_node_id = self.graph[idx].node_id.clone();
3957 for result in &results {
3958 if let Err(e) = self
3959 .ledger
3960 .record_sheaf_validation(&persist_node_id, result)
3961 {
3962 log::warn!("Failed to persist sheaf validation: {}", e);
3963 }
3964 }
3965
3966 let total_v_sheaf: f32 = results.iter().map(|r| r.v_sheaf_contribution).sum();
3968 let failures: Vec<&SheafValidationResult> = results.iter().filter(|r| !r.passed).collect();
3969 let failure_count = failures.len();
3970
3971 self.emit_event(perspt_core::AgentEvent::SheafValidationComplete {
3973 node_id: self.graph[idx].node_id.clone(),
3974 validators_run: results.len(),
3975 failures: failure_count,
3976 v_sheaf: total_v_sheaf,
3977 });
3978
3979 if !failures.is_empty() {
3980 let node_id = self.graph[idx].node_id.clone();
3981 let evidence = failures
3982 .iter()
3983 .map(|f| format!("{}: {}", f.validator_class, f.evidence_summary))
3984 .collect::<Vec<_>>()
3985 .join("; ");
3986
3987 self.emit_log(format!(
3988 "⚠️ Sheaf validation failed for {} (V_sheaf={:.3}): {}",
3989 node_id, total_v_sheaf, evidence
3990 ));
3991
3992 let requeue_targets: Vec<String> = failures
3994 .iter()
3995 .flat_map(|f| f.requeue_targets.iter().cloned())
3996 .collect::<std::collections::HashSet<_>>()
3997 .into_iter()
3998 .collect();
3999
4000 if !requeue_targets.is_empty() {
4001 self.emit_log(format!(
4002 "🔄 Requeuing {} nodes due to sheaf failures",
4003 requeue_targets.len()
4004 ));
4005 for nid in &requeue_targets {
4006 if let Some(&nidx) = self.node_indices.get(nid.as_str()) {
4007 self.graph[nidx].state = NodeState::TaskQueued;
4008 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4009 node_id: self.graph[nidx].node_id.clone(),
4010 status: perspt_core::NodeStatus::Queued,
4011 });
4012 }
4013 }
4014 }
4015
4016 anyhow::bail!("Sheaf validation failed for node {}: {}", node_id, evidence);
4017 }
4018
4019 log::info!("Sheaf validation passed (V_sheaf={:.3})", total_v_sheaf);
4020 Ok(())
4021 }
4022
4023 fn select_validators(&self, idx: NodeIndex) -> Vec<SheafValidatorClass> {
4026 let mut validators = Vec::new();
4027
4028 validators.push(SheafValidatorClass::DependencyGraphConsistency);
4030
4031 let node = &self.graph[idx];
4032
4033 if node.node_class == perspt_core::types::NodeClass::Interface {
4035 validators.push(SheafValidatorClass::ExportImportConsistency);
4036 }
4037
4038 if node.node_class == perspt_core::types::NodeClass::Integration {
4040 validators.push(SheafValidatorClass::ExportImportConsistency);
4041 validators.push(SheafValidatorClass::SchemaContractCompatibility);
4042 }
4043
4044 let node_owner = &node.owner_plugin;
4046 let has_cross_plugin_deps = self
4047 .graph
4048 .neighbors_directed(idx, petgraph::Direction::Outgoing)
4049 .any(|dep_idx| self.graph[dep_idx].owner_plugin != *node_owner);
4050 if has_cross_plugin_deps {
4051 validators.push(SheafValidatorClass::CrossLanguageBoundary);
4052 }
4053
4054 if let Some(ref vr) = self.last_verification_result {
4057 if !vr.build_ok {
4058 validators.push(SheafValidatorClass::BuildGraphConsistency);
4059 }
4060 if !vr.tests_ok && vr.tests_failed > 0 {
4065 validators.push(SheafValidatorClass::TestOwnershipConsistency);
4066 }
4067 }
4068
4069 validators
4070 }
4071
4072 fn run_sheaf_validator(
4074 &self,
4075 idx: NodeIndex,
4076 class: SheafValidatorClass,
4077 ) -> SheafValidationResult {
4078 let node = &self.graph[idx];
4079 let node_id = &node.node_id;
4080
4081 match class {
4082 SheafValidatorClass::DependencyGraphConsistency => {
4083 if petgraph::algo::is_cyclic_directed(&self.graph) {
4085 SheafValidationResult::failed(
4086 class,
4087 "Cyclic dependency detected in task graph",
4088 vec![node_id.clone()],
4089 self.affected_dependents(idx),
4090 0.5,
4091 )
4092 } else {
4093 SheafValidationResult::passed(class, vec![node_id.clone()])
4094 }
4095 }
4096 SheafValidatorClass::ExportImportConsistency => {
4097 let manifest = &self.context.ownership_manifest;
4099 let mut mismatched = Vec::new();
4100
4101 for target in &node.output_targets {
4102 let target_str = target.to_string_lossy();
4103 if let Some(entry) = manifest.owner_of(&target_str) {
4104 if entry.owner_node_id != *node_id {
4105 mismatched.push(target_str.to_string());
4106 }
4107 }
4108 }
4109
4110 if mismatched.is_empty() {
4111 SheafValidationResult::passed(class, vec![node_id.clone()])
4112 } else {
4113 SheafValidationResult::failed(
4114 class,
4115 format!(
4116 "Ownership mismatch on {} file(s): {}",
4117 mismatched.len(),
4118 mismatched.join(", ")
4119 ),
4120 mismatched,
4121 vec![node_id.clone()],
4122 0.3,
4123 )
4124 }
4125 }
4126 SheafValidatorClass::SchemaContractCompatibility => {
4127 let contract = &node.contract;
4129 if contract.invariants.is_empty() && contract.interface_signature.is_empty() {
4130 SheafValidationResult::failed(
4131 class,
4132 "Integration node has empty contract",
4133 node.output_targets
4134 .iter()
4135 .map(|t| t.to_string_lossy().to_string())
4136 .collect(),
4137 vec![node_id.clone()],
4138 0.2,
4139 )
4140 } else {
4141 SheafValidationResult::passed(class, vec![node_id.clone()])
4142 }
4143 }
4144 SheafValidatorClass::BuildGraphConsistency => {
4145 let dependents = self.affected_dependents(idx);
4148 if dependents.is_empty() {
4149 SheafValidationResult::passed(class, vec![node_id.clone()])
4150 } else {
4151 SheafValidationResult::failed(
4152 class,
4153 format!(
4154 "Build failed with {} dependent nodes potentially affected",
4155 dependents.len()
4156 ),
4157 node.output_targets
4158 .iter()
4159 .map(|t| t.to_string_lossy().to_string())
4160 .collect(),
4161 dependents,
4162 0.4,
4163 )
4164 }
4165 }
4166 SheafValidatorClass::TestOwnershipConsistency => {
4167 let owned_files = self.context.ownership_manifest.files_owned_by(node_id);
4169 if owned_files.is_empty() {
4170 SheafValidationResult::passed(class, vec![node_id.clone()])
4171 } else {
4172 SheafValidationResult::failed(
4175 class,
4176 format!(
4177 "Test failures may be attributed to {} owned file(s)",
4178 owned_files.len()
4179 ),
4180 owned_files.iter().map(|s| s.to_string()).collect(),
4181 vec![node_id.clone()],
4182 0.3,
4183 )
4184 }
4185 }
4186 SheafValidatorClass::CrossLanguageBoundary => {
4187 let mut boundary_issues = Vec::new();
4189 let node_plugin = &node.owner_plugin;
4190
4191 for dep_idx in self
4192 .graph
4193 .neighbors_directed(idx, petgraph::Direction::Outgoing)
4194 {
4195 let dep = &self.graph[dep_idx];
4196 if dep.owner_plugin != *node_plugin && !dep.owner_plugin.is_empty() {
4197 if !self.context.active_plugins.contains(&dep.owner_plugin) {
4199 boundary_issues.push(format!("plugin {} not active", dep.owner_plugin));
4200 }
4201 }
4202 }
4203
4204 if boundary_issues.is_empty() {
4205 SheafValidationResult::passed(class, vec![node_id.clone()])
4206 } else {
4207 SheafValidationResult::failed(
4208 class,
4209 boundary_issues.join("; "),
4210 vec![node_id.clone()],
4211 self.affected_dependents(idx),
4212 0.4,
4213 )
4214 }
4215 }
4216 SheafValidatorClass::PolicyInvariantConsistency => {
4217 SheafValidationResult::passed(class, vec![node_id.clone()])
4219 }
4220 }
4221 }
4222
4223 async fn step_commit(&mut self, idx: NodeIndex) -> Result<()> {
4225 log::info!("Step 7: Committing stable state to ledger");
4226
4227 self.graph[idx].state = NodeState::Committing;
4228 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4229 node_id: self.graph[idx].node_id.clone(),
4230 status: perspt_core::NodeStatus::Committing,
4231 });
4232
4233 if let Some(sandbox_dir) = self.sandbox_dir_for_node(idx) {
4236 match crate::tools::list_sandbox_files(&sandbox_dir) {
4237 Ok(files) => {
4238 for rel in &files {
4239 if let Err(e) = crate::tools::copy_from_sandbox(
4240 &sandbox_dir,
4241 &self.context.working_dir,
4242 rel,
4243 ) {
4244 log::warn!("Failed to export sandbox file {}: {}", rel, e);
4245 }
4246 }
4247 if !files.is_empty() {
4248 self.emit_log(format!(
4249 "📦 Exported {} file(s) from sandbox to workspace",
4250 files.len()
4251 ));
4252 }
4253 }
4254 Err(e) => {
4255 log::warn!("Failed to list sandbox files: {}", e);
4256 }
4257 }
4258 }
4259
4260 if let Some(provenance) = self.last_context_provenance.take() {
4262 if let Err(e) = self.ledger.record_context_provenance(&provenance) {
4263 log::warn!("Failed to record context provenance: {}", e);
4264 }
4265 }
4266
4267 if let Some(ref vr) = self.last_verification_result {
4269 self.ledger
4270 .record_verification_result(&self.graph[idx].node_id, vr)
4271 .map_err(|e| {
4272 anyhow::anyhow!(
4273 "Commit blocked: failed to persist verification result for {}: {}",
4274 self.graph[idx].node_id,
4275 e
4276 )
4277 })?;
4278 }
4279
4280 if let Some(bundle) = self.last_applied_bundle.take() {
4282 if let Err(e) = self
4283 .ledger
4284 .record_artifact_bundle(&self.graph[idx].node_id, &bundle)
4285 {
4286 log::warn!(
4287 "Failed to persist artifact bundle for {}: {}",
4288 self.graph[idx].node_id,
4289 e
4290 );
4291 }
4292 }
4293
4294 let node = &self.graph[idx];
4298 let children_json = if node.children.is_empty() {
4299 None
4300 } else {
4301 Some(serde_json::to_string(&node.children).unwrap_or_default())
4302 };
4303
4304 let payload = crate::ledger::NodeCommitPayload {
4305 node_id: node.node_id.clone(),
4306 state: "Completed".to_string(),
4307 v_total: node.monitor.current_energy(),
4308 merkle_hash: node.interface_seal_hash.map(|h| h.to_vec()),
4309 attempt_count: node.monitor.attempt_count as i32,
4310 node_class: Some(node.node_class.to_string()),
4311 owner_plugin: if node.owner_plugin.is_empty() {
4312 None
4313 } else {
4314 Some(node.owner_plugin.clone())
4315 },
4316 goal: Some(node.goal.clone()),
4317 parent_id: node.parent_id.clone(),
4318 children: children_json,
4319 last_error_type: self
4320 .last_tool_failure
4321 .as_ref()
4322 .map(|f| f.chars().take(200).collect()),
4323 };
4324
4325 self.ledger.commit_node_snapshot(&payload).map_err(|e| {
4326 anyhow::anyhow!(
4327 "Commit blocked: failed to persist node snapshot for {}: {}",
4328 self.graph[idx].node_id,
4329 e
4330 )
4331 })?;
4332
4333 self.emit_interface_seals(idx);
4335
4336 self.graph[idx].state = NodeState::Completed;
4337 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4338 node_id: self.graph[idx].node_id.clone(),
4339 status: perspt_core::NodeStatus::Completed,
4340 });
4341
4342 self.unblock_dependents(idx);
4344
4345 log::info!("Node {} committed", self.graph[idx].node_id);
4346 Ok(())
4347 }
4348
4349 fn classify_non_convergence(&self, idx: NodeIndex) -> EscalationCategory {
4358 let node = &self.graph[idx];
4359
4360 if let Some(ref vr) = self.last_verification_result {
4362 if vr.has_degraded_stages() {
4363 return EscalationCategory::DegradedSensors;
4364 }
4365 }
4366
4367 if node.monitor.retry_policy.review_rejections > 0 {
4369 return EscalationCategory::ContractMismatch;
4370 }
4371
4372 if !node.owner_plugin.is_empty() {
4374 let manifest = &self.context.ownership_manifest;
4375 for target in &node.output_targets {
4376 if let Some(entry) = manifest.owner_of(&target.to_string_lossy()) {
4377 if entry.owner_node_id != node.node_id {
4378 return EscalationCategory::TopologyMismatch;
4379 }
4380 }
4381 }
4382 }
4383
4384 if node.monitor.retry_policy.compilation_failures
4386 >= node.monitor.retry_policy.max_compilation_retries
4387 {
4388 if !node.monitor.is_converging() && node.monitor.attempt_count >= 3 {
4390 return EscalationCategory::InsufficientModelCapability;
4391 }
4392 }
4393
4394 EscalationCategory::ImplementationError
4396 }
4397
4398 fn choose_repair_action(&self, idx: NodeIndex, category: &EscalationCategory) -> RewriteAction {
4402 let node = &self.graph[idx];
4403
4404 match category {
4405 EscalationCategory::DegradedSensors => {
4406 let degraded = self
4407 .last_verification_result
4408 .as_ref()
4409 .map(|vr| vr.degraded_stage_reasons())
4410 .unwrap_or_default();
4411 RewriteAction::DegradedValidationStop {
4412 reason: format!(
4413 "Cannot verify stability — degraded sensors: {}",
4414 degraded.join(", ")
4415 ),
4416 }
4417 }
4418 EscalationCategory::ContractMismatch => RewriteAction::ContractRepair {
4419 fields: vec!["interface_signature".to_string(), "invariants".to_string()],
4420 },
4421 EscalationCategory::InsufficientModelCapability => {
4422 RewriteAction::CapabilityPromotion {
4423 from_tier: node.tier,
4424 to_tier: ModelTier::Architect, }
4426 }
4427 EscalationCategory::TopologyMismatch => {
4428 if node.output_targets.len() > 1 {
4430 RewriteAction::NodeSplit {
4431 proposed_children: node
4432 .output_targets
4433 .iter()
4434 .enumerate()
4435 .map(|(i, _)| format!("{}_split_{}", node.node_id, i))
4436 .collect(),
4437 }
4438 } else {
4439 RewriteAction::InterfaceInsertion {
4440 boundary: format!(
4441 "ownership boundary for {}",
4442 node.output_targets
4443 .first()
4444 .map(|p| p.display().to_string())
4445 .unwrap_or_default()
4446 ),
4447 }
4448 }
4449 }
4450 EscalationCategory::ImplementationError => {
4451 if node.monitor.remaining_attempts() > 0 {
4453 let evidence = self.build_escalation_evidence(idx);
4454 RewriteAction::GroundedRetry {
4455 evidence_summary: evidence,
4456 }
4457 } else {
4458 RewriteAction::UserEscalation {
4459 evidence: self.build_escalation_evidence(idx),
4460 }
4461 }
4462 }
4463 }
4464 }
4465
4466 async fn apply_repair_action(&mut self, idx: NodeIndex, action: &RewriteAction) -> bool {
4469 let node_id = self.graph[idx].node_id.clone();
4470
4471 const MAX_REWRITES_PER_LINEAGE: usize = 3;
4475 let lineage_rewrites = self.count_lineage_rewrites(&node_id);
4476 if lineage_rewrites >= MAX_REWRITES_PER_LINEAGE {
4477 log::warn!(
4478 "Rewrite churn limit reached for node {} ({} prior rewrites) — refusing rewrite",
4479 node_id,
4480 lineage_rewrites
4481 );
4482 self.emit_log(format!(
4483 "⛔ Rewrite churn limit ({}) reached for {} — escalating",
4484 MAX_REWRITES_PER_LINEAGE, node_id
4485 ));
4486 return false;
4487 }
4488
4489 let category = self.classify_non_convergence(idx);
4490
4491 match action {
4492 RewriteAction::DegradedValidationStop { reason } => {
4493 self.emit_log(format!("⛔ Degraded-validation stop: {}", reason));
4494 self.graph[idx].state = NodeState::Escalated;
4495 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4496 node_id: self.graph[idx].node_id.clone(),
4497 status: perspt_core::NodeStatus::Escalated,
4498 });
4499 self.persist_rewrite_record(&node_id, action, &category, &[]);
4500 false
4501 }
4502 RewriteAction::UserEscalation { evidence } => {
4503 self.emit_log(format!("⚠️ User escalation required: {}", evidence));
4504 self.persist_rewrite_record(&node_id, action, &category, &[]);
4505 false
4506 }
4507 RewriteAction::GroundedRetry { evidence_summary } => {
4508 log::info!(
4509 "Applying grounded retry for node {}: {}",
4510 node_id,
4511 evidence_summary
4512 );
4513 self.emit_log(format!(
4514 "🔄 Grounded retry for {}: {}",
4515 node_id,
4516 &evidence_summary[..evidence_summary.len().min(120)]
4517 ));
4518 self.graph[idx].state = NodeState::Retry;
4519 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4520 node_id: self.graph[idx].node_id.clone(),
4521 status: perspt_core::NodeStatus::Retrying,
4522 });
4523 self.persist_rewrite_record(&node_id, action, &category, &[]);
4524 true
4525 }
4526 RewriteAction::ContractRepair { fields } => {
4527 log::info!("Contract repair for node {}: fields {:?}", node_id, fields);
4528 self.emit_log(format!(
4529 "🔧 Contract repair for {}: {}",
4530 node_id,
4531 fields.join(", ")
4532 ));
4533 self.graph[idx].state = NodeState::Retry;
4534 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4535 node_id: self.graph[idx].node_id.clone(),
4536 status: perspt_core::NodeStatus::Retrying,
4537 });
4538 self.persist_rewrite_record(&node_id, action, &category, &[]);
4539 true
4540 }
4541 RewriteAction::CapabilityPromotion { from_tier, to_tier } => {
4542 log::info!(
4543 "Promoting node {} from {:?} to {:?}",
4544 node_id,
4545 from_tier,
4546 to_tier
4547 );
4548 self.emit_log(format!(
4549 "⬆️ Promoting {} from {:?} to {:?}",
4550 node_id, from_tier, to_tier
4551 ));
4552 self.graph[idx].tier = *to_tier;
4553 self.graph[idx].state = NodeState::Retry;
4554 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4555 node_id: self.graph[idx].node_id.clone(),
4556 status: perspt_core::NodeStatus::Retrying,
4557 });
4558 self.persist_rewrite_record(&node_id, action, &category, &[]);
4559 true
4560 }
4561 RewriteAction::SensorRecovery { degraded_stages } => {
4562 log::info!(
4563 "Sensor recovery for node {}: {:?}",
4564 node_id,
4565 degraded_stages
4566 );
4567 self.emit_log(format!("🔧 Attempting sensor recovery for {}", node_id));
4568 self.graph[idx].state = NodeState::Retry;
4569 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4570 node_id: self.graph[idx].node_id.clone(),
4571 status: perspt_core::NodeStatus::Retrying,
4572 });
4573 self.persist_rewrite_record(&node_id, action, &category, &[]);
4574 true
4575 }
4576 RewriteAction::NodeSplit { proposed_children } => {
4577 log::info!(
4578 "Node split requested for {}: {:?}",
4579 node_id,
4580 proposed_children
4581 );
4582 if proposed_children.is_empty() {
4583 self.emit_log(format!(
4584 "✂️ NodeSplit for {} requested with no children — escalating",
4585 node_id
4586 ));
4587 return false;
4588 }
4589 self.emit_log(format!(
4590 "✂️ Splitting {} into {} sub-nodes",
4591 node_id,
4592 proposed_children.len()
4593 ));
4594 let count = proposed_children.len();
4595 let inserted = self.split_node(idx, proposed_children);
4596 if !inserted.is_empty() {
4597 self.persist_rewrite_record(&node_id, action, &category, &inserted);
4598 self.emit_event(perspt_core::AgentEvent::GraphRewriteApplied {
4599 trigger_node: node_id.clone(),
4600 action: "node_split".to_string(),
4601 nodes_affected: count,
4602 });
4603 true
4604 } else {
4605 false
4606 }
4607 }
4608 RewriteAction::InterfaceInsertion { boundary } => {
4609 log::info!("Interface insertion for {}: {}", node_id, boundary);
4610 self.emit_log(format!(
4611 "📐 Inserting interface adapter at boundary: {}",
4612 boundary
4613 ));
4614 let inserted = self.insert_interface_node(idx, boundary);
4615 if let Some(ref adapter_id) = inserted {
4616 self.persist_rewrite_record(
4617 &node_id,
4618 action,
4619 &category,
4620 std::slice::from_ref(adapter_id),
4621 );
4622 self.emit_event(perspt_core::AgentEvent::GraphRewriteApplied {
4623 trigger_node: node_id.clone(),
4624 action: "interface_insertion".to_string(),
4625 nodes_affected: 1,
4626 });
4627 true
4628 } else {
4629 false
4630 }
4631 }
4632 RewriteAction::SubgraphReplan { affected_nodes } => {
4633 log::info!("Subgraph replan for {}: {:?}", node_id, affected_nodes);
4634 let count = affected_nodes.len();
4635 self.emit_log(format!(
4636 "🗺️ Replanning subgraph around {} ({} affected nodes)",
4637 node_id,
4638 affected_nodes.len()
4639 ));
4640 let applied = self.replan_subgraph(idx, affected_nodes);
4641 if applied {
4642 self.persist_rewrite_record(&node_id, action, &category, &[]);
4643 self.emit_event(perspt_core::AgentEvent::GraphRewriteApplied {
4644 trigger_node: node_id.clone(),
4645 action: "subgraph_replan".to_string(),
4646 nodes_affected: count + 1,
4647 });
4648 }
4649 applied
4650 }
4651 }
4652 }
4653
4654 fn persist_rewrite_record(
4656 &self,
4657 node_id: &str,
4658 action: &RewriteAction,
4659 category: &perspt_core::types::EscalationCategory,
4660 inserted_nodes: &[String],
4661 ) {
4662 let rewrite = RewriteRecord {
4663 node_id: node_id.to_string(),
4664 session_id: self.context.session_id.clone(),
4665 action: action.clone(),
4666 category: *category,
4667 requeued_nodes: Vec::new(),
4668 inserted_nodes: inserted_nodes.to_vec(),
4669 timestamp: epoch_seconds(),
4670 };
4671 if let Err(e) = self.ledger.record_rewrite(&rewrite) {
4672 log::warn!("Failed to persist rewrite record: {}", e);
4673 }
4674 }
4675
4676 fn count_lineage_rewrites(&self, node_id: &str) -> usize {
4679 let base = node_id
4681 .split("__split_")
4682 .next()
4683 .unwrap_or(node_id)
4684 .split("__iface")
4685 .next()
4686 .unwrap_or(node_id);
4687
4688 match self.ledger.get_rewrite_count_for_lineage(base) {
4690 Ok(count) => count,
4691 Err(e) => {
4692 log::warn!(
4693 "Failed to query rewrite count for lineage '{}': {}",
4694 base,
4695 e
4696 );
4697 0
4698 }
4699 }
4700 }
4701
4702 fn build_escalation_evidence(&self, idx: NodeIndex) -> String {
4704 let node = &self.graph[idx];
4705 let mut parts = Vec::new();
4706
4707 parts.push(format!("node: {}", node.node_id));
4708 parts.push(format!("goal: {}", node.goal));
4709 parts.push(format!("energy: {:.2}", node.monitor.current_energy()));
4710 parts.push(format!("attempts: {}", node.monitor.attempt_count));
4711 parts.push(node.monitor.retry_policy.summary());
4712
4713 if let Some(ref vr) = self.last_verification_result {
4714 parts.push(format!(
4715 "verification: syn={}, build={}, tests={}, diag={}",
4716 vr.syntax_ok, vr.build_ok, vr.tests_ok, vr.diagnostics_count
4717 ));
4718 if vr.has_degraded_stages() {
4719 parts.push(format!(
4720 "degraded: {}",
4721 vr.degraded_stage_reasons().join("; ")
4722 ));
4723 }
4724 }
4725
4726 if let Some(ref failure) = self.last_tool_failure {
4727 parts.push(format!("last tool failure: {}", failure));
4728 }
4729
4730 parts.join(" | ")
4731 }
4732
4733 fn affected_dependents(&self, idx: NodeIndex) -> Vec<String> {
4735 self.graph
4736 .neighbors_directed(idx, petgraph::Direction::Outgoing)
4737 .map(|dep_idx| self.graph[dep_idx].node_id.clone())
4738 .collect()
4739 }
4740
4741 fn split_node(&mut self, idx: NodeIndex, proposed_children: &[String]) -> Vec<String> {
4747 if proposed_children.is_empty() {
4748 return Vec::new();
4749 }
4750 let parent = self.graph[idx].clone();
4751 let parent_id = parent.node_id.clone();
4752
4753 let incoming: Vec<(NodeIndex, Dependency)> = self
4755 .graph
4756 .neighbors_directed(idx, petgraph::Direction::Incoming)
4757 .map(|src| {
4758 let edge = self.graph.edges_connecting(src, idx).next().unwrap();
4759 (src, edge.weight().clone())
4760 })
4761 .collect();
4762 let outgoing: Vec<(NodeIndex, Dependency)> = self
4763 .graph
4764 .neighbors_directed(idx, petgraph::Direction::Outgoing)
4765 .map(|dst| {
4766 let edge = self.graph.edges_connecting(idx, dst).next().unwrap();
4767 (dst, edge.weight().clone())
4768 })
4769 .collect();
4770
4771 let mut child_indices = Vec::with_capacity(proposed_children.len());
4773 let mut child_ids = Vec::with_capacity(proposed_children.len());
4774 for (i, sub_goal) in proposed_children.iter().enumerate() {
4775 let child_id = format!("{}__split_{}", parent_id, i);
4776 let mut child = SRBNNode::new(child_id.clone(), sub_goal.clone(), parent.tier);
4777 child.parent_id = Some(parent_id.clone());
4778 child.contract = parent.contract.clone();
4779 child.node_class = parent.node_class;
4780 child.owner_plugin = parent.owner_plugin.clone();
4781 child.output_targets = parent
4784 .output_targets
4785 .iter()
4786 .skip(i)
4787 .step_by(proposed_children.len())
4788 .cloned()
4789 .collect();
4790 child.context_files = parent.context_files.clone();
4791 let c_idx = self.graph.add_node(child);
4792 self.node_indices.insert(child_id.clone(), c_idx);
4793 child_indices.push(c_idx);
4794 child_ids.push(child_id);
4795 }
4796
4797 if let Some(&first) = child_indices.first() {
4799 for (src, dep) in &incoming {
4800 self.graph.add_edge(*src, first, dep.clone());
4801 let src_id = self.graph[*src].node_id.clone();
4803 let dst_id = self.graph[first].node_id.clone();
4804 let _ = self
4805 .ledger
4806 .record_task_graph_edge(&src_id, &dst_id, &dep.kind);
4807 }
4808 }
4809 if let Some(&last) = child_indices.last() {
4810 for (dst, dep) in &outgoing {
4811 self.graph.add_edge(last, *dst, dep.clone());
4812 let src_id = self.graph[last].node_id.clone();
4813 let dst_id = self.graph[*dst].node_id.clone();
4814 let _ = self
4815 .ledger
4816 .record_task_graph_edge(&src_id, &dst_id, &dep.kind);
4817 }
4818 }
4819
4820 for pair in child_indices.windows(2) {
4822 self.graph.add_edge(
4823 pair[0],
4824 pair[1],
4825 Dependency {
4826 kind: "split_sequence".to_string(),
4827 },
4828 );
4829 let src_id = self.graph[pair[0]].node_id.clone();
4830 let dst_id = self.graph[pair[1]].node_id.clone();
4831 let _ = self
4832 .ledger
4833 .record_task_graph_edge(&src_id, &dst_id, "split_sequence");
4834 }
4835
4836 self.node_indices.remove(&parent_id);
4838 self.graph.remove_node(idx);
4839
4840 log::info!(
4841 "Split node {} into {} children: {:?}",
4842 parent_id,
4843 proposed_children.len(),
4844 child_ids
4845 );
4846 child_ids
4847 }
4848
4849 fn insert_interface_node(&mut self, idx: NodeIndex, boundary: &str) -> Option<String> {
4854 let source_id = self.graph[idx].node_id.clone();
4855 let adapter_id = format!("{}__iface", source_id);
4856 let source_node = &self.graph[idx];
4857
4858 let mut adapter = SRBNNode::new(
4859 adapter_id.clone(),
4860 format!("Interface adapter: {}", boundary),
4861 source_node.tier,
4862 );
4863 adapter.parent_id = Some(source_id.clone());
4864 adapter.node_class = perspt_core::types::NodeClass::Interface;
4865 adapter.owner_plugin = source_node.owner_plugin.clone();
4866
4867 let adapter_idx = self.graph.add_node(adapter);
4868 self.node_indices.insert(adapter_id.clone(), adapter_idx);
4869
4870 let outgoing: Vec<(NodeIndex, Dependency)> = self
4872 .graph
4873 .neighbors_directed(idx, petgraph::Direction::Outgoing)
4874 .map(|dst| {
4875 let edge = self.graph.edges_connecting(idx, dst).next().unwrap();
4876 (dst, edge.weight().clone())
4877 })
4878 .collect();
4879
4880 let edge_ids: Vec<_> = self
4882 .graph
4883 .edges_directed(idx, petgraph::Direction::Outgoing)
4884 .map(|e| e.id())
4885 .collect();
4886 for eid in edge_ids {
4887 self.graph.remove_edge(eid);
4888 }
4889
4890 self.graph.add_edge(
4892 idx,
4893 adapter_idx,
4894 Dependency {
4895 kind: "interface_boundary".to_string(),
4896 },
4897 );
4898 let _ = self
4899 .ledger
4900 .record_task_graph_edge(&source_id, &adapter_id, "interface_boundary");
4901
4902 for (dst, dep) in outgoing {
4904 self.graph.add_edge(adapter_idx, dst, dep.clone());
4905 let dst_id = self.graph[dst].node_id.clone();
4906 let _ = self
4907 .ledger
4908 .record_task_graph_edge(&adapter_id, &dst_id, &dep.kind);
4909 }
4910
4911 log::info!("Inserted interface node {} after {}", adapter_id, source_id);
4912 Some(adapter_id)
4913 }
4914
4915 fn replan_subgraph(&mut self, trigger_idx: NodeIndex, affected_nodes: &[String]) -> bool {
4919 let mut replanned = 0;
4920
4921 self.graph[trigger_idx].state = NodeState::Retry;
4923 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4924 node_id: self.graph[trigger_idx].node_id.clone(),
4925 status: perspt_core::NodeStatus::Retrying,
4926 });
4927 self.graph[trigger_idx].monitor.reset_for_replan();
4928 replanned += 1;
4929
4930 for nid in affected_nodes {
4932 if let Some(&nidx) = self.node_indices.get(nid.as_str()) {
4933 self.graph[nidx].state = NodeState::TaskQueued;
4934 self.emit_event(perspt_core::AgentEvent::TaskStatusChanged {
4935 node_id: self.graph[nidx].node_id.clone(),
4936 status: perspt_core::NodeStatus::Queued,
4937 });
4938 self.graph[nidx].monitor.reset_for_replan();
4939 replanned += 1;
4940 } else {
4941 log::warn!("Subgraph replan: unknown node {}", nid);
4942 }
4943 }
4944
4945 log::info!(
4946 "Replanned {} nodes starting from {}",
4947 replanned,
4948 self.graph[trigger_idx].node_id
4949 );
4950 replanned > 0
4951 }
4952
4953 pub fn session_id(&self) -> &str {
4955 &self.context.session_id
4956 }
4957
4958 pub fn node_count(&self) -> usize {
4960 self.graph.node_count()
4961 }
4962
4963 pub async fn start_python_lsp(&mut self) -> Result<()> {
4967 self.start_lsp_for_plugins(&["python"]).await
4968 }
4969
4970 pub async fn start_lsp_for_plugins(&mut self, plugin_names: &[&str]) -> Result<()> {
4975 let registry = perspt_core::plugin::PluginRegistry::new();
4976
4977 for &name in plugin_names {
4978 if self.lsp_clients.contains_key(name) {
4979 log::debug!("LSP client already running for {}", name);
4980 continue;
4981 }
4982
4983 let plugin = match registry.get(name) {
4984 Some(p) => p,
4985 None => {
4986 log::warn!("No plugin found for '{}', skipping LSP startup", name);
4987 continue;
4988 }
4989 };
4990
4991 let profile = plugin.verifier_profile();
4992 let lsp_config = match profile.lsp.effective_config() {
4993 Some(cfg) => cfg.clone(),
4994 None => {
4995 log::warn!(
4996 "No available LSP for {} (primary and fallback unavailable)",
4997 name
4998 );
4999 continue;
5000 }
5001 };
5002
5003 log::info!(
5004 "Starting LSP for {}: {} {:?}",
5005 name,
5006 lsp_config.server_binary,
5007 lsp_config.args
5008 );
5009
5010 let mut client = LspClient::from_config(&lsp_config);
5011 match client
5012 .start_with_config(&lsp_config, &self.context.working_dir)
5013 .await
5014 {
5015 Ok(()) => {
5016 log::info!("{} LSP started successfully", name);
5017 self.lsp_clients.insert(name.to_string(), client);
5018 }
5019 Err(e) => {
5020 log::warn!(
5021 "Failed to start {} LSP: {} (continuing without it)",
5022 name,
5023 e
5024 );
5025 }
5026 }
5027 }
5028
5029 Ok(())
5030 }
5031
5032 fn lsp_key_for_file(&self, path: &str) -> Option<String> {
5037 let registry = perspt_core::plugin::PluginRegistry::new();
5038
5039 for plugin in registry.all() {
5041 if plugin.owns_file(path) {
5042 let name = plugin.name().to_string();
5043 if self.lsp_clients.contains_key(&name) {
5044 return Some(name);
5045 }
5046 }
5047 }
5048
5049 self.lsp_clients.keys().next().cloned()
5051 }
5052
5053 pub fn parse_artifact_bundle(
5062 &self,
5063 content: &str,
5064 ) -> Option<perspt_core::types::ArtifactBundle> {
5065 if let Some(bundle) = self.try_parse_json_bundle(content) {
5067 if let Ok(()) = bundle.validate() {
5068 log::info!(
5069 "Parsed structured artifact bundle: {} artifacts",
5070 bundle.len()
5071 );
5072 return Some(bundle);
5073 } else {
5074 log::warn!("JSON bundle found but failed validation, falling back to legacy");
5075 }
5076 }
5077
5078 let blocks = self.extract_all_code_blocks_from_response(content);
5080 if !blocks.is_empty() {
5081 let artifacts: Vec<perspt_core::types::ArtifactOperation> = blocks
5082 .into_iter()
5083 .map(|(filename, code, is_diff)| {
5084 if is_diff {
5085 perspt_core::types::ArtifactOperation::Diff {
5086 path: filename,
5087 patch: code,
5088 }
5089 } else {
5090 perspt_core::types::ArtifactOperation::Write {
5091 path: filename,
5092 content: code,
5093 }
5094 }
5095 })
5096 .collect();
5097 log::info!(
5098 "Constructed {}-artifact bundle from legacy extraction",
5099 artifacts.len()
5100 );
5101 let bundle = perspt_core::types::ArtifactBundle {
5102 artifacts,
5103 commands: vec![],
5104 };
5105 return Some(bundle);
5106 }
5107
5108 None
5109 }
5110
5111 fn try_parse_json_bundle(&self, content: &str) -> Option<perspt_core::types::ArtifactBundle> {
5115 match perspt_core::normalize::extract_and_deserialize::<perspt_core::types::ArtifactBundle>(
5116 content,
5117 ) {
5118 Ok((bundle, method)) => {
5119 log::info!("Parsed ArtifactBundle via normalization ({})", method);
5120 Some(bundle)
5121 }
5122 Err(e) => {
5123 log::debug!("Normalization could not extract ArtifactBundle: {}", e);
5124 None
5125 }
5126 }
5127 }
5128
5129 pub async fn apply_bundle_transactionally(
5136 &mut self,
5137 bundle: &perspt_core::types::ArtifactBundle,
5138 node_id: &str,
5139 node_class: perspt_core::types::NodeClass,
5140 ) -> Result<()> {
5141 let idx =
5142 self.node_indices.get(node_id).copied().ok_or_else(|| {
5143 anyhow::anyhow!("Unknown node '{}' for bundle application", node_id)
5144 })?;
5145 let node_workdir = self.effective_working_dir(idx);
5146
5147 bundle.validate().map_err(|e| anyhow::anyhow!(e))?;
5149
5150 let filtered = self.filter_bundle_to_declared_paths(bundle, node_id);
5152
5153 let bundle = if filtered.artifacts.is_empty() && !bundle.artifacts.is_empty() {
5158 log::warn!(
5159 "All artifacts stripped for node '{}' — falling back to original bundle",
5160 node_id
5161 );
5162 self.emit_log(format!(
5163 "⚠️ Path mismatch: all artifacts for '{}' targeted unplanned paths — applying anyway",
5164 node_id
5165 ));
5166 bundle.clone()
5167 } else {
5168 filtered
5169 };
5170
5171 if let Err(e) = self
5176 .context
5177 .ownership_manifest
5178 .validate_bundle(&bundle, node_id, node_class)
5179 {
5180 log::warn!("Ownership validation warning for node '{}': {}", node_id, e);
5181 self.emit_log(format!("⚠️ Ownership warning: {}", e));
5182 }
5183
5184 let owner_plugin = self
5186 .node_indices
5187 .get(node_id)
5188 .and_then(|idx| {
5189 let plugin = &self.graph[*idx].owner_plugin;
5190 if plugin.is_empty() {
5191 None
5192 } else {
5193 Some(plugin.clone())
5194 }
5195 })
5196 .unwrap_or_else(|| "unknown".to_string());
5197
5198 let mut files_created: Vec<String> = Vec::new();
5199 let mut files_modified: Vec<String> = Vec::new();
5200
5201 for op in &bundle.artifacts {
5202 let mut args = HashMap::new();
5203 let resolved_path = node_workdir.join(op.path());
5204 args.insert(
5205 "path".to_string(),
5206 resolved_path.to_string_lossy().to_string(),
5207 );
5208
5209 let call = match op {
5210 perspt_core::types::ArtifactOperation::Write { content, .. } => {
5211 args.insert("content".to_string(), content.clone());
5212 ToolCall {
5213 name: "write_file".to_string(),
5214 arguments: args,
5215 }
5216 }
5217 perspt_core::types::ArtifactOperation::Diff { patch, .. } => {
5218 args.insert("diff".to_string(), patch.clone());
5219 ToolCall {
5220 name: "apply_diff".to_string(),
5221 arguments: args,
5222 }
5223 }
5224 };
5225
5226 let result = self.tools.execute(&call).await;
5227 if result.success {
5228 let full_path = resolved_path.clone();
5229
5230 if op.is_write() {
5231 files_created.push(op.path().to_string());
5232 } else {
5233 files_modified.push(op.path().to_string());
5234 }
5235
5236 self.last_written_file = Some(full_path.clone());
5238 self.file_version += 1;
5239
5240 let registry = perspt_core::plugin::PluginRegistry::new();
5242 for (lang, client) in self.lsp_clients.iter_mut() {
5243 let should_notify = match registry.get(lang) {
5245 Some(plugin) => plugin.owns_file(op.path()),
5246 None => true,
5247 };
5248 if should_notify {
5249 if let Ok(content) = std::fs::read_to_string(&full_path) {
5250 let _ = client
5251 .did_change(&full_path, &content, self.file_version)
5252 .await;
5253 }
5254 }
5255 }
5256
5257 log::info!("✓ Applied: {}", op.path());
5258 self.emit_log(format!("✅ Applied: {}", op.path()));
5259 } else {
5260 log::warn!("Failed to apply {}: {:?}", op.path(), result.error);
5261 self.emit_log(format!("❌ Failed: {} - {:?}", op.path(), result.error));
5262 self.last_tool_failure = result.error.clone();
5263 return Err(anyhow::anyhow!(
5264 "Bundle application failed at {}: {:?}",
5265 op.path(),
5266 result.error
5267 ));
5268 }
5269 }
5270
5271 self.context.ownership_manifest.assign_new_paths(
5273 &bundle,
5274 node_id,
5275 &owner_plugin,
5276 node_class,
5277 );
5278
5279 self.emit_event(perspt_core::AgentEvent::BundleApplied {
5281 node_id: node_id.to_string(),
5282 files_created,
5283 files_modified,
5284 writes_count: bundle.writes_count(),
5285 diffs_count: bundle.diffs_count(),
5286 node_class: node_class.to_string(),
5287 });
5288
5289 self.last_tool_failure = None;
5290 Ok(())
5291 }
5292
5293 fn create_deterministic_fallback_graph(&mut self, task: &str) -> Result<()> {
5298 log::warn!("Using deterministic fallback graph (PSP-5)");
5299 self.emit_log("📦 Using deterministic fallback plan");
5300
5301 self.emit_event(perspt_core::AgentEvent::FallbackPlanner {
5303 reason: "Architect failed to produce valid JSON plan".to_string(),
5304 });
5305
5306 let lang = self.detect_language_from_task(task).unwrap_or("python");
5308 let ext = match lang {
5309 "rust" => "rs",
5310 "javascript" => "js",
5311 _ => "py",
5312 };
5313
5314 let (main_file, test_file) = match lang {
5316 "rust" => (
5317 "src/main.rs".to_string(),
5318 "tests/integration_test.rs".to_string(),
5319 ),
5320 "javascript" => ("index.js".to_string(), "test/index.test.js".to_string()),
5321 _ => ("main.py".to_string(), format!("tests/test_main.{}", ext)),
5322 };
5323
5324 let scaffold_task = perspt_core::types::PlannedTask {
5326 id: "scaffold".to_string(),
5327 goal: format!("Set up project structure for: {}", task),
5328 context_files: vec![],
5329 output_files: vec![main_file.clone()],
5330 dependencies: vec![],
5331 task_type: perspt_core::types::TaskType::Code,
5332 contract: Default::default(),
5333 command_contract: None,
5334 node_class: perspt_core::types::NodeClass::Interface,
5335 };
5336
5337 let impl_task = perspt_core::types::PlannedTask {
5339 id: "implement".to_string(),
5340 goal: format!("Implement core logic for: {}", task),
5341 context_files: vec![main_file.clone()],
5342 output_files: vec![main_file],
5343 dependencies: vec!["scaffold".to_string()],
5344 task_type: perspt_core::types::TaskType::Code,
5345 contract: Default::default(),
5346 command_contract: None,
5347 node_class: perspt_core::types::NodeClass::Implementation,
5348 };
5349
5350 let test_task = perspt_core::types::PlannedTask {
5352 id: "test".to_string(),
5353 goal: format!("Write tests for: {}", task),
5354 context_files: vec![],
5355 output_files: vec![test_file],
5356 dependencies: vec!["implement".to_string()],
5357 task_type: perspt_core::types::TaskType::UnitTest,
5358 contract: Default::default(),
5359 command_contract: None,
5360 node_class: perspt_core::types::NodeClass::Integration,
5361 };
5362
5363 let mut plan = perspt_core::types::TaskPlan::new();
5365 plan.tasks.push(scaffold_task);
5366 plan.tasks.push(impl_task);
5367 plan.tasks.push(test_task);
5368
5369 self.emit_event(perspt_core::AgentEvent::PlanGenerated(plan.clone()));
5370 self.create_nodes_from_plan(&plan)?;
5371
5372 Ok(())
5373 }
5374
5375 fn filter_bundle_to_declared_paths(
5381 &self,
5382 bundle: &perspt_core::types::ArtifactBundle,
5383 node_id: &str,
5384 ) -> perspt_core::types::ArtifactBundle {
5385 let allowed_paths: std::collections::HashSet<String> = self
5386 .node_indices
5387 .get(node_id)
5388 .map(|idx| {
5389 self.graph[*idx]
5390 .output_targets
5391 .iter()
5392 .map(|p| p.to_string_lossy().to_string())
5393 .collect()
5394 })
5395 .unwrap_or_default();
5396
5397 if allowed_paths.is_empty() {
5398 return bundle.clone();
5399 }
5400
5401 let (kept, dropped): (Vec<_>, Vec<_>) = bundle
5402 .artifacts
5403 .iter()
5404 .cloned()
5405 .partition(|a| allowed_paths.contains(a.path()));
5406
5407 if !dropped.is_empty() {
5408 let dropped_paths: Vec<String> = dropped.iter().map(|a| a.path().to_string()).collect();
5409 log::warn!(
5410 "Stripped {} undeclared artifact(s) from node '{}': {}",
5411 dropped.len(),
5412 node_id,
5413 dropped_paths.join(", ")
5414 );
5415 self.emit_log(format!(
5416 "⚠️ Stripped {} undeclared path(s) from bundle: {}",
5417 dropped.len(),
5418 dropped_paths.join(", ")
5419 ));
5420 }
5421
5422 perspt_core::types::ArtifactBundle {
5423 artifacts: kept,
5424 commands: bundle.commands.clone(),
5425 }
5426 }
5427
5428 pub async fn run_plugin_verification(
5438 &mut self,
5439 plugin_name: &str,
5440 allowed_stages: &[perspt_core::plugin::VerifierStage],
5441 working_dir: std::path::PathBuf,
5442 ) -> perspt_core::types::VerificationResult {
5443 use perspt_core::plugin::VerifierStage;
5444 use perspt_core::types::{SensorStatus, StageOutcome};
5445
5446 let registry = perspt_core::plugin::PluginRegistry::new();
5447 let plugin = match registry.get(plugin_name) {
5448 Some(p) => p,
5449 None => {
5450 return perspt_core::types::VerificationResult::degraded(format!(
5451 "Plugin '{}' not found",
5452 plugin_name
5453 ));
5454 }
5455 };
5456
5457 let profile = plugin.verifier_profile();
5458
5459 if profile.fully_degraded() {
5461 return perspt_core::types::VerificationResult::degraded(format!(
5462 "{} toolchain not available on host (all stages degraded)",
5463 plugin.name()
5464 ));
5465 }
5466
5467 let sensor_status_for = |stage: VerifierStage,
5469 profile: &perspt_core::plugin::VerifierProfile|
5470 -> SensorStatus {
5471 match profile.get(stage) {
5472 Some(cap) if cap.available => SensorStatus::Available,
5473 Some(cap) if cap.fallback_available => SensorStatus::Fallback {
5474 actual: cap
5475 .fallback_command
5476 .clone()
5477 .unwrap_or_else(|| "fallback".into()),
5478 reason: format!(
5479 "primary '{}' not found",
5480 cap.command.as_deref().unwrap_or("?")
5481 ),
5482 },
5483 Some(cap) => SensorStatus::Unavailable {
5484 reason: format!(
5485 "no tool for {} (tried '{}')",
5486 stage,
5487 cap.command.as_deref().unwrap_or("?")
5488 ),
5489 },
5490 None => SensorStatus::Unavailable {
5491 reason: format!("{} stage not declared by plugin", stage),
5492 },
5493 }
5494 };
5495
5496 let syn_sensor = sensor_status_for(VerifierStage::SyntaxCheck, &profile);
5497 let build_sensor = sensor_status_for(VerifierStage::Build, &profile);
5498 let test_sensor = sensor_status_for(VerifierStage::Test, &profile);
5499 let lint_sensor = sensor_status_for(VerifierStage::Lint, &profile);
5500
5501 let runner = test_runner::test_runner_for_profile(profile, working_dir);
5502
5503 let mut result = perspt_core::types::VerificationResult::default();
5504
5505 if allowed_stages.contains(&VerifierStage::SyntaxCheck) {
5511 match runner.run_syntax_check().await {
5512 Ok(r) => {
5513 result.syntax_ok = r.passed > 0 && r.failed == 0;
5514 if !result.syntax_ok && r.run_succeeded {
5515 result.diagnostics_count = r.output.lines().count();
5516 result.raw_output = Some(r.output.clone());
5517 self.emit_log(format!(
5518 "⚠️ Syntax check failed ({} diagnostics)",
5519 result.diagnostics_count
5520 ));
5521 } else if result.syntax_ok {
5522 self.emit_log("✅ Syntax check passed".to_string());
5523 }
5524 result.stage_outcomes.push(StageOutcome {
5525 stage: VerifierStage::SyntaxCheck.to_string(),
5526 passed: result.syntax_ok,
5527 sensor_status: syn_sensor,
5528 output: Some(r.output),
5529 });
5530 }
5531 Err(e) => {
5532 log::warn!("Syntax check failed to run: {}", e);
5533 result.syntax_ok = false;
5534 result.stage_outcomes.push(StageOutcome {
5535 stage: VerifierStage::SyntaxCheck.to_string(),
5536 passed: false,
5537 sensor_status: SensorStatus::Unavailable {
5538 reason: format!("execution error: {}", e),
5539 },
5540 output: None,
5541 });
5542 }
5543 }
5544
5545 if !result.syntax_ok {
5547 self.emit_log("⏭️ Skipping build/test/lint — syntax check failed".to_string());
5548 result.build_ok = false;
5549 result.tests_ok = false;
5550 self.finalize_verification_result(&mut result, plugin_name);
5551 return result;
5552 }
5553 }
5554
5555 if allowed_stages.contains(&VerifierStage::Build) {
5557 match runner.run_build_check().await {
5558 Ok(r) => {
5559 result.build_ok = r.passed > 0 && r.failed == 0;
5560 if result.build_ok {
5561 self.emit_log("✅ Build passed".to_string());
5562 } else if r.run_succeeded {
5563 self.emit_log("⚠️ Build failed".to_string());
5564 result.raw_output = Some(r.output.clone());
5565 }
5566 result.stage_outcomes.push(StageOutcome {
5567 stage: VerifierStage::Build.to_string(),
5568 passed: result.build_ok,
5569 sensor_status: build_sensor,
5570 output: Some(r.output),
5571 });
5572 }
5573 Err(e) => {
5574 log::warn!("Build check failed to run: {}", e);
5575 result.build_ok = false;
5576 result.stage_outcomes.push(StageOutcome {
5577 stage: VerifierStage::Build.to_string(),
5578 passed: false,
5579 sensor_status: SensorStatus::Unavailable {
5580 reason: format!("execution error: {}", e),
5581 },
5582 output: None,
5583 });
5584 }
5585 }
5586
5587 if !result.build_ok {
5589 self.emit_log("⏭️ Skipping test/lint — build failed".to_string());
5590 result.tests_ok = false;
5591 self.finalize_verification_result(&mut result, plugin_name);
5592 return result;
5593 }
5594 }
5595
5596 if allowed_stages.contains(&VerifierStage::Test) {
5598 match runner.run_tests().await {
5599 Ok(r) => {
5600 result.tests_ok = r.all_passed();
5601 result.tests_passed = r.passed;
5602 result.tests_failed = r.failed;
5603
5604 if result.tests_ok {
5605 self.emit_log(format!("✅ Tests passed ({})", plugin_name));
5606 } else {
5607 self.emit_log(format!("❌ Tests failed ({})", plugin_name));
5608 result.raw_output = Some(r.output.clone());
5609 }
5610 result.stage_outcomes.push(StageOutcome {
5611 stage: VerifierStage::Test.to_string(),
5612 passed: result.tests_ok,
5613 sensor_status: test_sensor,
5614 output: Some(r.output),
5615 });
5616 }
5617 Err(e) => {
5618 log::warn!("Test command failed to run: {}", e);
5619 result.tests_ok = false;
5620 result.stage_outcomes.push(StageOutcome {
5621 stage: VerifierStage::Test.to_string(),
5622 passed: false,
5623 sensor_status: SensorStatus::Unavailable {
5624 reason: format!("execution error: {}", e),
5625 },
5626 output: None,
5627 });
5628 }
5629 }
5630 }
5631
5632 if allowed_stages.contains(&VerifierStage::Lint)
5634 && self.context.verifier_strictness == perspt_core::types::VerifierStrictness::Strict
5635 {
5636 match runner.run_lint().await {
5637 Ok(r) => {
5638 result.lint_ok = r.passed > 0 && r.failed == 0;
5639 if result.lint_ok {
5640 self.emit_log("✅ Lint passed".to_string());
5641 } else if r.run_succeeded {
5642 self.emit_log("⚠️ Lint issues found".to_string());
5643 }
5644 result.stage_outcomes.push(StageOutcome {
5645 stage: VerifierStage::Lint.to_string(),
5646 passed: result.lint_ok,
5647 sensor_status: lint_sensor,
5648 output: Some(r.output),
5649 });
5650 }
5651 Err(e) => {
5652 log::warn!("Lint command failed to run: {}", e);
5653 result.lint_ok = false;
5654 result.stage_outcomes.push(StageOutcome {
5655 stage: VerifierStage::Lint.to_string(),
5656 passed: false,
5657 sensor_status: SensorStatus::Unavailable {
5658 reason: format!("execution error: {}", e),
5659 },
5660 output: None,
5661 });
5662 }
5663 }
5664 } else if !allowed_stages.contains(&VerifierStage::Lint) {
5665 result.lint_ok = true; } else {
5667 result.lint_ok = true; }
5669
5670 self.finalize_verification_result(&mut result, plugin_name);
5671 result
5672 }
5673
5674 fn extract_missing_crates(output: &str) -> Vec<String> {
5684 use std::collections::HashSet;
5685
5686 let mut crates: HashSet<String> = HashSet::new();
5687
5688 for line in output.lines() {
5689 let lower = line.to_lowercase();
5690
5691 if lower.contains("undeclared crate or module") {
5693 if let Some(name) = Self::extract_backtick_ident(line) {
5694 if !name.contains("::") {
5695 crates.insert(name);
5696 }
5697 }
5698 }
5699 else if lower.contains("can't find crate for")
5701 || lower.contains("cant find crate for")
5702 {
5703 if let Some(name) = Self::extract_backtick_ident(line) {
5704 crates.insert(name);
5705 }
5706 }
5707 else if lower.contains("unresolved import") {
5709 if let Some(name) = Self::extract_backtick_ident(line) {
5710 let root = name.split("::").next().unwrap_or(&name).to_string();
5711 if root != "crate" && root != "self" && root != "super" {
5712 crates.insert(root);
5713 }
5714 }
5715 }
5716 }
5717
5718 let builtins: HashSet<&str> = ["std", "core", "alloc", "proc_macro", "test"]
5719 .iter()
5720 .copied()
5721 .collect();
5722
5723 crates
5724 .into_iter()
5725 .filter(|c| !builtins.contains(c.as_str()))
5726 .collect()
5727 }
5728
5729 fn extract_backtick_ident(line: &str) -> Option<String> {
5731 let start = line.find('`')? + 1;
5732 let rest = &line[start..];
5733 let end = rest.find('`')?;
5734 let ident = &rest[..end];
5735 if ident.is_empty() {
5736 None
5737 } else {
5738 Some(ident.to_string())
5739 }
5740 }
5741
5742 fn extract_commands_from_correction(response: &str) -> Vec<String> {
5744 let mut commands = Vec::new();
5745 let mut in_commands_section = false;
5746 let mut in_code_block = false;
5747
5748 let allowed_prefixes = [
5749 "cargo add",
5750 "pip install",
5751 "pip3 install",
5752 "uv add",
5753 "uv pip install",
5754 "npm install",
5755 "yarn add",
5756 "pnpm add",
5757 ];
5758
5759 for line in response.lines() {
5760 let trimmed = line.trim();
5761
5762 if trimmed.starts_with("Commands:")
5763 || trimmed.starts_with("**Commands:")
5764 || trimmed.starts_with("### Commands")
5765 {
5766 in_commands_section = true;
5767 continue;
5768 }
5769
5770 if in_commands_section {
5771 if trimmed.starts_with("```") {
5772 in_code_block = !in_code_block;
5773 continue;
5774 }
5775
5776 if !in_code_block
5777 && (trimmed.is_empty()
5778 || trimmed.starts_with('#')
5779 || trimmed.starts_with("File:")
5780 || trimmed.starts_with("Diff:"))
5781 {
5782 in_commands_section = false;
5783 continue;
5784 }
5785
5786 let cmd = trimmed
5787 .trim_start_matches("- ")
5788 .trim_start_matches("$ ")
5789 .trim();
5790
5791 if !cmd.is_empty() && allowed_prefixes.iter().any(|p| cmd.starts_with(p)) {
5792 commands.push(cmd.to_string());
5793 }
5794 }
5795 }
5796
5797 commands
5798 }
5799
5800 async fn auto_install_crate_deps(crates: &[String], working_dir: &std::path::Path) -> usize {
5802 let mut installed = 0usize;
5803 for krate in crates {
5804 log::info!("Auto-installing crate: cargo add {}", krate);
5805 let result = tokio::process::Command::new("cargo")
5806 .args(["add", krate])
5807 .current_dir(working_dir)
5808 .stdout(std::process::Stdio::piped())
5809 .stderr(std::process::Stdio::piped())
5810 .output()
5811 .await;
5812
5813 match result {
5814 Ok(output) if output.status.success() => {
5815 log::info!("Successfully installed crate: {}", krate);
5816 installed += 1;
5817 }
5818 Ok(output) => {
5819 let stderr = String::from_utf8_lossy(&output.stderr);
5820 log::warn!("Failed to install crate {}: {}", krate, stderr);
5821 }
5822 Err(e) => {
5823 log::warn!("Failed to run cargo add {}: {}", krate, e);
5824 }
5825 }
5826 }
5827 installed
5828 }
5829
5830 fn extract_missing_python_modules(output: &str) -> Vec<String> {
5841 use std::collections::HashSet;
5842
5843 let mut modules: HashSet<String> = HashSet::new();
5844
5845 for line in output.lines() {
5846 let trimmed = line.trim().trim_start_matches("E").trim();
5847
5848 if trimmed.contains("ModuleNotFoundError: No module named ") {
5852 if let Some(pos) = trimmed.find("No module named ") {
5854 let after = &trimmed[pos + "No module named ".len()..];
5855 let name = after.trim().trim_matches('\'').trim_matches('"');
5856 let root = name.split('.').next().unwrap_or(name);
5857 if !root.is_empty() {
5858 modules.insert(root.to_string());
5859 }
5860 }
5861 }
5862 else if trimmed.contains("ImportError") && trimmed.contains("No module named") {
5865 if let Some(start) = trimmed.find('\'') {
5866 let rest = &trimmed[start + 1..];
5867 if let Some(end) = rest.find('\'') {
5868 let name = &rest[..end];
5869 let root = name.split('.').next().unwrap_or(name);
5870 if !root.is_empty() {
5871 modules.insert(root.to_string());
5872 }
5873 }
5874 }
5875 }
5876 }
5877
5878 let stdlib: HashSet<&str> = [
5880 "os",
5881 "sys",
5882 "json",
5883 "re",
5884 "math",
5885 "datetime",
5886 "collections",
5887 "itertools",
5888 "functools",
5889 "pathlib",
5890 "typing",
5891 "abc",
5892 "io",
5893 "unittest",
5894 "logging",
5895 "argparse",
5896 "sqlite3",
5897 "csv",
5898 "hashlib",
5899 "tempfile",
5900 "shutil",
5901 "copy",
5902 "contextlib",
5903 "dataclasses",
5904 "enum",
5905 "textwrap",
5906 "importlib",
5907 "inspect",
5908 "traceback",
5909 "subprocess",
5910 "threading",
5911 "multiprocessing",
5912 "asyncio",
5913 "socket",
5914 "http",
5915 "urllib",
5916 "xml",
5917 "html",
5918 "email",
5919 "string",
5920 "struct",
5921 "array",
5922 "queue",
5923 "heapq",
5924 "bisect",
5925 "pprint",
5926 "decimal",
5927 "fractions",
5928 "random",
5929 "secrets",
5930 "time",
5931 "calendar",
5932 "zlib",
5933 "gzip",
5934 "zipfile",
5935 "tarfile",
5936 "glob",
5937 "fnmatch",
5938 "stat",
5939 "fileinput",
5940 "codecs",
5941 "uuid",
5942 "base64",
5943 "binascii",
5944 "pickle",
5945 "shelve",
5946 "dbm",
5947 "platform",
5948 "signal",
5949 "mmap",
5950 "ctypes",
5951 "configparser",
5952 "tomllib",
5953 "warnings",
5954 "weakref",
5955 "types",
5956 "operator",
5957 "numbers",
5958 "__future__",
5959 ]
5960 .iter()
5961 .copied()
5962 .collect();
5963
5964 modules
5965 .into_iter()
5966 .filter(|m| !stdlib.contains(m.as_str()))
5967 .collect()
5968 }
5969
5970 fn python_import_to_package(import_name: &str) -> &str {
5975 match import_name {
5976 "PIL" | "pil" => "pillow",
5977 "cv2" => "opencv-python",
5978 "yaml" => "pyyaml",
5979 "bs4" => "beautifulsoup4",
5980 "sklearn" => "scikit-learn",
5981 "attr" | "attrs" => "attrs",
5982 "dateutil" => "python-dateutil",
5983 "dotenv" => "python-dotenv",
5984 "gi" => "PyGObject",
5985 "serial" => "pyserial",
5986 "usb" => "pyusb",
5987 "wx" => "wxPython",
5988 "lxml" => "lxml",
5989 "Crypto" => "pycryptodome",
5990 "jose" => "python-jose",
5991 "jwt" => "PyJWT",
5992 "magic" => "python-magic",
5993 "docx" => "python-docx",
5994 "pptx" => "python-pptx",
5995 "git" => "gitpython",
5996 "psycopg2" => "psycopg2-binary",
5997 other => other,
5998 }
5999 }
6000
6001 async fn auto_install_python_deps(modules: &[String], working_dir: &std::path::Path) -> usize {
6003 let mut installed = 0usize;
6004 for module in modules {
6005 let package = Self::python_import_to_package(module);
6006 log::info!("Auto-installing Python package: uv add {}", package);
6007 let result = tokio::process::Command::new("uv")
6008 .args(["add", package])
6009 .current_dir(working_dir)
6010 .stdout(std::process::Stdio::piped())
6011 .stderr(std::process::Stdio::piped())
6012 .output()
6013 .await;
6014
6015 match result {
6016 Ok(output) if output.status.success() => {
6017 log::info!("Successfully installed Python package: {}", package);
6018 installed += 1;
6019 }
6020 Ok(output) => {
6021 let stderr = String::from_utf8_lossy(&output.stderr);
6022 log::warn!("Failed to install Python package {}: {}", package, stderr);
6023 }
6024 Err(e) => {
6025 log::warn!("Failed to run uv add {}: {}", package, e);
6026 }
6027 }
6028 }
6029
6030 if installed > 0 {
6032 log::info!("Running uv sync --dev after dependency install...");
6033 let _ = tokio::process::Command::new("uv")
6034 .args(["sync", "--dev"])
6035 .current_dir(working_dir)
6036 .stdout(std::process::Stdio::piped())
6037 .stderr(std::process::Stdio::piped())
6038 .output()
6039 .await;
6040 }
6041
6042 installed
6043 }
6044
6045 fn normalize_command_to_uv(command: &str) -> String {
6050 let trimmed = command.trim();
6051
6052 let pip_install_prefixes = [
6057 "pip install ",
6058 "pip3 install ",
6059 "python -m pip install ",
6060 "python3 -m pip install ",
6061 ];
6062 for prefix in &pip_install_prefixes {
6063 if let Some(rest) = trimmed.strip_prefix(prefix) {
6064 let packages = rest.trim();
6065 if packages.is_empty() {
6066 return command.to_string();
6067 }
6068 if packages.starts_with("-r ") || packages.starts_with("--requirement ") {
6070 return format!("uv pip install {}", packages);
6071 }
6072 return format!("uv add {}", packages);
6073 }
6074 }
6075
6076 if trimmed.starts_with("pip install -") || trimmed.starts_with("pip3 install -") {
6078 return format!("uv {}", trimmed);
6079 }
6080
6081 command.to_string()
6082 }
6083
6084 fn finalize_verification_result(
6086 &mut self,
6087 result: &mut perspt_core::types::VerificationResult,
6088 plugin_name: &str,
6089 ) {
6090 if result.has_degraded_stages() {
6091 result.degraded = true;
6092 let reasons = result.degraded_stage_reasons();
6093 result.degraded_reason = Some(reasons.join("; "));
6094
6095 for outcome in &result.stage_outcomes {
6097 if let perspt_core::types::SensorStatus::Fallback { actual, reason } =
6098 &outcome.sensor_status
6099 {
6100 self.emit_event(perspt_core::AgentEvent::SensorFallback {
6101 node_id: plugin_name.to_string(),
6102 stage: outcome.stage.clone(),
6103 primary: reason.clone(),
6104 actual: actual.clone(),
6105 reason: reason.clone(),
6106 });
6107 }
6108 }
6109 }
6110
6111 self.last_verification_result = Some(result.clone());
6113
6114 result.summary = format!(
6116 "{}: syntax={}, build={}, tests={}, lint={}{}",
6117 plugin_name,
6118 if result.syntax_ok { "✅" } else { "❌" },
6119 if result.build_ok { "✅" } else { "❌" },
6120 if result.tests_ok { "✅" } else { "❌" },
6121 if result.lint_ok { "✅" } else { "⏭️" },
6122 if result.degraded { " (degraded)" } else { "" },
6123 );
6124 }
6125
6126 fn sandbox_dir_for_node(&self, idx: NodeIndex) -> Option<std::path::PathBuf> {
6133 let branch_id = self.graph[idx].provisional_branch_id.as_ref()?;
6134 let sandbox_path = self
6135 .context
6136 .working_dir
6137 .join(".perspt")
6138 .join("sandboxes")
6139 .join(&self.context.session_id)
6140 .join(branch_id);
6141 if sandbox_path.exists() {
6142 Some(sandbox_path)
6143 } else {
6144 None
6145 }
6146 }
6147
6148 fn effective_working_dir(&self, idx: NodeIndex) -> std::path::PathBuf {
6151 self.sandbox_dir_for_node(idx)
6152 .unwrap_or_else(|| self.context.working_dir.clone())
6153 }
6154
6155 fn maybe_create_provisional_branch(&mut self, idx: NodeIndex) -> Option<String> {
6158 let parents: Vec<NodeIndex> = self
6160 .graph
6161 .neighbors_directed(idx, petgraph::Direction::Incoming)
6162 .collect();
6163
6164 if parents.is_empty() {
6165 return None; }
6167
6168 let node = &self.graph[idx];
6169 let node_id = node.node_id.clone();
6170 let session_id = self.context.session_id.clone();
6171
6172 let parent_idx = parents[0];
6176 let parent_node_id = self.graph[parent_idx].node_id.clone();
6177
6178 let branch_id = format!("branch_{}_{}", node_id, uuid::Uuid::new_v4());
6179 let branch = ProvisionalBranch::new(
6180 branch_id.clone(),
6181 session_id.clone(),
6182 node_id.clone(),
6183 parent_node_id.clone(),
6184 );
6185
6186 if let Err(e) = self.ledger.record_provisional_branch(&branch) {
6188 log::warn!("Failed to record provisional branch: {}", e);
6189 }
6190
6191 for pidx in &parents {
6193 let parent_id = self.graph[*pidx].node_id.clone();
6194 let depends_on_seal = self.graph[*pidx].node_class == NodeClass::Interface;
6196 let lineage = perspt_core::types::BranchLineage {
6197 lineage_id: format!("lin_{}_{}", branch_id, parent_id),
6198 parent_branch_id: parent_id,
6199 child_branch_id: branch_id.clone(),
6200 depends_on_seal,
6201 };
6202 if let Err(e) = self.ledger.record_branch_lineage(&lineage) {
6203 log::warn!("Failed to record branch lineage: {}", e);
6204 }
6205 }
6206
6207 self.graph[idx].provisional_branch_id = Some(branch_id.clone());
6209
6210 match crate::tools::create_sandbox(&self.context.working_dir, &session_id, &branch_id) {
6213 Ok(sandbox_path) => {
6214 log::debug!("Sandbox created at {}", sandbox_path.display());
6215 let node = &self.graph[idx];
6218 for target in &node.output_targets {
6219 if let Some(rel) = target.to_str() {
6220 if let Err(e) = crate::tools::copy_to_sandbox(
6221 &self.context.working_dir,
6222 &sandbox_path,
6223 rel,
6224 ) {
6225 log::debug!("Could not seed sandbox with {}: {}", rel, e);
6226 }
6227 }
6228 }
6229 for pidx in &parents {
6231 for target in &self.graph[*pidx].output_targets {
6232 if let Some(rel) = target.to_str() {
6233 if let Err(e) = crate::tools::copy_to_sandbox(
6234 &self.context.working_dir,
6235 &sandbox_path,
6236 rel,
6237 ) {
6238 log::debug!(
6239 "Could not seed sandbox with parent file {}: {}",
6240 rel,
6241 e
6242 );
6243 }
6244 }
6245 }
6246 }
6247 }
6248 Err(e) => {
6249 log::warn!("Failed to create sandbox for branch {}: {}", branch_id, e);
6250 }
6251 }
6252
6253 self.emit_event(perspt_core::AgentEvent::BranchCreated {
6254 branch_id: branch_id.clone(),
6255 node_id,
6256 parent_node_id,
6257 });
6258 log::info!("Created provisional branch {} for node", branch_id);
6259
6260 Some(branch_id)
6261 }
6262
6263 fn merge_provisional_branch(&mut self, branch_id: &str, idx: NodeIndex) {
6265 let node_id = self.graph[idx].node_id.clone();
6266 if let Err(e) = self
6267 .ledger
6268 .update_branch_state(branch_id, &ProvisionalBranchState::Merged.to_string())
6269 {
6270 log::warn!("Failed to merge branch {}: {}", branch_id, e);
6271 }
6272
6273 let sandbox_path = self
6275 .context
6276 .working_dir
6277 .join(".perspt")
6278 .join("sandboxes")
6279 .join(&self.context.session_id)
6280 .join(branch_id);
6281 if let Err(e) = crate::tools::cleanup_sandbox(&sandbox_path) {
6282 log::warn!(
6283 "Failed to cleanup sandbox for merged branch {}: {}",
6284 branch_id,
6285 e
6286 );
6287 }
6288
6289 self.emit_event(perspt_core::AgentEvent::BranchMerged {
6290 branch_id: branch_id.to_string(),
6291 node_id,
6292 });
6293 log::info!("Merged provisional branch {}", branch_id);
6294 }
6295
6296 fn flush_provisional_branch(&mut self, branch_id: &str, node_id: &str) {
6298 if let Err(e) = self
6299 .ledger
6300 .update_branch_state(branch_id, &ProvisionalBranchState::Flushed.to_string())
6301 {
6302 log::warn!("Failed to flush branch {}: {}", branch_id, e);
6303 }
6304
6305 let sandbox_path = self
6307 .context
6308 .working_dir
6309 .join(".perspt")
6310 .join("sandboxes")
6311 .join(&self.context.session_id)
6312 .join(branch_id);
6313 if let Err(e) = crate::tools::cleanup_sandbox(&sandbox_path) {
6314 log::warn!(
6315 "Failed to cleanup sandbox for flushed branch {}: {}",
6316 branch_id,
6317 e
6318 );
6319 }
6320
6321 log::info!(
6322 "Flushed provisional branch {} for node {}",
6323 branch_id,
6324 node_id
6325 );
6326 }
6327
6328 fn flush_descendant_branches(&mut self, idx: NodeIndex) {
6334 let parent_node_id = self.graph[idx].node_id.clone();
6335 let session_id = self.context.session_id.clone();
6336
6337 let descendant_indices = self.collect_descendants(idx);
6339
6340 let mut flushed_branch_ids = Vec::new();
6341 let mut requeue_node_ids = Vec::new();
6342
6343 for desc_idx in &descendant_indices {
6344 let desc_node = &self.graph[*desc_idx];
6345 if let Some(ref bid) = desc_node.provisional_branch_id {
6346 let bid_clone = bid.clone();
6348 let nid_clone = desc_node.node_id.clone();
6349 self.flush_provisional_branch(&bid_clone, &nid_clone);
6350 flushed_branch_ids.push(bid_clone);
6351 requeue_node_ids.push(nid_clone);
6352 }
6353 }
6354
6355 if flushed_branch_ids.is_empty() {
6356 return;
6357 }
6358
6359 let flush_record = perspt_core::types::BranchFlushRecord::new(
6361 &session_id,
6362 &parent_node_id,
6363 flushed_branch_ids.clone(),
6364 requeue_node_ids.clone(),
6365 format!(
6366 "Parent node {} failed verification/convergence",
6367 parent_node_id
6368 ),
6369 );
6370 if let Err(e) = self.ledger.record_branch_flush(&flush_record) {
6371 log::warn!("Failed to record branch flush: {}", e);
6372 }
6373
6374 self.emit_event(perspt_core::AgentEvent::BranchFlushed {
6375 parent_node_id: parent_node_id.clone(),
6376 flushed_branch_ids,
6377 reason: format!("Parent {} failed", parent_node_id),
6378 });
6379
6380 log::info!(
6381 "Flushed {} descendant branches for parent {}; {} nodes eligible for requeue",
6382 flush_record.flushed_branch_ids.len(),
6383 parent_node_id,
6384 requeue_node_ids.len(),
6385 );
6386 }
6387
6388 fn collect_descendants(&self, idx: NodeIndex) -> Vec<NodeIndex> {
6391 let mut descendants = Vec::new();
6392 let mut stack = vec![idx];
6393 let mut visited = std::collections::HashSet::new();
6394 visited.insert(idx);
6395
6396 while let Some(current) = stack.pop() {
6397 for child in self
6398 .graph
6399 .neighbors_directed(current, petgraph::Direction::Outgoing)
6400 {
6401 if visited.insert(child) {
6402 descendants.push(child);
6403 stack.push(child);
6404 }
6405 }
6406 }
6407 descendants
6408 }
6409
6410 fn emit_interface_seals(&mut self, idx: NodeIndex) {
6416 let node = &self.graph[idx];
6417 if node.node_class != NodeClass::Interface {
6418 return;
6419 }
6420
6421 let node_id = node.node_id.clone();
6422 let session_id = self.context.session_id.clone();
6423 let output_targets: Vec<_> = node.output_targets.clone();
6424 let mut sealed_paths = Vec::new();
6425 let mut seal_hash = [0u8; 32];
6426
6427 let retriever = ContextRetriever::new(self.context.working_dir.clone());
6428
6429 for target in &output_targets {
6430 let path_str = target.to_string_lossy().to_string();
6431 match retriever.compute_structural_digest(
6432 &path_str,
6433 perspt_core::types::ArtifactKind::InterfaceSeal,
6434 &node_id,
6435 ) {
6436 Ok(digest) => {
6437 let seal = perspt_core::types::InterfaceSealRecord::from_digest(
6438 &session_id,
6439 &node_id,
6440 &digest,
6441 );
6442 seal_hash = seal.seal_hash;
6443 sealed_paths.push(path_str);
6444
6445 if let Err(e) = self.ledger.record_interface_seal(&seal) {
6446 log::warn!("Failed to record interface seal: {}", e);
6447 }
6448 }
6449 Err(e) => {
6450 log::debug!("Skipping seal for {}: {}", path_str, e);
6451 }
6452 }
6453 }
6454
6455 if !sealed_paths.is_empty() {
6456 self.graph[idx].interface_seal_hash = Some(seal_hash);
6458
6459 self.emit_event(perspt_core::AgentEvent::InterfaceSealed {
6460 node_id: node_id.clone(),
6461 sealed_paths: sealed_paths.clone(),
6462 seal_hash: seal_hash
6463 .iter()
6464 .map(|b| format!("{:02x}", b))
6465 .collect::<String>(),
6466 });
6467 log::info!(
6468 "Sealed {} interface artifact(s) for node {}",
6469 sealed_paths.len(),
6470 node_id
6471 );
6472 }
6473 }
6474
6475 fn unblock_dependents(&mut self, idx: NodeIndex) {
6477 let node_id = self.graph[idx].node_id.clone();
6478
6479 let (unblocked, remaining): (Vec<_>, Vec<_>) = self
6481 .blocked_dependencies
6482 .drain(..)
6483 .partition(|dep| dep.parent_node_id == node_id);
6484
6485 self.blocked_dependencies = remaining;
6486
6487 for dep in unblocked {
6488 self.emit_event(perspt_core::AgentEvent::DependentUnblocked {
6489 child_node_id: dep.child_node_id.clone(),
6490 parent_node_id: node_id.clone(),
6491 });
6492 log::info!(
6493 "Unblocked dependent {} (parent {} sealed)",
6494 dep.child_node_id,
6495 node_id
6496 );
6497 }
6498 }
6499
6500 fn check_seal_prerequisites(&mut self, idx: NodeIndex) -> bool {
6503 let parents: Vec<NodeIndex> = self
6504 .graph
6505 .neighbors_directed(idx, petgraph::Direction::Incoming)
6506 .collect();
6507
6508 for pidx in parents {
6509 let parent = &self.graph[pidx];
6510 if parent.node_class == NodeClass::Interface
6511 && parent.interface_seal_hash.is_none()
6512 && parent.state != NodeState::Completed
6513 {
6514 let child_node_id = self.graph[idx].node_id.clone();
6516 let parent_node_id = parent.node_id.clone();
6517 let sealed_paths: Vec<String> = parent
6518 .output_targets
6519 .iter()
6520 .map(|p| p.to_string_lossy().to_string())
6521 .collect();
6522
6523 let dep = perspt_core::types::BlockedDependency::new(
6524 &child_node_id,
6525 &parent_node_id,
6526 sealed_paths,
6527 );
6528 self.blocked_dependencies.push(dep);
6529
6530 log::info!(
6531 "Node {} blocked: waiting on interface seal from {}",
6532 child_node_id,
6533 parent_node_id
6534 );
6535 return true;
6536 }
6537 }
6538 false
6539 }
6540
6541 fn check_structural_dependencies(
6547 &self,
6548 node: &SRBNNode,
6549 restriction_map: &perspt_core::types::RestrictionMap,
6550 ) -> Vec<(String, String)> {
6551 use perspt_core::types::{ArtifactKind, NodeClass};
6552
6553 let mut prose_only = Vec::new();
6554
6555 if node.node_class != NodeClass::Implementation {
6557 return prose_only;
6558 }
6559
6560 let idx = match self.node_indices.get(&node.node_id) {
6562 Some(i) => *i,
6563 None => return prose_only,
6564 };
6565
6566 let parents: Vec<NodeIndex> = self
6567 .graph
6568 .neighbors_directed(idx, petgraph::Direction::Incoming)
6569 .collect();
6570
6571 for pidx in parents {
6572 let parent = &self.graph[pidx];
6573 if parent.node_class != NodeClass::Interface {
6574 continue;
6575 }
6576
6577 let has_structural = restriction_map.structural_digests.iter().any(|d| {
6579 d.source_node_id == parent.node_id
6580 && matches!(
6581 d.artifact_kind,
6582 ArtifactKind::Signature
6583 | ArtifactKind::Schema
6584 | ArtifactKind::InterfaceSeal
6585 )
6586 });
6587
6588 if !has_structural {
6589 prose_only.push((
6590 parent.node_id.clone(),
6591 format!(
6592 "Interface node '{}' has no Signature/Schema/InterfaceSeal digest in the restriction map",
6593 parent.node_id
6594 ),
6595 ));
6596 }
6597 }
6598
6599 prose_only
6600 }
6601
6602 fn inject_sealed_interfaces(
6609 &self,
6610 idx: NodeIndex,
6611 restriction_map: &mut perspt_core::types::RestrictionMap,
6612 ) {
6613 let parents: Vec<NodeIndex> = self
6614 .graph
6615 .neighbors_directed(idx, petgraph::Direction::Incoming)
6616 .collect();
6617
6618 for pidx in parents {
6619 let parent = &self.graph[pidx];
6620 if parent.interface_seal_hash.is_none() {
6621 continue;
6622 }
6623
6624 let parent_node_id = &parent.node_id;
6625
6626 let seals = match self.ledger.get_interface_seals(parent_node_id) {
6628 Ok(rows) => rows,
6629 Err(e) => {
6630 log::debug!("Could not query seals for {}: {}", parent_node_id, e);
6631 continue;
6632 }
6633 };
6634
6635 for seal in seals {
6636 restriction_map
6638 .sealed_interfaces
6639 .retain(|p| *p != seal.sealed_path);
6640
6641 let mut hash = [0u8; 32];
6643 let len = seal.seal_hash.len().min(32);
6644 hash[..len].copy_from_slice(&seal.seal_hash[..len]);
6645
6646 let digest = perspt_core::types::StructuralDigest {
6648 digest_id: format!("seal_{}_{}", seal.node_id, seal.sealed_path),
6649 source_node_id: seal.node_id.clone(),
6650 source_path: seal.sealed_path.clone(),
6651 artifact_kind: perspt_core::types::ArtifactKind::InterfaceSeal,
6652 hash,
6653 version: seal.version as u32,
6654 };
6655 restriction_map.structural_digests.push(digest);
6656
6657 log::debug!(
6658 "Injected sealed digest for {} from parent {}",
6659 seal.sealed_path,
6660 parent_node_id,
6661 );
6662 }
6663 }
6664 }
6665}
6666
6667fn severity_to_str(severity: Option<lsp_types::DiagnosticSeverity>) -> &'static str {
6669 match severity {
6670 Some(lsp_types::DiagnosticSeverity::ERROR) => "ERROR",
6671 Some(lsp_types::DiagnosticSeverity::WARNING) => "WARNING",
6672 Some(lsp_types::DiagnosticSeverity::INFORMATION) => "INFO",
6673 Some(lsp_types::DiagnosticSeverity::HINT) => "HINT",
6674 Some(_) => "OTHER",
6675 None => "UNKNOWN",
6676 }
6677}
6678
6679fn verification_stages_for_node(node: &SRBNNode) -> Vec<perspt_core::plugin::VerifierStage> {
6685 use perspt_core::plugin::VerifierStage;
6686 match node.node_class {
6687 perspt_core::types::NodeClass::Interface => {
6688 vec![VerifierStage::SyntaxCheck]
6689 }
6690 perspt_core::types::NodeClass::Implementation => {
6691 let mut stages = vec![VerifierStage::SyntaxCheck, VerifierStage::Build];
6692 if !node.contract.weighted_tests.is_empty() {
6693 stages.push(VerifierStage::Test);
6694 }
6695 stages
6696 }
6697 perspt_core::types::NodeClass::Integration => {
6698 vec![
6699 VerifierStage::SyntaxCheck,
6700 VerifierStage::Build,
6701 VerifierStage::Test,
6702 VerifierStage::Lint,
6703 ]
6704 }
6705 }
6706}
6707
6708fn parse_node_state(s: &str) -> NodeState {
6710 match s {
6711 "TaskQueued" => NodeState::TaskQueued,
6712 "Planning" => NodeState::Planning,
6713 "Coding" => NodeState::Coding,
6714 "Verifying" => NodeState::Verifying,
6715 "Retry" => NodeState::Retry,
6716 "SheafCheck" => NodeState::SheafCheck,
6717 "Committing" => NodeState::Committing,
6718 "Escalated" => NodeState::Escalated,
6719 "Completed" | "COMPLETED" | "STABLE" => NodeState::Completed,
6720 "Failed" | "FAILED" => NodeState::Failed,
6721 "Aborted" | "ABORTED" => NodeState::Aborted,
6722 _ => NodeState::TaskQueued, }
6724}
6725
6726fn parse_node_class(s: &str) -> NodeClass {
6728 match s {
6729 "Interface" => NodeClass::Interface,
6730 "Implementation" => NodeClass::Implementation,
6731 "Integration" => NodeClass::Integration,
6732 _ => NodeClass::default(),
6733 }
6734}
6735
6736#[cfg(test)]
6737mod tests {
6738 use super::*;
6739 use std::path::PathBuf;
6740
6741 #[tokio::test]
6742 async fn test_orchestrator_creation() {
6743 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6744 assert_eq!(orch.node_count(), 0);
6745 }
6746
6747 #[tokio::test]
6748 async fn test_add_nodes() {
6749 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6750
6751 let node1 = SRBNNode::new(
6752 "node1".to_string(),
6753 "Test task 1".to_string(),
6754 ModelTier::Architect,
6755 );
6756 let node2 = SRBNNode::new(
6757 "node2".to_string(),
6758 "Test task 2".to_string(),
6759 ModelTier::Actuator,
6760 );
6761
6762 orch.add_node(node1);
6763 orch.add_node(node2);
6764 orch.add_dependency("node1", "node2", "depends_on").unwrap();
6765
6766 assert_eq!(orch.node_count(), 2);
6767 }
6768 #[tokio::test]
6769 async fn test_lsp_key_for_file_resolves_by_plugin() {
6770 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6771 orch.lsp_clients.insert(
6773 "rust".to_string(),
6774 crate::lsp::LspClient::new("rust-analyzer"),
6775 );
6776 orch.lsp_clients
6777 .insert("python".to_string(), crate::lsp::LspClient::new("pylsp"));
6778
6779 assert_eq!(
6781 orch.lsp_key_for_file("src/main.rs"),
6782 Some("rust".to_string())
6783 );
6784 assert_eq!(orch.lsp_key_for_file("app.py"), Some("python".to_string()));
6786 let key = orch.lsp_key_for_file("data.csv");
6788 assert!(key.is_some()); }
6790
6791 #[tokio::test]
6796 async fn test_split_node_creates_children() {
6797 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6798 let mut node = SRBNNode::new("parent".into(), "Do everything".into(), ModelTier::Actuator);
6799 node.output_targets = vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")];
6800 orch.add_node(node);
6801
6802 let idx = orch.node_indices["parent"];
6803 let applied = orch.split_node(idx, &["handle a.rs".into(), "handle b.rs".into()]);
6804 assert!(!applied.is_empty());
6805 assert!(!orch.node_indices.contains_key("parent"));
6807 assert!(orch.node_indices.contains_key("parent__split_0"));
6809 assert!(orch.node_indices.contains_key("parent__split_1"));
6810 }
6811
6812 #[tokio::test]
6813 async fn test_split_node_empty_children_is_noop() {
6814 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6815 let node = SRBNNode::new("n".into(), "g".into(), ModelTier::Actuator);
6816 orch.add_node(node);
6817 let idx = orch.node_indices["n"];
6818 let applied = orch.split_node(idx, &[]);
6819 assert!(applied.is_empty());
6821 }
6822
6823 #[tokio::test]
6824 async fn test_insert_interface_node() {
6825 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6826 let n1 = SRBNNode::new("a".into(), "source".into(), ModelTier::Actuator);
6827 let n2 = SRBNNode::new("b".into(), "dest".into(), ModelTier::Actuator);
6828 orch.add_node(n1);
6829 orch.add_node(n2);
6830 orch.add_dependency("a", "b", "data_flow").unwrap();
6831
6832 let idx_a = orch.node_indices["a"];
6833 let applied = orch.insert_interface_node(idx_a, "API boundary");
6834 assert!(applied.is_some());
6835 assert!(orch.node_indices.contains_key("a__iface"));
6836 assert_eq!(orch.node_count(), 3);
6838 }
6839
6840 #[tokio::test]
6841 async fn test_replan_subgraph_resets_nodes() {
6842 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6843 let mut n1 = SRBNNode::new("trigger".into(), "g1".into(), ModelTier::Actuator);
6844 n1.state = NodeState::Coding;
6845 let mut n2 = SRBNNode::new("dep".into(), "g2".into(), ModelTier::Actuator);
6846 n2.state = NodeState::Completed;
6847 orch.add_node(n1);
6848 orch.add_node(n2);
6849
6850 let trigger_idx = orch.node_indices["trigger"];
6851 let applied = orch.replan_subgraph(trigger_idx, &["dep".into()]);
6852 assert!(applied);
6853
6854 let dep_idx = orch.node_indices["dep"];
6855 assert_eq!(orch.graph[dep_idx].state, NodeState::TaskQueued);
6856 assert_eq!(orch.graph[trigger_idx].state, NodeState::Retry);
6857 }
6858
6859 #[tokio::test]
6860 async fn test_select_validators_always_includes_dependency_graph() {
6861 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6862 let node = SRBNNode::new("n".into(), "g".into(), ModelTier::Actuator);
6863 orch.add_node(node);
6864 let idx = orch.node_indices["n"];
6865
6866 let validators = orch.select_validators(idx);
6867 assert!(validators.contains(&SheafValidatorClass::DependencyGraphConsistency));
6868 }
6869
6870 #[tokio::test]
6871 async fn test_select_validators_interface_node() {
6872 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6873 let mut node = SRBNNode::new("iface".into(), "g".into(), ModelTier::Actuator);
6874 node.node_class = perspt_core::types::NodeClass::Interface;
6875 orch.add_node(node);
6876 let idx = orch.node_indices["iface"];
6877
6878 let validators = orch.select_validators(idx);
6879 assert!(validators.contains(&SheafValidatorClass::ExportImportConsistency));
6880 }
6881
6882 #[tokio::test]
6883 async fn test_run_sheaf_validator_dependency_graph_no_cycles() {
6884 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6885 let n1 = SRBNNode::new("a".into(), "g".into(), ModelTier::Actuator);
6886 let n2 = SRBNNode::new("b".into(), "g".into(), ModelTier::Actuator);
6887 orch.add_node(n1);
6888 orch.add_node(n2);
6889 orch.add_dependency("a", "b", "dep").unwrap();
6890
6891 let idx = orch.node_indices["a"];
6892 let result = orch.run_sheaf_validator(idx, SheafValidatorClass::DependencyGraphConsistency);
6893 assert!(result.passed);
6894 assert_eq!(result.v_sheaf_contribution, 0.0);
6895 }
6896
6897 #[tokio::test]
6898 async fn test_classify_non_convergence_default() {
6899 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6900 let node = SRBNNode::new("n".into(), "g".into(), ModelTier::Actuator);
6901 orch.add_node(node);
6902 let idx = orch.node_indices["n"];
6903
6904 let category = orch.classify_non_convergence(idx);
6906 assert_eq!(category, EscalationCategory::ImplementationError);
6907 }
6908
6909 #[tokio::test]
6910 async fn test_affected_dependents() {
6911 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6912 let n1 = SRBNNode::new("root".into(), "g".into(), ModelTier::Actuator);
6913 let n2 = SRBNNode::new("child1".into(), "g".into(), ModelTier::Actuator);
6914 let n3 = SRBNNode::new("child2".into(), "g".into(), ModelTier::Actuator);
6915 orch.add_node(n1);
6916 orch.add_node(n2);
6917 orch.add_node(n3);
6918 orch.add_dependency("root", "child1", "dep").unwrap();
6919 orch.add_dependency("root", "child2", "dep").unwrap();
6920
6921 let idx = orch.node_indices["root"];
6922 let deps = orch.affected_dependents(idx);
6923 assert_eq!(deps.len(), 2);
6924 assert!(deps.contains(&"child1".to_string()));
6925 assert!(deps.contains(&"child2".to_string()));
6926 }
6927
6928 #[tokio::test]
6933 async fn test_maybe_create_provisional_branch_root_node() {
6934 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_phase6"));
6935 orch.context.session_id = "test_session".into();
6936 let node = SRBNNode::new("root".into(), "root goal".into(), ModelTier::Actuator);
6937 orch.add_node(node);
6938
6939 let idx = orch.node_indices["root"];
6940 let branch = orch.maybe_create_provisional_branch(idx);
6942 assert!(branch.is_none());
6943 assert!(orch.graph[idx].provisional_branch_id.is_none());
6944 }
6945
6946 #[tokio::test]
6947 async fn test_maybe_create_provisional_branch_child_node() {
6948 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_phase6"));
6949 orch.context.session_id = "test_session".into();
6950 let parent = SRBNNode::new("parent".into(), "parent goal".into(), ModelTier::Actuator);
6951 let child = SRBNNode::new("child".into(), "child goal".into(), ModelTier::Actuator);
6952 orch.add_node(parent);
6953 orch.add_node(child);
6954 orch.add_dependency("parent", "child", "dep").unwrap();
6955
6956 let idx = orch.node_indices["child"];
6957 let branch = orch.maybe_create_provisional_branch(idx);
6958 assert!(branch.is_some());
6959 assert!(orch.graph[idx].provisional_branch_id.is_some());
6960 }
6961
6962 #[tokio::test]
6963 async fn test_collect_descendants() {
6964 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6965 let n1 = SRBNNode::new("a".into(), "g".into(), ModelTier::Actuator);
6966 let n2 = SRBNNode::new("b".into(), "g".into(), ModelTier::Actuator);
6967 let n3 = SRBNNode::new("c".into(), "g".into(), ModelTier::Actuator);
6968 let n4 = SRBNNode::new("d".into(), "g".into(), ModelTier::Actuator);
6969 orch.add_node(n1);
6970 orch.add_node(n2);
6971 orch.add_node(n3);
6972 orch.add_node(n4);
6973 orch.add_dependency("a", "b", "dep").unwrap();
6974 orch.add_dependency("b", "c", "dep").unwrap();
6975 orch.add_dependency("a", "d", "dep").unwrap();
6976
6977 let idx_a = orch.node_indices["a"];
6978 let descendants = orch.collect_descendants(idx_a);
6979 assert_eq!(descendants.len(), 3); }
6981
6982 #[tokio::test]
6983 async fn test_check_seal_prerequisites_no_interface_parent() {
6984 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
6985 let parent = SRBNNode::new("parent".into(), "g".into(), ModelTier::Actuator);
6986 let child = SRBNNode::new("child".into(), "g".into(), ModelTier::Actuator);
6987 orch.add_node(parent);
6988 orch.add_node(child);
6989 orch.add_dependency("parent", "child", "dep").unwrap();
6990
6991 let idx = orch.node_indices["child"];
6992 assert!(!orch.check_seal_prerequisites(idx));
6994 assert!(orch.blocked_dependencies.is_empty());
6995 }
6996
6997 #[tokio::test]
6998 async fn test_check_seal_prerequisites_unsealed_interface() {
6999 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
7000 let mut parent = SRBNNode::new("iface".into(), "g".into(), ModelTier::Actuator);
7001 parent.node_class = perspt_core::types::NodeClass::Interface;
7002 let child = SRBNNode::new("impl".into(), "g".into(), ModelTier::Actuator);
7003 orch.add_node(parent);
7004 orch.add_node(child);
7005 orch.add_dependency("iface", "impl", "dep").unwrap();
7006
7007 let idx = orch.node_indices["impl"];
7008 assert!(orch.check_seal_prerequisites(idx));
7010 assert_eq!(orch.blocked_dependencies.len(), 1);
7011 assert_eq!(orch.blocked_dependencies[0].parent_node_id, "iface");
7012 }
7013
7014 #[tokio::test]
7015 async fn test_check_seal_prerequisites_sealed_interface() {
7016 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
7017 let mut parent = SRBNNode::new("iface".into(), "g".into(), ModelTier::Actuator);
7018 parent.node_class = perspt_core::types::NodeClass::Interface;
7019 parent.interface_seal_hash = Some([1u8; 32]); let child = SRBNNode::new("impl".into(), "g".into(), ModelTier::Actuator);
7021 orch.add_node(parent);
7022 orch.add_node(child);
7023 orch.add_dependency("iface", "impl", "dep").unwrap();
7024
7025 let idx = orch.node_indices["impl"];
7026 assert!(!orch.check_seal_prerequisites(idx));
7028 assert!(orch.blocked_dependencies.is_empty());
7029 }
7030
7031 #[tokio::test]
7032 async fn test_unblock_dependents() {
7033 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
7034 let parent = SRBNNode::new("parent".into(), "g".into(), ModelTier::Actuator);
7035 let child = SRBNNode::new("child".into(), "g".into(), ModelTier::Actuator);
7036 orch.add_node(parent);
7037 orch.add_node(child);
7038
7039 orch.blocked_dependencies
7041 .push(perspt_core::types::BlockedDependency::new(
7042 "child",
7043 "parent",
7044 vec!["src/api.rs".into()],
7045 ));
7046 assert_eq!(orch.blocked_dependencies.len(), 1);
7047
7048 let idx = orch.node_indices["parent"];
7049 orch.unblock_dependents(idx);
7050 assert!(orch.blocked_dependencies.is_empty());
7051 }
7052
7053 #[tokio::test]
7054 async fn test_flush_descendant_branches() {
7055 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_phase6_flush"));
7056 orch.context.session_id = "test_session".into();
7057
7058 let parent = SRBNNode::new("parent".into(), "g".into(), ModelTier::Actuator);
7059 let mut child1 = SRBNNode::new("child1".into(), "g".into(), ModelTier::Actuator);
7060 child1.provisional_branch_id = Some("branch_c1".into());
7061 let mut child2 = SRBNNode::new("child2".into(), "g".into(), ModelTier::Actuator);
7062 child2.provisional_branch_id = Some("branch_c2".into());
7063 let grandchild = SRBNNode::new("grandchild".into(), "g".into(), ModelTier::Actuator);
7064 orch.add_node(parent);
7065 orch.add_node(child1);
7066 orch.add_node(child2);
7067 orch.add_node(grandchild);
7068 orch.add_dependency("parent", "child1", "dep").unwrap();
7069 orch.add_dependency("parent", "child2", "dep").unwrap();
7070 orch.add_dependency("child1", "grandchild", "dep").unwrap();
7071
7072 let idx = orch.node_indices["parent"];
7073 orch.flush_descendant_branches(idx);
7076 }
7077
7078 #[tokio::test]
7083 async fn test_effective_working_dir_no_branch() {
7084 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/test/workspace"));
7085 let mut orch = orch;
7087 let node = SRBNNode::new("n1".into(), "goal".into(), ModelTier::Actuator);
7088 orch.add_node(node);
7089 let idx = orch.node_indices["n1"];
7090 assert_eq!(
7092 orch.effective_working_dir(idx),
7093 PathBuf::from("/test/workspace")
7094 );
7095 }
7096
7097 #[tokio::test]
7098 async fn test_sandbox_dir_for_node_none_without_branch() {
7099 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/test/workspace"));
7100 let mut orch = orch;
7101 let node = SRBNNode::new("n1".into(), "goal".into(), ModelTier::Actuator);
7102 orch.add_node(node);
7103 let idx = orch.node_indices["n1"];
7104 assert!(orch.sandbox_dir_for_node(idx).is_none());
7105 }
7106
7107 #[tokio::test]
7108 async fn test_rewrite_churn_guardrail() {
7109 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_churn"));
7110 let mut orch = orch;
7111 let node = SRBNNode::new("node_a".into(), "goal".into(), ModelTier::Actuator);
7112 orch.add_node(node);
7113 let count = orch.count_lineage_rewrites("node_a");
7115 assert_eq!(count, 0);
7116 }
7117
7118 #[tokio::test]
7119 async fn test_run_resumed_skips_terminal_nodes() {
7120 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_resume"));
7121
7122 let mut n1 = SRBNNode::new("done".into(), "completed".into(), ModelTier::Actuator);
7123 n1.state = NodeState::Completed;
7124 let mut n2 = SRBNNode::new("failed".into(), "failed".into(), ModelTier::Actuator);
7125 n2.state = NodeState::Failed;
7126 orch.add_node(n1);
7127 orch.add_node(n2);
7128
7129 let result = orch.run_resumed().await;
7131 assert!(result.is_ok());
7132 }
7133
7134 #[tokio::test]
7135 async fn test_persist_review_decision_no_panic() {
7136 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_review"));
7137 orch.persist_review_decision("node_x", "approved", None);
7140 }
7141
7142 #[tokio::test]
7147 async fn test_check_structural_dependencies_blocks_prose_only() {
7148 use perspt_core::types::{NodeClass, RestrictionMap};
7149
7150 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_struct_dep"));
7151
7152 let mut parent = SRBNNode::new("iface_1".into(), "Define API".into(), ModelTier::Architect);
7154 parent.node_class = NodeClass::Interface;
7155
7156 let mut child = SRBNNode::new("impl_1".into(), "Implement API".into(), ModelTier::Actuator);
7158 child.node_class = NodeClass::Implementation;
7159
7160 let parent_idx = orch.add_node(parent);
7161 let child_idx = orch.add_node(child.clone());
7162 orch.graph
7163 .add_edge(parent_idx, child_idx, Dependency { kind: "dep".into() });
7164
7165 let rmap = RestrictionMap::for_node("impl_1");
7167 let gaps = orch.check_structural_dependencies(&child, &rmap);
7168
7169 assert_eq!(gaps.len(), 1);
7170 assert_eq!(gaps[0].0, "iface_1");
7171 assert!(gaps[0].1.contains("no Signature/Schema/InterfaceSeal"));
7172 }
7173
7174 #[tokio::test]
7175 async fn test_check_structural_dependencies_passes_with_digest() {
7176 use perspt_core::types::{ArtifactKind, NodeClass, RestrictionMap, StructuralDigest};
7177
7178 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_struct_ok"));
7179
7180 let mut parent = SRBNNode::new("iface_2".into(), "Define API".into(), ModelTier::Architect);
7181 parent.node_class = NodeClass::Interface;
7182
7183 let mut child = SRBNNode::new("impl_2".into(), "Implement API".into(), ModelTier::Actuator);
7184 child.node_class = NodeClass::Implementation;
7185
7186 let parent_idx = orch.add_node(parent);
7187 let child_idx = orch.add_node(child.clone());
7188 orch.graph
7189 .add_edge(parent_idx, child_idx, Dependency { kind: "dep".into() });
7190
7191 let mut rmap = RestrictionMap::for_node("impl_2");
7193 rmap.structural_digests.push(StructuralDigest::from_content(
7194 "iface_2",
7195 "api.rs",
7196 ArtifactKind::Signature,
7197 b"fn do_thing(x: i32) -> bool;",
7198 ));
7199
7200 let gaps = orch.check_structural_dependencies(&child, &rmap);
7201 assert!(gaps.is_empty(), "Expected no gaps when digest present");
7202 }
7203
7204 #[tokio::test]
7205 async fn test_check_structural_dependencies_skips_non_implementation() {
7206 use perspt_core::types::{NodeClass, RestrictionMap};
7207
7208 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_struct_skip"));
7209
7210 let mut node = SRBNNode::new("integ_1".into(), "Wire modules".into(), ModelTier::Actuator);
7212 node.node_class = NodeClass::Integration;
7213 orch.add_node(node.clone());
7214
7215 let rmap = RestrictionMap::for_node("integ_1");
7216 let gaps = orch.check_structural_dependencies(&node, &rmap);
7217 assert!(gaps.is_empty(), "Integration nodes should skip the check");
7218 }
7219
7220 #[tokio::test]
7221 async fn test_tier_default_models_are_differentiated() {
7222 let arch = ModelTier::Architect.default_model();
7224 let act = ModelTier::Actuator.default_model();
7225 let spec = ModelTier::Speculator.default_model();
7226
7227 assert_ne!(arch, act, "Architect and Actuator defaults should differ");
7229 assert_ne!(spec, arch, "Speculator should differ from Architect");
7231 }
7232
7233 #[tokio::test]
7238 async fn test_orchestrator_stores_all_four_tier_models() {
7239 let orch = SRBNOrchestrator::new_with_models(
7240 PathBuf::from("/tmp/test_tiers"),
7241 false,
7242 Some("arch-model".into()),
7243 Some("act-model".into()),
7244 Some("ver-model".into()),
7245 Some("spec-model".into()),
7246 None,
7247 None,
7248 None,
7249 None,
7250 );
7251 assert_eq!(orch.architect_model, "arch-model");
7252 assert_eq!(orch.actuator_model, "act-model");
7253 assert_eq!(orch.verifier_model, "ver-model");
7254 assert_eq!(orch.speculator_model, "spec-model");
7255 }
7256
7257 #[tokio::test]
7258 async fn test_orchestrator_default_tier_models() {
7259 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_tier_defaults"));
7260 assert_eq!(orch.architect_model, ModelTier::Architect.default_model());
7261 assert_eq!(orch.actuator_model, ModelTier::Actuator.default_model());
7262 assert_eq!(orch.verifier_model, ModelTier::Verifier.default_model());
7263 assert_eq!(orch.speculator_model, ModelTier::Speculator.default_model());
7264 }
7265
7266 #[tokio::test]
7267 async fn test_create_nodes_rejects_duplicate_output_files() {
7268 use perspt_core::types::PlannedTask;
7269
7270 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_dup_outputs"));
7271
7272 let plan = TaskPlan {
7273 tasks: vec![
7274 PlannedTask {
7275 id: "task_1".into(),
7276 goal: "Create math".into(),
7277 output_files: vec!["src/math.py".into(), "tests/test_math.py".into()],
7278 ..PlannedTask::new("task_1", "Create math")
7279 },
7280 PlannedTask {
7281 id: "task_2".into(),
7282 goal: "Create tests".into(),
7283 output_files: vec!["tests/test_math.py".into()],
7284 ..PlannedTask::new("task_2", "Create tests")
7285 },
7286 ],
7287 };
7288
7289 let result = orch.create_nodes_from_plan(&plan);
7290 assert!(result.is_err(), "Should reject duplicate output_files");
7291 let err = result.unwrap_err().to_string();
7292 assert!(
7293 err.contains("tests/test_math.py"),
7294 "Error should mention the duplicate file: {}",
7295 err
7296 );
7297 }
7298
7299 #[tokio::test]
7300 async fn test_create_nodes_accepts_unique_output_files() {
7301 use perspt_core::types::PlannedTask;
7302
7303 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_unique_outputs"));
7304
7305 let plan = TaskPlan {
7306 tasks: vec![
7307 PlannedTask {
7308 id: "task_1".into(),
7309 goal: "Create math".into(),
7310 output_files: vec!["src/math.py".into()],
7311 ..PlannedTask::new("task_1", "Create math")
7312 },
7313 PlannedTask {
7314 id: "test_1".into(),
7315 goal: "Test math".into(),
7316 output_files: vec!["tests/test_math.py".into()],
7317 dependencies: vec!["task_1".into()],
7318 ..PlannedTask::new("test_1", "Test math")
7319 },
7320 ],
7321 };
7322
7323 let result = orch.create_nodes_from_plan(&plan);
7324 assert!(result.is_ok(), "Should accept unique output_files");
7325 assert_eq!(orch.graph.node_count(), 2);
7326 }
7327
7328 #[tokio::test]
7329 async fn test_ownership_manifest_built_with_majority_plugin_vote() {
7330 use perspt_core::types::PlannedTask;
7331
7332 let mut orch = SRBNOrchestrator::new_for_testing(PathBuf::from("/tmp/test_plugin_vote"));
7333
7334 let plan = TaskPlan {
7335 tasks: vec![PlannedTask {
7336 id: "task_1".into(),
7337 goal: "Create Python module".into(),
7338 output_files: vec![
7339 "src/main.py".into(),
7340 "src/helper.py".into(),
7341 "src/__init__.py".into(),
7342 ],
7343 ..PlannedTask::new("task_1", "Create Python module")
7344 }],
7345 };
7346
7347 orch.create_nodes_from_plan(&plan).unwrap();
7348
7349 assert_eq!(orch.context.ownership_manifest.len(), 3);
7351 let idx = orch.node_indices["task_1"];
7353 assert_eq!(orch.graph[idx].owner_plugin, "python");
7354 }
7355
7356 #[tokio::test]
7357 async fn test_apply_bundle_strips_paths_outside_node_output_targets() {
7358 use perspt_core::types::{ArtifactBundle, ArtifactOperation, PlannedTask};
7359
7360 let temp_dir = std::env::temp_dir().join(format!(
7361 "perspt_bundle_target_guard_{}",
7362 uuid::Uuid::new_v4()
7363 ));
7364 std::fs::create_dir_all(temp_dir.join("src")).unwrap();
7365
7366 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
7367 let plan = TaskPlan {
7368 tasks: vec![
7369 PlannedTask {
7370 id: "validate_module".into(),
7371 goal: "Create validation module".into(),
7372 output_files: vec!["src/validate.rs".into()],
7373 ..PlannedTask::new("validate_module", "Create validation module")
7374 },
7375 PlannedTask {
7376 id: "lib_module".into(),
7377 goal: "Export validation module".into(),
7378 output_files: vec!["src/lib.rs".into()],
7379 dependencies: vec!["validate_module".into()],
7380 ..PlannedTask::new("lib_module", "Export validation module")
7381 },
7382 ],
7383 };
7384
7385 orch.create_nodes_from_plan(&plan).unwrap();
7386
7387 let bundle = ArtifactBundle {
7388 artifacts: vec![
7389 ArtifactOperation::Write {
7390 path: "src/validate.rs".into(),
7391 content: "pub fn ok() {}".into(),
7392 },
7393 ArtifactOperation::Write {
7394 path: "src/lib.rs".into(),
7395 content: "pub mod validate;".into(),
7396 },
7397 ],
7398 commands: vec![],
7399 };
7400
7401 orch.apply_bundle_transactionally(
7404 &bundle,
7405 "validate_module",
7406 perspt_core::types::NodeClass::Implementation,
7407 )
7408 .await
7409 .expect("Should apply valid artifacts after stripping undeclared paths");
7410
7411 assert!(temp_dir.join("src/validate.rs").exists());
7413 assert!(!temp_dir.join("src/lib.rs").exists());
7415 }
7416
7417 #[tokio::test]
7418 async fn test_apply_bundle_writes_into_branch_sandbox() {
7419 use perspt_core::types::{ArtifactBundle, ArtifactOperation, PlannedTask};
7420
7421 let temp_dir = std::env::temp_dir().join(format!(
7422 "perspt_branch_sandbox_write_{}",
7423 uuid::Uuid::new_v4()
7424 ));
7425 std::fs::create_dir_all(temp_dir.join("src")).unwrap();
7426 std::fs::write(temp_dir.join("src/lib.rs"), "pub fn old() {}\n").unwrap();
7427
7428 let mut orch = SRBNOrchestrator::new_for_testing(temp_dir.clone());
7429 orch.context.session_id = uuid::Uuid::new_v4().to_string();
7430
7431 let plan = TaskPlan {
7432 tasks: vec![
7433 PlannedTask {
7434 id: "parent".into(),
7435 goal: "Parent node".into(),
7436 output_files: vec!["src/lib.rs".into()],
7437 ..PlannedTask::new("parent", "Parent node")
7438 },
7439 PlannedTask {
7440 id: "child".into(),
7441 goal: "Child node".into(),
7442 context_files: vec!["src/lib.rs".into()],
7443 output_files: vec!["src/child.rs".into()],
7444 dependencies: vec!["parent".into()],
7445 ..PlannedTask::new("child", "Child node")
7446 },
7447 ],
7448 };
7449
7450 orch.create_nodes_from_plan(&plan).unwrap();
7451 let child_idx = orch.node_indices["child"];
7452 let branch_id = orch.maybe_create_provisional_branch(child_idx).unwrap();
7453 let sandbox_dir = orch.sandbox_dir_for_node(child_idx).unwrap();
7454
7455 let bundle = ArtifactBundle {
7456 artifacts: vec![ArtifactOperation::Write {
7457 path: "src/child.rs".into(),
7458 content: "pub fn child() {}\n".into(),
7459 }],
7460 commands: vec![],
7461 };
7462
7463 orch.apply_bundle_transactionally(
7464 &bundle,
7465 "child",
7466 perspt_core::types::NodeClass::Implementation,
7467 )
7468 .await
7469 .unwrap();
7470
7471 assert!(sandbox_dir.join("src/child.rs").exists());
7472 assert!(!temp_dir.join("src/child.rs").exists());
7473
7474 orch.merge_provisional_branch(&branch_id, child_idx);
7475 }
7476
7477 #[test]
7478 fn test_verification_stages_for_node_classes() {
7479 use perspt_core::plugin::VerifierStage;
7480
7481 let interface_node =
7483 SRBNNode::new("iface".into(), "Define trait".into(), ModelTier::Actuator);
7484 let mut interface_node = interface_node;
7486 interface_node.node_class = perspt_core::types::NodeClass::Interface;
7487 let stages = verification_stages_for_node(&interface_node);
7488 assert_eq!(stages, vec![VerifierStage::SyntaxCheck]);
7489
7490 let mut implementation_node = SRBNNode::new(
7492 "impl".into(),
7493 "Implement feature".into(),
7494 ModelTier::Actuator,
7495 );
7496 implementation_node.node_class = perspt_core::types::NodeClass::Implementation;
7497 let stages = verification_stages_for_node(&implementation_node);
7498 assert_eq!(
7499 stages,
7500 vec![VerifierStage::SyntaxCheck, VerifierStage::Build]
7501 );
7502
7503 implementation_node
7505 .contract
7506 .weighted_tests
7507 .push(perspt_core::types::WeightedTest {
7508 test_name: "test_feature".into(),
7509 criticality: perspt_core::types::Criticality::High,
7510 });
7511 let stages = verification_stages_for_node(&implementation_node);
7512 assert_eq!(
7513 stages,
7514 vec![
7515 VerifierStage::SyntaxCheck,
7516 VerifierStage::Build,
7517 VerifierStage::Test
7518 ]
7519 );
7520
7521 let mut integration_node =
7523 SRBNNode::new("test".into(), "Verify feature".into(), ModelTier::Actuator);
7524 integration_node.node_class = perspt_core::types::NodeClass::Integration;
7525 integration_node
7526 .contract
7527 .weighted_tests
7528 .push(perspt_core::types::WeightedTest {
7529 test_name: "test_feature".into(),
7530 criticality: perspt_core::types::Criticality::High,
7531 });
7532 let stages = verification_stages_for_node(&integration_node);
7533 assert_eq!(
7534 stages,
7535 vec![
7536 VerifierStage::SyntaxCheck,
7537 VerifierStage::Build,
7538 VerifierStage::Test,
7539 VerifierStage::Lint,
7540 ]
7541 );
7542 }
7543
7544 #[tokio::test]
7549 async fn test_classify_workspace_empty_dir() {
7550 let temp = tempfile::tempdir().unwrap();
7551 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
7552 let state = orch.classify_workspace("build a web app");
7553 assert!(matches!(state, WorkspaceState::Greenfield { .. }));
7555 }
7556
7557 #[tokio::test]
7558 async fn test_classify_workspace_empty_dir_no_lang() {
7559 let temp = tempfile::tempdir().unwrap();
7560 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
7561 let state = orch.classify_workspace("do something");
7562 match state {
7564 WorkspaceState::Greenfield { inferred_lang } => assert!(inferred_lang.is_none()),
7565 _ => panic!("expected Greenfield, got {:?}", state),
7566 }
7567 }
7568
7569 #[tokio::test]
7570 async fn test_classify_workspace_existing_rust_project() {
7571 let temp = tempfile::tempdir().unwrap();
7572 std::fs::write(
7574 temp.path().join("Cargo.toml"),
7575 "[package]\nname = \"test\"\nversion = \"0.1.0\"",
7576 )
7577 .unwrap();
7578 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
7579 let state = orch.classify_workspace("add a feature");
7580 match state {
7581 WorkspaceState::ExistingProject { plugins } => {
7582 assert!(plugins.contains(&"rust".to_string()));
7583 }
7584 _ => panic!("expected ExistingProject, got {:?}", state),
7585 }
7586 }
7587
7588 #[tokio::test]
7589 async fn test_classify_workspace_existing_python_project() {
7590 let temp = tempfile::tempdir().unwrap();
7591 std::fs::write(
7592 temp.path().join("pyproject.toml"),
7593 "[project]\nname = \"test\"",
7594 )
7595 .unwrap();
7596 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
7597 let state = orch.classify_workspace("add a feature");
7598 match state {
7599 WorkspaceState::ExistingProject { plugins } => {
7600 assert!(plugins.contains(&"python".to_string()));
7601 }
7602 _ => panic!("expected ExistingProject, got {:?}", state),
7603 }
7604 }
7605
7606 #[tokio::test]
7607 async fn test_classify_workspace_existing_js_project() {
7608 let temp = tempfile::tempdir().unwrap();
7609 std::fs::write(temp.path().join("package.json"), "{}").unwrap();
7610 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
7611 let state = orch.classify_workspace("add auth");
7612 match state {
7613 WorkspaceState::ExistingProject { plugins } => {
7614 assert!(plugins.contains(&"javascript".to_string()));
7615 }
7616 _ => panic!("expected ExistingProject, got {:?}", state),
7617 }
7618 }
7619
7620 #[tokio::test]
7621 async fn test_classify_workspace_ambiguous_with_misc_files() {
7622 let temp = tempfile::tempdir().unwrap();
7623 std::fs::write(temp.path().join("notes.txt"), "hello").unwrap();
7625 std::fs::write(temp.path().join("data.csv"), "a,b,c").unwrap();
7626 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
7627 let state = orch.classify_workspace("do something");
7628 assert!(matches!(state, WorkspaceState::Ambiguous));
7629 }
7630
7631 #[tokio::test]
7632 async fn test_classify_workspace_greenfield_with_rust_task() {
7633 let temp = tempfile::tempdir().unwrap();
7634 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
7635 let state = orch.classify_workspace("create a rust CLI tool");
7636 match state {
7637 WorkspaceState::Greenfield { inferred_lang } => {
7638 assert_eq!(inferred_lang, Some("rust".to_string()));
7639 }
7640 _ => panic!("expected Greenfield, got {:?}", state),
7641 }
7642 }
7643
7644 #[tokio::test]
7645 async fn test_classify_workspace_greenfield_with_python_task() {
7646 let temp = tempfile::tempdir().unwrap();
7647 let orch = SRBNOrchestrator::new_for_testing(temp.path().to_path_buf());
7648 let state = orch.classify_workspace("build a python flask API");
7649 match state {
7650 WorkspaceState::Greenfield { inferred_lang } => {
7651 assert_eq!(inferred_lang, Some("python".to_string()));
7652 }
7653 _ => panic!("expected Greenfield, got {:?}", state),
7654 }
7655 }
7656
7657 #[tokio::test]
7662 async fn test_check_prerequisites_returns_true_when_tools_available() {
7663 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
7664 let registry = perspt_core::plugin::PluginRegistry::new();
7665 if let Some(plugin) = registry.get("rust") {
7667 let result = orch.check_tool_prerequisites(plugin);
7668 let _ = result;
7671 }
7672 }
7673
7674 #[test]
7675 fn test_required_binaries_rust_includes_cargo() {
7676 let registry = perspt_core::plugin::PluginRegistry::new();
7677 let plugin = registry.get("rust").unwrap();
7678 let bins = plugin.required_binaries();
7679 assert!(bins.iter().any(|(name, _, _)| *name == "cargo"));
7680 assert!(bins.iter().any(|(name, _, _)| *name == "rustc"));
7681 }
7682
7683 #[test]
7684 fn test_required_binaries_python_includes_uv() {
7685 let registry = perspt_core::plugin::PluginRegistry::new();
7686 let plugin = registry.get("python").unwrap();
7687 let bins = plugin.required_binaries();
7688 assert!(bins.iter().any(|(name, _, _)| *name == "uv"));
7689 assert!(bins.iter().any(|(name, _, _)| *name == "python3"));
7690 }
7691
7692 #[test]
7693 fn test_required_binaries_js_includes_node() {
7694 let registry = perspt_core::plugin::PluginRegistry::new();
7695 let plugin = registry.get("javascript").unwrap();
7696 let bins = plugin.required_binaries();
7697 assert!(bins.iter().any(|(name, _, _)| *name == "node"));
7698 assert!(bins.iter().any(|(name, _, _)| *name == "npm"));
7699 }
7700
7701 #[tokio::test]
7706 async fn test_fallback_defaults_to_none_without_explicit_config() {
7707 let orch = SRBNOrchestrator::new_for_testing(PathBuf::from("."));
7708 assert!(orch.architect_fallback_model.is_none());
7709 assert!(orch.actuator_fallback_model.is_none());
7710 assert!(orch.verifier_fallback_model.is_none());
7711 assert!(orch.speculator_fallback_model.is_none());
7712 }
7713
7714 #[tokio::test]
7715 async fn test_explicit_fallback_stored_correctly() {
7716 let orch = SRBNOrchestrator::new_with_models(
7717 PathBuf::from("/tmp/test_fallback"),
7718 false,
7719 None,
7720 None,
7721 None,
7722 None,
7723 Some("gpt-4o".into()),
7724 Some("gpt-4o-mini".into()),
7725 Some("gpt-4o".into()),
7726 Some("gpt-4o-mini".into()),
7727 );
7728 assert_eq!(orch.architect_fallback_model, Some("gpt-4o".to_string()));
7729 assert_eq!(
7730 orch.actuator_fallback_model,
7731 Some("gpt-4o-mini".to_string())
7732 );
7733 assert_eq!(orch.verifier_fallback_model, Some("gpt-4o".to_string()));
7734 assert_eq!(
7735 orch.speculator_fallback_model,
7736 Some("gpt-4o-mini".to_string())
7737 );
7738 }
7739
7740 #[tokio::test]
7741 async fn test_per_tier_models_independent() {
7742 let orch = SRBNOrchestrator::new_with_models(
7743 PathBuf::from("/tmp/test_tiers_independent"),
7744 false,
7745 Some("arch".into()),
7746 Some("act".into()),
7747 Some("ver".into()),
7748 Some("spec".into()),
7749 None,
7750 None,
7751 None,
7752 None,
7753 );
7754 assert_ne!(orch.architect_model, orch.actuator_model);
7756 assert_ne!(orch.verifier_model, orch.speculator_model);
7757 }
7758
7759 #[test]
7764 fn test_extract_missing_python_modules_basic() {
7765 let output = r#"
7766FAILED tests/test_core.py::TestPipeline::test_run - ModuleNotFoundError: No module named 'httpx'
7767E ModuleNotFoundError: No module named 'pydantic'
7768ImportError: No module named 'pyarrow'
7769"#;
7770 let mut missing = SRBNOrchestrator::extract_missing_python_modules(output);
7771 missing.sort();
7772 assert_eq!(missing, vec!["httpx", "pyarrow", "pydantic"]);
7773 }
7774
7775 #[test]
7776 fn test_extract_missing_python_modules_subpackage() {
7777 let output = "ModuleNotFoundError: No module named 'foo.bar.baz'";
7778 let missing = SRBNOrchestrator::extract_missing_python_modules(output);
7779 assert_eq!(missing, vec!["foo"]);
7780 }
7781
7782 #[test]
7783 fn test_extract_missing_python_modules_stdlib_filtered() {
7784 let output = r#"
7785ModuleNotFoundError: No module named 'numpy'
7786ModuleNotFoundError: No module named 'os'
7787ModuleNotFoundError: No module named 'json'
7788"#;
7789 let missing = SRBNOrchestrator::extract_missing_python_modules(output);
7790 assert_eq!(missing, vec!["numpy"]);
7791 }
7792
7793 #[test]
7794 fn test_extract_missing_python_modules_empty() {
7795 let output = "All tests passed!\n3 passed in 0.5s";
7796 let missing = SRBNOrchestrator::extract_missing_python_modules(output);
7797 assert!(missing.is_empty());
7798 }
7799
7800 #[test]
7801 fn test_python_import_to_package_mapping() {
7802 assert_eq!(SRBNOrchestrator::python_import_to_package("PIL"), "pillow");
7803 assert_eq!(SRBNOrchestrator::python_import_to_package("yaml"), "pyyaml");
7804 assert_eq!(
7805 SRBNOrchestrator::python_import_to_package("cv2"),
7806 "opencv-python"
7807 );
7808 assert_eq!(
7809 SRBNOrchestrator::python_import_to_package("sklearn"),
7810 "scikit-learn"
7811 );
7812 assert_eq!(
7813 SRBNOrchestrator::python_import_to_package("bs4"),
7814 "beautifulsoup4"
7815 );
7816 assert_eq!(SRBNOrchestrator::python_import_to_package("httpx"), "httpx");
7818 assert_eq!(
7819 SRBNOrchestrator::python_import_to_package("fastapi"),
7820 "fastapi"
7821 );
7822 }
7823
7824 #[test]
7825 fn test_normalize_command_to_uv_pip_install() {
7826 assert_eq!(
7827 SRBNOrchestrator::normalize_command_to_uv("pip install httpx"),
7828 "uv add httpx"
7829 );
7830 assert_eq!(
7831 SRBNOrchestrator::normalize_command_to_uv("pip3 install httpx pydantic"),
7832 "uv add httpx pydantic"
7833 );
7834 assert_eq!(
7835 SRBNOrchestrator::normalize_command_to_uv("python -m pip install requests"),
7836 "uv add requests"
7837 );
7838 assert_eq!(
7839 SRBNOrchestrator::normalize_command_to_uv("python3 -m pip install flask"),
7840 "uv add flask"
7841 );
7842 }
7843
7844 #[test]
7845 fn test_normalize_command_to_uv_requirements_file() {
7846 assert_eq!(
7847 SRBNOrchestrator::normalize_command_to_uv("pip install -r requirements.txt"),
7848 "uv pip install -r requirements.txt"
7849 );
7850 }
7851
7852 #[test]
7853 fn test_normalize_command_to_uv_passthrough() {
7854 assert_eq!(
7856 SRBNOrchestrator::normalize_command_to_uv("uv add httpx"),
7857 "uv add httpx"
7858 );
7859 assert_eq!(
7861 SRBNOrchestrator::normalize_command_to_uv("cargo add serde"),
7862 "cargo add serde"
7863 );
7864 assert_eq!(
7865 SRBNOrchestrator::normalize_command_to_uv("npm install lodash"),
7866 "npm install lodash"
7867 );
7868 }
7869
7870 #[test]
7871 fn test_extract_commands_from_correction_includes_uv() {
7872 let response = r#"Here's the fix:
7873Commands:
7874```
7875uv add httpx
7876uv add --dev pytest
7877cargo add serde
7878pip install numpy
7879```
7880File: main.py
7881```python
7882import httpx
7883```"#;
7884 let commands = SRBNOrchestrator::extract_commands_from_correction(response);
7885 assert!(
7886 commands.contains(&"uv add httpx".to_string()),
7887 "{:?}",
7888 commands
7889 );
7890 assert!(
7891 commands.contains(&"cargo add serde".to_string()),
7892 "{:?}",
7893 commands
7894 );
7895 assert!(
7896 commands.contains(&"pip install numpy".to_string()),
7897 "{:?}",
7898 commands
7899 );
7900 }
7901
7902 #[test]
7903 fn test_extract_all_code_blocks_multiple_files() {
7904 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
7905 let content = r#"Here are the files:
7906
7907File: src/etl_pipeline/core.py
7908```python
7909def run_pipeline():
7910 pass
7911```
7912
7913File: src/etl_pipeline/validator.py
7914```python
7915def validate(data):
7916 return True
7917```
7918
7919File: tests/test_core.py
7920```python
7921from etl_pipeline.core import run_pipeline
7922
7923def test_run():
7924 run_pipeline()
7925```
7926"#;
7927 let blocks = orch.extract_all_code_blocks_from_response(content);
7928 assert_eq!(blocks.len(), 3, "Expected 3 blocks, got {:?}", blocks);
7929 assert_eq!(blocks[0].0, "src/etl_pipeline/core.py");
7930 assert_eq!(blocks[1].0, "src/etl_pipeline/validator.py");
7931 assert_eq!(blocks[2].0, "tests/test_core.py");
7932 assert!(!blocks[0].2, "core.py should not be a diff");
7933 }
7934
7935 #[test]
7936 fn test_extract_all_code_blocks_single_file() {
7937 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
7938 let content = r#"File: main.py
7939```python
7940print("hello")
7941```"#;
7942 let blocks = orch.extract_all_code_blocks_from_response(content);
7943 assert_eq!(blocks.len(), 1);
7944 assert_eq!(blocks[0].0, "main.py");
7945 }
7946
7947 #[test]
7948 fn test_extract_all_code_blocks_mixed_file_and_diff() {
7949 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
7950 let content = r#"File: new_module.py
7951```python
7952def new_fn():
7953 pass
7954```
7955
7956Diff: existing.py
7957```diff
7958--- existing.py
7959+++ existing.py
7960@@ -1 +1,2 @@
7961+import new_module
7962 def old_fn():
7963```"#;
7964 let blocks = orch.extract_all_code_blocks_from_response(content);
7965 assert_eq!(blocks.len(), 2);
7966 assert_eq!(blocks[0].0, "new_module.py");
7967 assert!(!blocks[0].2, "new_module.py should be a write");
7968 assert_eq!(blocks[1].0, "existing.py");
7969 assert!(blocks[1].2, "existing.py should be a diff");
7970 }
7971
7972 #[test]
7973 fn test_parse_artifact_bundle_legacy_multi_file() {
7974 let orch = SRBNOrchestrator::new(std::path::PathBuf::from("/tmp/test"), false);
7975 let content = r#"File: core.py
7976```python
7977def core():
7978 pass
7979```
7980
7981File: utils.py
7982```python
7983def util():
7984 pass
7985```"#;
7986 let bundle = orch.parse_artifact_bundle(content);
7987 assert!(bundle.is_some(), "Should parse multi-file legacy response");
7988 let bundle = bundle.unwrap();
7989 assert_eq!(bundle.artifacts.len(), 2, "Should have 2 artifacts");
7990 assert_eq!(bundle.artifacts[0].path(), "core.py");
7991 assert_eq!(bundle.artifacts[1].path(), "utils.py");
7992 }
7993}