1mod loop_state;
6#[cfg(test)]
7mod tests;
8
9pub use loop_state::LoopState;
10
11use crate::config::{HatBackend, InjectMode, RalphConfig, ScratchpadConfig};
12use crate::event_parser::{EventParser, MutationEvidence, MutationStatus};
13use crate::event_reader::EventReader;
14use crate::hat_registry::HatRegistry;
15use crate::hatless_ralph::HatlessRalph;
16use crate::instructions::InstructionBuilder;
17use crate::loop_context::LoopContext;
18use crate::memory_store::{MarkdownMemoryStore, format_memories_as_markdown, truncate_to_budget};
19use crate::skill_registry::SkillRegistry;
20use crate::text::floor_char_boundary;
21use ralph_proto::{CheckinContext, Event, EventBus, Hat, HatId, RobotService};
22use serde::{Deserialize, Serialize};
23use serde_json::{Map, Value};
24use std::collections::HashMap;
25use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28use std::sync::atomic::AtomicBool;
29use std::time::Duration;
30use tracing::{debug, info, warn};
31
32#[derive(Debug, Clone)]
34pub struct ProcessedEvents {
35 pub had_events: bool,
37 pub had_plan_events: bool,
39 pub human_interact_context: Option<Value>,
42 pub has_orphans: bool,
44}
45
46#[derive(Debug)]
48pub struct ProcessedEventsWithWaves {
49 pub processed: ProcessedEvents,
51 pub wave_events: Vec<crate::event_reader::Event>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct PersistedLoopState {
58 pub iteration: u32,
59 pub total_cost_usd: f64,
60 pub last_hat: Option<String>,
61 #[serde(default)]
62 pub peak_input_tokens: u64,
63 #[serde(default)]
64 pub last_input_tokens: Option<u64>,
65 #[serde(default)]
66 pub hat_peak_input_tokens: HashMap<String, u64>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum TerminationReason {
72 CompletionPromise,
74 MaxIterations,
76 MaxRuntime,
78 MaxCost,
80 ConsecutiveFailures,
82 LoopThrashing,
84 LoopStale,
86 ValidationFailure,
88 Stopped,
90 Interrupted,
92 RestartRequested,
94 WorkspaceGone,
96 Cancelled,
98}
99
100impl TerminationReason {
101 pub fn exit_code(&self) -> i32 {
109 match self {
110 TerminationReason::CompletionPromise => 0,
111 TerminationReason::ConsecutiveFailures
112 | TerminationReason::LoopThrashing
113 | TerminationReason::LoopStale
114 | TerminationReason::ValidationFailure
115 | TerminationReason::Stopped
116 | TerminationReason::WorkspaceGone => 1,
117 TerminationReason::MaxIterations
118 | TerminationReason::MaxRuntime
119 | TerminationReason::MaxCost => 2,
120 TerminationReason::Interrupted => 130,
121 TerminationReason::RestartRequested => 3,
123 TerminationReason::Cancelled => 0,
125 }
126 }
127
128 pub fn as_str(&self) -> &'static str {
133 match self {
134 TerminationReason::CompletionPromise => "completed",
135 TerminationReason::MaxIterations => "max_iterations",
136 TerminationReason::MaxRuntime => "max_runtime",
137 TerminationReason::MaxCost => "max_cost",
138 TerminationReason::ConsecutiveFailures => "consecutive_failures",
139 TerminationReason::LoopThrashing => "loop_thrashing",
140 TerminationReason::LoopStale => "loop_stale",
141 TerminationReason::ValidationFailure => "validation_failure",
142 TerminationReason::Stopped => "stopped",
143 TerminationReason::Interrupted => "interrupted",
144 TerminationReason::RestartRequested => "restart_requested",
145 TerminationReason::WorkspaceGone => "workspace_gone",
146 TerminationReason::Cancelled => "cancelled",
147 }
148 }
149
150 pub fn is_success(&self) -> bool {
152 matches!(self, TerminationReason::CompletionPromise)
153 }
154}
155
156pub struct EventLoop {
158 config: RalphConfig,
159 registry: HatRegistry,
160 bus: EventBus,
161 state: LoopState,
162 instruction_builder: InstructionBuilder,
163 ralph: HatlessRalph,
164 robot_guidance: Vec<String>,
166 pub(crate) event_reader: EventReader,
169 diagnostics: crate::diagnostics::DiagnosticsCollector,
170 loop_context: Option<LoopContext>,
172 skill_registry: SkillRegistry,
174 robot_service: Option<Box<dyn RobotService>>,
177}
178
179impl EventLoop {
180 pub fn new(config: RalphConfig) -> Self {
182 let diagnostics = crate::diagnostics::DiagnosticsCollector::new(std::path::Path::new("."))
185 .unwrap_or_else(|e| {
186 debug!(
187 "Failed to initialize diagnostics: {}, using disabled collector",
188 e
189 );
190 crate::diagnostics::DiagnosticsCollector::disabled()
191 });
192
193 Self::with_diagnostics(config, diagnostics)
194 }
195
196 pub fn with_context(config: RalphConfig, context: LoopContext) -> Self {
202 let diagnostics = crate::diagnostics::DiagnosticsCollector::new(context.workspace())
203 .unwrap_or_else(|e| {
204 debug!(
205 "Failed to initialize diagnostics: {}, using disabled collector",
206 e
207 );
208 crate::diagnostics::DiagnosticsCollector::disabled()
209 });
210
211 Self::with_context_and_diagnostics(config, context, diagnostics)
212 }
213
214 pub fn with_context_and_diagnostics(
216 mut config: RalphConfig,
217 context: LoopContext,
218 diagnostics: crate::diagnostics::DiagnosticsCollector,
219 ) -> Self {
220 if config.hats.is_empty() && !config.core.scratchpad.enabled {
222 warn!(
223 "core.scratchpad.enabled is false but no hats are defined. \
224 Scratchpad is the only continuity mechanism in solo mode — forcing enabled."
225 );
226 config.core.scratchpad.enabled = true;
227 }
228
229 let registry = HatRegistry::from_config(&config);
230 let instruction_builder =
231 InstructionBuilder::with_events(config.core.clone(), config.events.clone());
232
233 let mut bus = EventBus::new();
234
235 for hat in registry.all() {
239 bus.register(hat.clone());
240 }
241
242 let ralph_hat = ralph_proto::Hat::new("ralph", "Ralph").subscribe("*"); bus.register(ralph_hat);
246
247 if registry.is_empty() {
248 debug!("Solo mode: Ralph is the only coordinator");
249 } else {
250 debug!(
251 "Multi-hat mode: {} custom hats + Ralph as fallback",
252 registry.len()
253 );
254 }
255
256 let mut skill_registry = if config.skills.enabled {
258 SkillRegistry::from_config(
259 &config.skills,
260 context.workspace(),
261 Some(config.cli.backend.as_str()),
262 )
263 .unwrap_or_else(|e| {
264 warn!(
265 "Failed to build skill registry: {}, using empty registry",
266 e
267 );
268 SkillRegistry::new(Some(config.cli.backend.as_str()))
269 })
270 } else {
271 SkillRegistry::new(Some(config.cli.backend.as_str()))
272 };
273
274 if !config.tasks.enabled {
276 skill_registry.remove("ralph-tools-tasks");
277 }
278 if !config.memories.enabled {
279 skill_registry.remove("ralph-tools-memories");
280 }
281
282 let skill_index = if config.skills.enabled {
283 skill_registry.build_index(None)
284 } else {
285 String::new()
286 };
287
288 let ralph = HatlessRalph::new(
290 config.event_loop.completion_promise.clone(),
291 config.core.clone(),
292 ®istry,
293 config.event_loop.starting_event.clone(),
294 )
295 .with_memories_enabled(config.memories.enabled)
296 .with_skill_index(skill_index);
297
298 let events_path = std::fs::read_to_string(context.current_events_marker())
302 .map(|s| {
303 let relative = s.trim();
304 context.workspace().join(relative)
305 })
306 .unwrap_or_else(|_| context.events_path());
307 let event_reader = EventReader::new(&events_path);
308
309 Self {
310 config,
311 registry,
312 bus,
313 state: LoopState::new(),
314 instruction_builder,
315 ralph,
316 robot_guidance: Vec::new(),
317 event_reader,
318 diagnostics,
319 loop_context: Some(context),
320 skill_registry,
321 robot_service: None,
322 }
323 }
324
325 pub fn with_diagnostics(
327 mut config: RalphConfig,
328 diagnostics: crate::diagnostics::DiagnosticsCollector,
329 ) -> Self {
330 if config.hats.is_empty() && !config.core.scratchpad.enabled {
332 warn!(
333 "core.scratchpad.enabled is false but no hats are defined. \
334 Scratchpad is the only continuity mechanism in solo mode — forcing enabled."
335 );
336 config.core.scratchpad.enabled = true;
337 }
338
339 let registry = HatRegistry::from_config(&config);
340 let instruction_builder =
341 InstructionBuilder::with_events(config.core.clone(), config.events.clone());
342
343 let mut bus = EventBus::new();
344
345 for hat in registry.all() {
349 bus.register(hat.clone());
350 }
351
352 let ralph_hat = ralph_proto::Hat::new("ralph", "Ralph").subscribe("*"); bus.register(ralph_hat);
356
357 if registry.is_empty() {
358 debug!("Solo mode: Ralph is the only coordinator");
359 } else {
360 debug!(
361 "Multi-hat mode: {} custom hats + Ralph as fallback",
362 registry.len()
363 );
364 }
365
366 let workspace_root = std::path::Path::new(".");
368 let mut skill_registry = if config.skills.enabled {
369 SkillRegistry::from_config(
370 &config.skills,
371 workspace_root,
372 Some(config.cli.backend.as_str()),
373 )
374 .unwrap_or_else(|e| {
375 warn!(
376 "Failed to build skill registry: {}, using empty registry",
377 e
378 );
379 SkillRegistry::new(Some(config.cli.backend.as_str()))
380 })
381 } else {
382 SkillRegistry::new(Some(config.cli.backend.as_str()))
383 };
384
385 if !config.tasks.enabled {
387 skill_registry.remove("ralph-tools-tasks");
388 }
389 if !config.memories.enabled {
390 skill_registry.remove("ralph-tools-memories");
391 }
392
393 let skill_index = if config.skills.enabled {
394 skill_registry.build_index(None)
395 } else {
396 String::new()
397 };
398
399 let ralph = HatlessRalph::new(
401 config.event_loop.completion_promise.clone(),
402 config.core.clone(),
403 ®istry,
404 config.event_loop.starting_event.clone(),
405 )
406 .with_memories_enabled(config.memories.enabled)
407 .with_skill_index(skill_index);
408
409 let events_path = std::fs::read_to_string(".ralph/current-events")
412 .map(|s| s.trim().to_string())
413 .unwrap_or_else(|_| ".ralph/events.jsonl".to_string());
414 let event_reader = EventReader::new(&events_path);
415
416 Self {
417 config,
418 registry,
419 bus,
420 state: LoopState::new(),
421 instruction_builder,
422 ralph,
423 robot_guidance: Vec::new(),
424 event_reader,
425 diagnostics,
426 loop_context: None,
427 skill_registry,
428 robot_service: None,
429 }
430 }
431
432 pub fn set_robot_service(&mut self, service: Box<dyn RobotService>) {
440 self.robot_service = Some(service);
441 }
442
443 pub fn loop_context(&self) -> Option<&LoopContext> {
445 self.loop_context.as_ref()
446 }
447
448 fn tasks_path(&self) -> PathBuf {
450 self.loop_context
451 .as_ref()
452 .map(|ctx| ctx.tasks_path())
453 .unwrap_or_else(|| PathBuf::from(".ralph/agent/tasks.jsonl"))
454 }
455
456 fn scratchpad_path(&self) -> PathBuf {
463 let active_path = &self.ralph.active_scratchpad().path;
464
465 match self.loop_context.as_ref() {
466 Some(ctx) => ctx.workspace().join(active_path),
467 None => PathBuf::from(active_path),
468 }
469 }
470
471 fn global_scratchpad_path(&self) -> PathBuf {
474 self.loop_context
475 .as_ref()
476 .map(|ctx| ctx.scratchpad_path())
477 .unwrap_or_else(|| PathBuf::from(&self.config.core.scratchpad.path))
478 }
479
480 pub fn state(&self) -> &LoopState {
482 &self.state
483 }
484
485 pub fn record_iteration_tokens(&mut self, hat: &HatId, tokens: u64) {
490 self.state.record_iteration_tokens(hat, tokens);
491 }
492
493 pub fn loop_state_path(&self) -> PathBuf {
495 self.loop_context
496 .as_ref()
497 .map(|ctx| ctx.ralph_dir().join("api/loop-state.json"))
498 .unwrap_or_else(|| PathBuf::from(".ralph/api/loop-state.json"))
499 }
500
501 pub fn persisted_loop_state(&self) -> PersistedLoopState {
503 PersistedLoopState {
504 iteration: self.state.iteration,
505 total_cost_usd: self.state.cumulative_cost,
506 last_hat: self
507 .state
508 .last_hat
509 .as_ref()
510 .map(|hat| hat.as_str().to_string()),
511 peak_input_tokens: self.state.peak_input_tokens,
512 last_input_tokens: self.state.last_input_tokens,
513 hat_peak_input_tokens: self
514 .state
515 .hat_peak_input_tokens
516 .iter()
517 .map(|(hat, tokens)| (hat.as_str().to_string(), *tokens))
518 .collect(),
519 }
520 }
521
522 pub fn restore_loop_state(&mut self, path: &std::path::Path) -> std::io::Result<()> {
524 if !path.exists() {
525 return Ok(());
526 }
527
528 let raw = std::fs::read_to_string(path)?;
529 let persisted: PersistedLoopState = serde_json::from_str(&raw).map_err(|error| {
530 std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string())
531 })?;
532 self.state.iteration = persisted.iteration;
533 self.state.cumulative_cost = persisted.total_cost_usd;
534 self.state.last_hat = persisted.last_hat.map(HatId::new);
535 self.state.peak_input_tokens = persisted.peak_input_tokens;
536 self.state.last_input_tokens = persisted.last_input_tokens;
537 self.state.hat_peak_input_tokens = persisted
538 .hat_peak_input_tokens
539 .into_iter()
540 .map(|(hat, tokens)| (HatId::new(hat), tokens))
541 .collect();
542 Ok(())
543 }
544
545 pub fn save_loop_state(&self, path: &std::path::Path) -> std::io::Result<()> {
547 if let Some(parent) = path.parent() {
548 std::fs::create_dir_all(parent)?;
549 }
550 let payload = serde_json::to_string_pretty(&self.persisted_loop_state())?;
551 std::fs::write(path, payload)
552 }
553
554 pub fn clear_loop_state(path: &std::path::Path) -> std::io::Result<()> {
556 match std::fs::remove_file(path) {
557 Ok(()) => Ok(()),
558 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
559 Err(error) => Err(error),
560 }
561 }
562
563 pub fn reset_stale_topic_counter(&mut self) {
569 self.state.consecutive_same_signature = 0;
570 self.state.last_emitted_signature = None;
571 }
572
573 pub fn config(&self) -> &RalphConfig {
575 &self.config
576 }
577
578 pub fn registry(&self) -> &HatRegistry {
580 &self.registry
581 }
582
583 pub fn log_hook_run_telemetry(&self, entry: crate::diagnostics::HookRunTelemetryEntry) {
585 self.diagnostics.log_hook_run(entry);
586 }
587
588 pub fn log_prompt(&self, iteration: u32, hat: &str, prompt: &str) {
590 self.diagnostics.log_prompt(iteration, hat, prompt);
591 }
592
593 pub fn get_hat_backend(&self, hat_id: &HatId) -> Option<&HatBackend> {
598 self.registry
599 .get_config(hat_id)
600 .and_then(|config| config.backend.as_ref())
601 }
602
603 pub fn add_observer<F>(&mut self, observer: F)
608 where
609 F: Fn(&Event) + Send + 'static,
610 {
611 self.bus.add_observer(observer);
612 }
613
614 #[deprecated(since = "2.0.0", note = "Use add_observer instead")]
618 pub fn set_observer<F>(&mut self, observer: F)
619 where
620 F: Fn(&Event) + Send + 'static,
621 {
622 #[allow(deprecated)]
623 self.bus.set_observer(observer);
624 }
625
626 pub fn check_termination(&self) -> Option<TerminationReason> {
628 let cfg = &self.config.event_loop;
629
630 if self.state.iteration >= cfg.max_iterations {
631 return Some(TerminationReason::MaxIterations);
632 }
633
634 if self.state.elapsed().as_secs() >= cfg.max_runtime_seconds {
635 return Some(TerminationReason::MaxRuntime);
636 }
637
638 if let Some(max_cost) = cfg.max_cost_usd
639 && self.state.cumulative_cost >= max_cost
640 {
641 return Some(TerminationReason::MaxCost);
642 }
643
644 if self.state.consecutive_failures >= cfg.max_consecutive_failures {
645 return Some(TerminationReason::ConsecutiveFailures);
646 }
647
648 if self.state.abandoned_task_redispatches >= 3 {
650 return Some(TerminationReason::LoopThrashing);
651 }
652
653 if self.state.consecutive_malformed_events >= 3 {
655 return Some(TerminationReason::ValidationFailure);
656 }
657
658 if self.state.consecutive_same_signature >= 3 {
660 let topic = self
661 .state
662 .last_emitted_signature
663 .as_ref()
664 .map(|signature| signature.topic.as_str())
665 .unwrap_or("?");
666 warn!(
667 topic,
668 count = self.state.consecutive_same_signature,
669 "Stale loop detected: same event signature emitted consecutively"
670 );
671 return Some(TerminationReason::LoopStale);
672 }
673
674 let stop_path =
676 std::path::Path::new(&self.config.core.workspace_root).join(".ralph/stop-requested");
677 if stop_path.exists() {
678 let _ = std::fs::remove_file(&stop_path);
679 return Some(TerminationReason::Stopped);
680 }
681
682 let restart_path =
684 std::path::Path::new(&self.config.core.workspace_root).join(".ralph/restart-requested");
685 if restart_path.exists() {
686 return Some(TerminationReason::RestartRequested);
687 }
688
689 if !std::path::Path::new(&self.config.core.workspace_root).is_dir() {
691 return Some(TerminationReason::WorkspaceGone);
692 }
693
694 None
695 }
696
697 pub fn check_cancellation_event(&mut self) -> Option<TerminationReason> {
702 if !self.state.cancellation_requested {
703 return None;
704 }
705 self.state.cancellation_requested = false;
706 info!("Loop cancelled gracefully via loop.cancel event");
707
708 self.diagnostics.log_orchestration(
709 self.state.iteration,
710 "loop",
711 crate::diagnostics::OrchestrationEvent::LoopTerminated {
712 reason: "cancelled".to_string(),
713 },
714 );
715
716 Some(TerminationReason::Cancelled)
717 }
718
719 pub fn request_completion_from_text_fallback(&mut self) {
726 self.state.completion_requested = true;
727 info!("Completion requested via text fallback (output contained completion promise)");
728 }
729
730 pub fn check_completion_event(&mut self) -> Option<TerminationReason> {
735 if !self.state.completion_requested {
736 return None;
737 }
738
739 let required = &self.config.event_loop.required_events;
741 if !required.is_empty() {
742 let missing = self.state.missing_required_events(required);
743 if !missing.is_empty() {
744 warn!(
745 missing = ?missing,
746 "Rejecting LOOP_COMPLETE: required events not seen during loop lifetime"
747 );
748 self.state.completion_requested = false;
749
750 let resume_payload = format!(
752 "LOOP_COMPLETE rejected: missing required events: {:?}. \
753 The agent must complete all workflow phases before emitting LOOP_COMPLETE. \
754 Use loop.cancel to abort the workflow instead.",
755 missing
756 );
757 self.bus.publish(Event::new("task.resume", resume_payload));
758 return None;
759 }
760 }
761
762 if !self.state.unacknowledged_guidance.is_empty() {
763 let guidance_count = self.state.unacknowledged_guidance.len();
764 warn!(
765 guidance_count,
766 "Rejecting LOOP_COMPLETE: human.guidance is unacknowledged"
767 );
768 self.state.completion_requested = false;
769 self.bus.publish(Event::new(
770 "task.resume",
771 format!(
772 "Completion rejected: {guidance_count} human.guidance message(s) remain unacknowledged. Address the guidance, then emit human.guidance.ack with a summary before emitting the completion promise."
773 ),
774 ));
775 return None;
776 }
777
778 self.state.completion_requested = false;
779
780 if self.config.event_loop.persistent {
782 info!("Completion event suppressed - persistent mode active, loop staying alive");
783
784 self.diagnostics.log_orchestration(
785 self.state.iteration,
786 "loop",
787 crate::diagnostics::OrchestrationEvent::LoopTerminated {
788 reason: "completion_event_suppressed_persistent".to_string(),
789 },
790 );
791
792 let resume_event = Event::new(
794 "task.resume",
795 "Persistent mode: loop staying alive after completion signal. \
796 Check for new tasks or await human guidance.",
797 );
798 self.bus.publish(resume_event);
799
800 return None;
801 }
802
803 if self.config.memories.enabled {
805 if let Ok(false) = self.verify_tasks_complete() {
806 let open_tasks = self.get_open_task_list();
807 warn!(
808 open_tasks = ?open_tasks,
809 "Rejecting completion event with {} open task(s)",
810 open_tasks.len()
811 );
812 self.bus.publish(Event::new(
813 "task.resume",
814 format!(
815 "Completion rejected: runtime tasks remain open: {:?}. Close, fail, or reopen outstanding tasks before emitting the completion promise.",
816 open_tasks
817 ),
818 ));
819 return None;
820 }
821 } else if let Ok(false) = self.verify_scratchpad_complete() {
822 warn!("Completion event with pending scratchpad tasks - trusting agent decision");
823 }
824
825 info!("Completion event detected - terminating");
826
827 self.diagnostics.log_orchestration(
829 self.state.iteration,
830 "loop",
831 crate::diagnostics::OrchestrationEvent::LoopTerminated {
832 reason: "completion_event".to_string(),
833 },
834 );
835
836 Some(TerminationReason::CompletionPromise)
837 }
838
839 pub fn initialize(&mut self, prompt_content: &str) {
841 let topic = self
843 .config
844 .event_loop
845 .starting_event
846 .clone()
847 .unwrap_or_else(|| "task.start".to_string());
848 self.initialize_with_topic(&topic, prompt_content);
849 }
850
851 pub fn initialize_resume(&mut self, prompt_content: &str) {
856 self.initialize_with_topic("task.resume", prompt_content);
858 }
859
860 fn initialize_with_topic(&mut self, topic: &str, prompt_content: &str) {
862 self.ralph.set_objective(prompt_content.to_string());
866
867 let start_event = Event::new(topic, prompt_content);
868 self.bus.publish(start_event);
869 debug!(topic = topic, "Published {} event", topic);
870 }
871
872 pub fn next_hat(&self) -> Option<&HatId> {
881 let next = self.bus.next_hat_with_pending();
882
883 if next.is_none() && self.bus.has_human_pending() {
885 return self.bus.hat_ids().find(|id| id.as_str() == "ralph");
886 }
887
888 next.as_ref()?;
890
891 if self.registry.is_empty() {
894 next
896 } else {
897 self.bus.hat_ids().find(|id| id.as_str() == "ralph")
900 }
901 }
902
903 pub fn replay_resume_events_from_jsonl(&mut self) -> std::io::Result<ProcessedEvents> {
914 let path = self.event_reader.path().to_path_buf();
915 let mut historical_reader = EventReader::new(&path);
916 let result = historical_reader.read_new_events()?;
917
918 for event in &result.events {
919 self.state.seen_topics.insert(event.topic.clone());
920 }
921
922 let last_terminate_index = result
923 .events
924 .iter()
925 .rposition(|event| event.topic == "loop.terminate");
926 let last_terminate_line = if result.malformed.is_empty() {
927 None
928 } else {
929 Self::last_terminate_line_number(&path)?
930 };
931 let replay_events = match last_terminate_index {
932 Some(index) => result.events.into_iter().skip(index + 1).collect(),
933 None => Vec::new(),
934 };
935 let replay_malformed = match last_terminate_line {
936 Some(line_number) => result
937 .malformed
938 .into_iter()
939 .filter(|malformed| malformed.line_number > line_number)
940 .collect(),
941 None => Vec::new(),
942 };
943
944 let processed = self.process_parse_result(crate::event_reader::ParseResult {
945 events: replay_events,
946 malformed: replay_malformed,
947 })?;
948
949 if let Ok(metadata) = std::fs::metadata(&path) {
950 self.event_reader.set_position(metadata.len());
951 }
952
953 Ok(processed)
954 }
955
956 fn last_terminate_line_number(path: &Path) -> std::io::Result<Option<u64>> {
957 if !path.exists() {
958 return Ok(None);
959 }
960
961 let file = std::fs::File::open(path)?;
962 let reader = std::io::BufReader::new(file);
963 let mut last_terminate = None;
964
965 for (index, line) in reader.lines().enumerate() {
966 let line = line?;
967 if line.trim().is_empty() {
968 continue;
969 }
970 if let Ok(event) = serde_json::from_str::<crate::event_reader::Event>(&line)
971 && event.topic == "loop.terminate"
972 {
973 last_terminate = Some(index as u64 + 1);
974 }
975 }
976
977 Ok(last_terminate)
978 }
979
980 pub fn sync_event_reader_to_file_end(&mut self) {
987 let path = self.event_reader.path();
988 if let Ok(metadata) = std::fs::metadata(path) {
989 self.event_reader.set_position(metadata.len());
990 }
991 }
992
993 pub fn has_pending_events(&self) -> bool {
998 self.bus.next_hat_with_pending().is_some() || self.bus.has_human_pending()
999 }
1000
1001 pub fn has_pending_human_events(&self) -> bool {
1006 self.bus.has_human_pending()
1007 }
1008
1009 pub fn inject_human_guidance<I, S>(&mut self, messages: I)
1014 where
1015 I: IntoIterator<Item = S>,
1016 S: Into<String>,
1017 {
1018 for message in messages {
1019 let event = Event::new("human.guidance", message.into());
1020 self.state.record_event(&event);
1021 self.bus.publish(event);
1022 }
1023 }
1024
1025 pub fn has_pending_plan_events_in_jsonl(&self) -> std::io::Result<bool> {
1030 let result = self.event_reader.peek_new_events()?;
1031 Ok(result
1032 .events
1033 .iter()
1034 .any(|event| event.topic.starts_with("plan.")))
1035 }
1036
1037 pub fn pending_human_interact_context_in_jsonl(&self) -> std::io::Result<Option<Value>> {
1040 let result = self.event_reader.peek_new_events()?;
1041 Ok(result
1042 .events
1043 .iter()
1044 .find(|event| event.topic == "human.interact")
1045 .map(|event| {
1046 Self::parse_human_interact_context(event.payload.as_deref().unwrap_or_default())
1047 }))
1048 }
1049
1050 pub fn get_hat_publishes(&self, hat_id: &HatId) -> Vec<String> {
1054 self.registry
1055 .get(hat_id)
1056 .map(|hat| hat.publishes.iter().map(|t| t.to_string()).collect())
1057 .unwrap_or_default()
1058 }
1059
1060 pub fn inject_fallback_event(&mut self) -> bool {
1067 let fallback_event = match &self.state.last_hat {
1070 Some(hat_id) if hat_id.as_str() != "ralph" => {
1071 let publishes = self.get_hat_publishes(hat_id);
1072 let payload = if publishes.is_empty() {
1073 format!(
1074 "RECOVERY: Previous iteration by hat `{}` did not publish an event. \
1075 Emit exactly one valid next event via `ralph emit`, or stop only after \
1076 publishing the configured completion event.",
1077 hat_id.as_str()
1078 )
1079 } else {
1080 format!(
1081 "RECOVERY: Previous iteration by hat `{}` did not publish an event. \
1082 This failed because no event was emitted. Emit exactly ONE valid next \
1083 event via `ralph emit`. Allowed topics: `{}`. Do not only write prose \
1084 or update files. Stop immediately after emitting.",
1085 hat_id.as_str(),
1086 publishes.join("`, `")
1087 )
1088 };
1089
1090 debug!(
1091 hat = %hat_id.as_str(),
1092 "Injecting fallback event to recover - targeting last hat with task.resume"
1093 );
1094 Event::new("task.resume", payload).with_target(hat_id.clone())
1095 }
1096 _ => {
1097 debug!("Injecting fallback event to recover - triggering Ralph with task.resume");
1098 Event::new(
1099 "task.resume",
1100 "RECOVERY: Previous iteration did not publish an event. \
1101 Review the scratchpad and either dispatch the next task or complete the loop.",
1102 )
1103 }
1104 };
1105
1106 self.bus.publish(fallback_event);
1107 true
1108 }
1109
1110 pub fn build_prompt(&mut self, hat_id: &HatId) -> Option<String> {
1124 if hat_id.as_str() == "ralph" {
1127 if self.registry.is_empty() {
1128 let mut events = self.bus.take_pending(&hat_id.clone());
1130 let mut human_events = self.bus.take_human_pending();
1131 events.append(&mut human_events);
1132
1133 let (guidance_events, regular_events): (Vec<_>, Vec<_>) = events
1135 .into_iter()
1136 .partition(|e| e.topic.as_str() == "human.guidance");
1137
1138 let events_context = regular_events
1139 .iter()
1140 .map(|e| Self::format_event(e))
1141 .collect::<Vec<_>>()
1142 .join("\n");
1143
1144 self.ralph
1146 .set_active_scratchpad(self.config.core.scratchpad.clone());
1147 self.ralph.set_iteration(self.state.iteration);
1148
1149 self.update_robot_guidance(guidance_events);
1151 self.apply_robot_guidance();
1152
1153 let base_prompt = self.ralph.build_prompt(&events_context, &[]);
1155 self.ralph.clear_robot_guidance();
1156 let with_skills = self.prepend_auto_inject_skills(base_prompt);
1157 let with_scratchpad = self.prepend_scratchpad(with_skills);
1158 let final_prompt = self.prepend_ready_tasks(with_scratchpad);
1159
1160 debug!("build_prompt: routing to HatlessRalph (solo mode)");
1161 return Some(final_prompt);
1162 } else {
1163 let mut all_hat_ids: Vec<HatId> = self.bus.hat_ids().cloned().collect();
1165 all_hat_ids.sort_by(|a, b| a.as_str().cmp(b.as_str()));
1167
1168 let mut all_events = Vec::new();
1169 let mut system_events = Vec::new();
1170
1171 for id in &all_hat_ids {
1172 let pending = self.bus.take_pending(id);
1173 if pending.is_empty() {
1174 continue;
1175 }
1176
1177 let (drop_pending, exhausted_event) = self.check_hat_exhaustion(id, &pending);
1178 if drop_pending {
1179 if let Some(exhausted_event) = exhausted_event {
1181 all_events.push(exhausted_event.clone());
1182 system_events.push(exhausted_event);
1183 }
1184 continue;
1185 }
1186
1187 all_events.extend(pending);
1188 }
1189
1190 let mut human_events = self.bus.take_human_pending();
1191 all_events.append(&mut human_events);
1192
1193 for event in system_events {
1196 self.bus.publish(event);
1197 }
1198
1199 let (guidance_events, regular_events): (Vec<_>, Vec<_>) = all_events
1201 .into_iter()
1202 .partition(|e| e.topic.as_str() == "human.guidance");
1203
1204 let effective_regular_events = self.effective_regular_events(®ular_events);
1206
1207 let active_hat_ids = self.determine_active_hat_ids(®ular_events);
1209 self.record_hat_activations(&active_hat_ids);
1210 self.state.last_active_hat_ids = active_hat_ids.clone();
1211
1212 let resolved_scratchpad = if let Some(hat_id) = active_hat_ids.first() {
1216 let hat_scratchpad = self
1217 .registry
1218 .get_config(hat_id)
1219 .and_then(|c| c.scratchpad.as_ref());
1220 ScratchpadConfig::resolve(hat_scratchpad, &self.config.core.scratchpad)
1221 } else {
1222 self.config.core.scratchpad.clone()
1224 };
1225 self.ralph.set_active_scratchpad(resolved_scratchpad);
1226 self.ralph.set_iteration(self.state.iteration);
1227
1228 self.update_robot_guidance(guidance_events);
1231 self.apply_robot_guidance();
1232
1233 let active_hats = self.determine_active_hats(®ular_events);
1234
1235 let events_context = effective_regular_events
1237 .iter()
1238 .map(|e| Self::format_event(e))
1239 .collect::<Vec<_>>()
1240 .join("\n");
1241
1242 let base_prompt = self.ralph.build_prompt(&events_context, &active_hats);
1244
1245 debug!(
1247 "build_prompt: routing to HatlessRalph (multi-hat coordinator mode), active_hats: {:?}",
1248 active_hats
1249 .iter()
1250 .map(|h| h.id.as_str())
1251 .collect::<Vec<_>>()
1252 );
1253
1254 self.ralph.clear_robot_guidance();
1256 let with_skills = self.prepend_auto_inject_skills(base_prompt);
1257 let with_scratchpad = self.prepend_scratchpad(with_skills);
1258 let final_prompt = self.prepend_ready_tasks(with_scratchpad);
1259
1260 return Some(final_prompt);
1261 }
1262 }
1263
1264 let events = self.bus.take_pending(&hat_id.clone());
1268 let events_context = events
1269 .iter()
1270 .map(|e| Self::format_event(e))
1271 .collect::<Vec<_>>()
1272 .join("\n");
1273
1274 let hat = self.registry.get(hat_id)?;
1275
1276 debug!(
1278 "build_prompt: hat_id='{}', instructions.is_empty()={}",
1279 hat_id.as_str(),
1280 hat.instructions.is_empty()
1281 );
1282
1283 debug!(
1285 "build_prompt: routing to build_custom_hat() for '{}'",
1286 hat_id.as_str()
1287 );
1288 let hat_scratchpad = self
1289 .registry
1290 .get_config(hat_id)
1291 .and_then(|c| c.scratchpad.as_ref());
1292 let resolved_scratchpad =
1293 ScratchpadConfig::resolve(hat_scratchpad, &self.config.core.scratchpad);
1294 self.ralph
1295 .set_active_scratchpad(resolved_scratchpad.clone());
1296 Some(
1297 self.instruction_builder
1298 .build_custom_hat(hat, &events_context, &resolved_scratchpad),
1299 )
1300 }
1301
1302 fn update_robot_guidance(&mut self, guidance_events: Vec<Event>) {
1308 if guidance_events.is_empty() {
1309 return;
1310 }
1311
1312 self.persist_guidance_to_scratchpad(&guidance_events);
1314
1315 self.robot_guidance
1316 .extend(guidance_events.into_iter().map(|e| e.payload));
1317 }
1318
1319 fn persist_guidance_to_scratchpad(&self, guidance_events: &[Event]) {
1328 use std::io::Write;
1329
1330 let scratchpad_path = if self.ralph.active_scratchpad().enabled {
1332 self.scratchpad_path()
1333 } else {
1334 if !self.config.core.scratchpad.enabled {
1335 debug!("Both hat and global scratchpad disabled, skipping guidance persistence");
1336 return;
1337 }
1338 self.global_scratchpad_path()
1339 };
1340 let resolved_path = if scratchpad_path.is_relative() {
1341 self.config.core.workspace_root.join(&scratchpad_path)
1342 } else {
1343 scratchpad_path
1344 };
1345
1346 if let Some(parent) = resolved_path.parent()
1348 && !parent.exists()
1349 && let Err(e) = std::fs::create_dir_all(parent)
1350 {
1351 warn!("Failed to create scratchpad directory: {}", e);
1352 return;
1353 }
1354
1355 let mut file = match std::fs::OpenOptions::new()
1356 .create(true)
1357 .append(true)
1358 .open(&resolved_path)
1359 {
1360 Ok(f) => f,
1361 Err(e) => {
1362 warn!("Failed to open scratchpad for guidance persistence: {}", e);
1363 return;
1364 }
1365 };
1366
1367 let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
1368 for event in guidance_events {
1369 let entry = format!(
1370 "\n### HUMAN GUIDANCE ({})\n\n{}\n",
1371 timestamp, event.payload
1372 );
1373 if let Err(e) = file.write_all(entry.as_bytes()) {
1374 warn!("Failed to write guidance to scratchpad: {}", e);
1375 }
1376 }
1377
1378 info!(
1379 count = guidance_events.len(),
1380 "Persisted human guidance to scratchpad"
1381 );
1382 }
1383
1384 fn apply_robot_guidance(&mut self) {
1386 if self.robot_guidance.is_empty() {
1387 return;
1388 }
1389
1390 self.ralph.set_robot_guidance(self.robot_guidance.clone());
1391 }
1392
1393 fn prepend_auto_inject_skills(&self, prompt: String) -> String {
1403 let mut prefix = String::new();
1404
1405 self.inject_memories_and_tools_skill(&mut prefix);
1407
1408 self.inject_robot_skill(&mut prefix);
1410
1411 self.inject_custom_auto_skills(&mut prefix);
1413
1414 if prefix.is_empty() {
1415 return prompt;
1416 }
1417
1418 prefix.push_str("\n\n");
1419 prefix.push_str(&prompt);
1420 prefix
1421 }
1422
1423 fn inject_memories_and_tools_skill(&self, prefix: &mut String) {
1431 let memories_config = &self.config.memories;
1432
1433 if memories_config.enabled && memories_config.inject == InjectMode::Auto {
1435 info!(
1436 "Memory injection check: enabled={}, inject={:?}, workspace_root={:?}",
1437 memories_config.enabled, memories_config.inject, self.config.core.workspace_root
1438 );
1439
1440 let workspace_root = &self.config.core.workspace_root;
1441 let store = MarkdownMemoryStore::with_default_path(workspace_root);
1442 let memories_path = workspace_root.join(".ralph/agent/memories.md");
1443
1444 info!(
1445 "Looking for memories at: {:?} (exists: {})",
1446 memories_path,
1447 memories_path.exists()
1448 );
1449
1450 let memories = match store.load() {
1451 Ok(memories) => {
1452 info!("Successfully loaded {} memories from store", memories.len());
1453 memories
1454 }
1455 Err(e) => {
1456 info!(
1457 "Failed to load memories for injection: {} (path: {:?})",
1458 e, memories_path
1459 );
1460 Vec::new()
1461 }
1462 };
1463
1464 if memories.is_empty() {
1465 info!("Memory store is empty - no memories to inject");
1466 } else {
1467 let mut memories_content = format_memories_as_markdown(&memories);
1468
1469 if memories_config.budget > 0 {
1470 let original_len = memories_content.len();
1471 memories_content =
1472 truncate_to_budget(&memories_content, memories_config.budget);
1473 debug!(
1474 "Applied budget: {} chars -> {} chars (budget: {})",
1475 original_len,
1476 memories_content.len(),
1477 memories_config.budget
1478 );
1479 }
1480
1481 info!(
1482 "Injecting {} memories ({} chars) into prompt",
1483 memories.len(),
1484 memories_content.len()
1485 );
1486
1487 prefix.push_str(&memories_content);
1488 }
1489 }
1490
1491 let tasks_enabled = self.config.tasks.enabled;
1493
1494 if (memories_config.enabled || tasks_enabled)
1496 && let Some(skill) = self.skill_registry.get("ralph-tools")
1497 {
1498 if !prefix.is_empty() {
1499 prefix.push_str("\n\n");
1500 }
1501 prefix.push_str(&format!(
1502 "<ralph-tools-skill>\n{}\n</ralph-tools-skill>",
1503 skill.content.trim()
1504 ));
1505 debug!("Injected ralph-tools skill from registry");
1506 }
1507
1508 if tasks_enabled && let Some(skill) = self.skill_registry.get("ralph-tools-tasks") {
1510 if !prefix.is_empty() {
1511 prefix.push_str("\n\n");
1512 }
1513 prefix.push_str(&format!(
1514 "<ralph-tools-tasks-skill>\n{}\n</ralph-tools-tasks-skill>",
1515 skill.content.trim()
1516 ));
1517 debug!("Injected ralph-tools-tasks skill from registry");
1518 }
1519
1520 if memories_config.enabled
1522 && let Some(skill) = self.skill_registry.get("ralph-tools-memories")
1523 {
1524 if !prefix.is_empty() {
1525 prefix.push_str("\n\n");
1526 }
1527 prefix.push_str(&format!(
1528 "<ralph-tools-memories-skill>\n{}\n</ralph-tools-memories-skill>",
1529 skill.content.trim()
1530 ));
1531 debug!("Injected ralph-tools-memories skill from registry");
1532 }
1533 }
1534
1535 fn inject_robot_skill(&self, prefix: &mut String) {
1540 if !self.config.robot.enabled {
1541 return;
1542 }
1543
1544 if let Some(skill) = self.skill_registry.get("robot-interaction") {
1545 if !prefix.is_empty() {
1546 prefix.push_str("\n\n");
1547 }
1548 prefix.push_str(&format!(
1549 "<robot-skill>\n{}\n</robot-skill>",
1550 skill.content.trim()
1551 ));
1552 debug!("Injected robot interaction skill from registry");
1553 }
1554 }
1555
1556 fn inject_custom_auto_skills(&self, prefix: &mut String) {
1558 for skill in self.skill_registry.auto_inject_skills(None) {
1559 if matches!(
1561 skill.name.as_str(),
1562 "ralph-tools" | "ralph-tools-tasks" | "ralph-tools-memories" | "robot-interaction"
1563 ) {
1564 continue;
1565 }
1566
1567 if !prefix.is_empty() {
1568 prefix.push_str("\n\n");
1569 }
1570 prefix.push_str(&format!(
1571 "<{name}-skill>\n{content}\n</{name}-skill>",
1572 name = skill.name,
1573 content = skill.content.trim()
1574 ));
1575 debug!("Injected auto-inject skill: {}", skill.name);
1576 }
1577 }
1578
1579 fn prepend_scratchpad(&self, prompt: String) -> String {
1585 if !self.ralph.active_scratchpad().enabled {
1587 return prompt;
1588 }
1589
1590 let scratchpad_path = self.scratchpad_path();
1591
1592 let resolved_path = if scratchpad_path.is_relative() {
1593 self.config.core.workspace_root.join(&scratchpad_path)
1594 } else {
1595 scratchpad_path
1596 };
1597
1598 if !resolved_path.exists() {
1599 debug!(
1600 "Scratchpad not found at {:?}, skipping injection",
1601 resolved_path
1602 );
1603 return prompt;
1604 }
1605
1606 let content = match std::fs::read_to_string(&resolved_path) {
1607 Ok(c) => c,
1608 Err(e) => {
1609 info!("Failed to read scratchpad for injection: {}", e);
1610 return prompt;
1611 }
1612 };
1613
1614 if content.trim().is_empty() {
1615 debug!("Scratchpad is empty, skipping injection");
1616 return prompt;
1617 }
1618
1619 let char_budget = 4000 * 4;
1621 let content = if content.len() > char_budget {
1622 let start = content.len() - char_budget;
1624 let start = floor_char_boundary(&content, start);
1626 let line_start = content[start..].find('\n').map_or(start, |n| start + n + 1);
1627 let discarded = &content[..line_start];
1628
1629 let headings: Vec<&str> = discarded
1631 .lines()
1632 .filter(|line| line.starts_with('#'))
1633 .collect();
1634 let summary = if headings.is_empty() {
1635 format!(
1636 "<!-- earlier content truncated ({} chars omitted) -->",
1637 line_start
1638 )
1639 } else {
1640 format!(
1641 "<!-- earlier content truncated ({} chars omitted) -->\n\
1642 <!-- discarded sections: {} -->",
1643 line_start,
1644 headings.join(" | ")
1645 )
1646 };
1647
1648 format!("{}\n\n{}", summary, &content[line_start..])
1649 } else {
1650 content
1651 };
1652
1653 info!("Injecting scratchpad ({} chars) into prompt", content.len());
1654
1655 let mut final_prompt = format!(
1656 "<scratchpad path=\"{}\">\n{}\n</scratchpad>\n\n",
1657 self.ralph.active_scratchpad().path,
1658 content
1659 );
1660 final_prompt.push_str(&prompt);
1661 final_prompt
1662 }
1663
1664 fn prepend_ready_tasks(&self, prompt: String) -> String {
1670 if !self.config.tasks.enabled {
1671 return prompt;
1672 }
1673
1674 use crate::task::TaskStatus;
1675 use crate::task_store::TaskStore;
1676
1677 let tasks_path = self.tasks_path();
1678 let resolved_path = if tasks_path.is_relative() {
1679 self.config.core.workspace_root.join(&tasks_path)
1680 } else {
1681 tasks_path
1682 };
1683
1684 if !resolved_path.exists() {
1685 return prompt;
1686 }
1687
1688 let store = match TaskStore::load(&resolved_path) {
1689 Ok(s) => s,
1690 Err(e) => {
1691 info!("Failed to load task store for injection: {}", e);
1692 return prompt;
1693 }
1694 };
1695
1696 let current_loop_id = self.current_loop_id();
1697
1698 let ready = Self::filter_tasks_by_loop(store.ready(), current_loop_id.as_deref());
1699 let open = Self::filter_tasks_by_loop(store.open(), current_loop_id.as_deref());
1700 let all_count =
1701 Self::filter_tasks_by_loop(store.all().iter().collect(), current_loop_id.as_deref())
1702 .len();
1703 let closed_count = all_count - open.len();
1704
1705 if open.is_empty() && closed_count == 0 {
1706 return prompt;
1707 }
1708
1709 let mut section = String::from("<ready-tasks>\n");
1710 if ready.is_empty() && open.is_empty() {
1711 section.push_str("No open tasks. Create tasks with `ralph tools task add`.\n");
1712 } else {
1713 section.push_str(&format!(
1714 "## Tasks: {} ready, {} open, {} closed\n\n",
1715 ready.len(),
1716 open.len(),
1717 closed_count
1718 ));
1719 for task in &ready {
1720 let status_icon = match task.status {
1721 TaskStatus::Open => "[ ]",
1722 TaskStatus::InProgress => "[~]",
1723 _ => "[?]",
1724 };
1725 section.push_str(&format!(
1726 "- {} [P{}] {} ({}){}\n",
1727 status_icon,
1728 task.priority,
1729 task.title,
1730 task.id,
1731 task.key
1732 .as_deref()
1733 .map(|key| format!(" — key: {key}"))
1734 .unwrap_or_default()
1735 ));
1736 }
1737 let ready_ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
1739 let blocked: Vec<_> = open
1740 .iter()
1741 .filter(|t| !ready_ids.contains(&t.id.as_str()))
1742 .collect();
1743 if !blocked.is_empty() {
1744 section.push_str("\nBlocked:\n");
1745 for task in blocked {
1746 section.push_str(&format!(
1747 "- [blocked] [P{}] {} ({}){} — blocked by: {}\n",
1748 task.priority,
1749 task.title,
1750 task.id,
1751 task.key
1752 .as_deref()
1753 .map(|key| format!(" — key: {key}"))
1754 .unwrap_or_default(),
1755 task.blocked_by.join(", ")
1756 ));
1757 }
1758 }
1759 }
1760 section.push_str("</ready-tasks>\n\n");
1761
1762 info!(
1763 "Injecting ready tasks ({} ready, {} open, {} closed) into prompt",
1764 ready.len(),
1765 open.len(),
1766 closed_count
1767 );
1768
1769 let mut final_prompt = section;
1770 final_prompt.push_str(&prompt);
1771 final_prompt
1772 }
1773
1774 pub fn build_ralph_prompt(&self, prompt_content: &str) -> String {
1776 self.ralph.build_prompt(prompt_content, &[])
1777 }
1778
1779 fn determine_active_hats(&self, events: &[Event]) -> Vec<&Hat> {
1782 let mut active_hats = Vec::new();
1783 for id in self.determine_active_hat_ids(events) {
1784 if let Some(hat) = self.registry.get(&id) {
1785 active_hats.push(hat);
1786 }
1787 }
1788 active_hats
1789 }
1790
1791 fn determine_active_hat_ids(&self, events: &[Event]) -> Vec<HatId> {
1792 let mut entrypoint_hat_ids = Vec::new();
1793 let mut progressed_hat_ids = Vec::new();
1794 for event in events {
1795 let hat_id = if let Some(target) = &event.target
1797 && self.registry.get(target).is_some()
1798 {
1799 target.clone()
1800 } else if let Some(hat) = self.registry.get_for_topic(event.topic.as_str()) {
1801 hat.id.clone()
1802 } else {
1803 continue;
1804 };
1805
1806 let list = if self.is_entrypoint_topic(event.topic.as_str()) {
1807 &mut entrypoint_hat_ids
1808 } else {
1809 &mut progressed_hat_ids
1810 };
1811 if !list.iter().any(|id| id == &hat_id) {
1812 list.push(hat_id);
1813 }
1814 }
1815 if progressed_hat_ids.is_empty() {
1821 entrypoint_hat_ids
1822 } else {
1823 progressed_hat_ids
1824 }
1825 }
1826
1827 fn effective_regular_events<'a>(&self, events: &'a [Event]) -> Vec<&'a Event> {
1828 let has_downstream_event = events
1829 .iter()
1830 .any(|event| !Self::is_kickoff_or_recovery_event(event.topic.as_str()));
1831 events
1832 .iter()
1833 .filter(|event| {
1834 !has_downstream_event || !Self::is_kickoff_or_recovery_event(event.topic.as_str())
1835 })
1836 .collect()
1837 }
1838
1839 fn is_kickoff_or_recovery_event(topic: &str) -> bool {
1840 topic == "task.start" || topic == "task.resume" || topic.strip_suffix(".start").is_some()
1841 }
1842
1843 fn is_entrypoint_topic(&self, topic: &str) -> bool {
1844 topic == "task.start"
1845 || topic == "task.resume"
1846 || topic.strip_suffix(".start").is_some()
1847 || self.config.event_loop.starting_event.as_deref() == Some(topic)
1848 }
1849
1850 fn peek_pending_regular_events(&self) -> Vec<Event> {
1851 let mut events = Vec::new();
1852 for hat_id in self.bus.hat_ids() {
1853 if let Some(pending) = self.bus.peek_pending(hat_id) {
1854 events.extend(pending.iter().cloned());
1855 }
1856 }
1857 events
1858 }
1859
1860 fn format_event(event: &Event) -> String {
1865 let topic = &event.topic;
1866 let payload = &event.payload;
1867
1868 if topic.as_str() == "task.start" || topic.as_str() == "task.resume" {
1869 format!(
1870 "Event: {} - <top-level-prompt>\n{}\n</top-level-prompt>",
1871 topic, payload
1872 )
1873 } else {
1874 format!("Event: {} - {}", topic, payload)
1875 }
1876 }
1877
1878 fn check_hat_exhaustion(&mut self, hat_id: &HatId, dropped: &[Event]) -> (bool, Option<Event>) {
1879 let Some(config) = self.registry.get_config(hat_id) else {
1880 return (false, None);
1881 };
1882 let Some(max) = config.max_activations else {
1883 return (false, None);
1884 };
1885
1886 let count = *self.state.hat_activation_counts.get(hat_id).unwrap_or(&0);
1887 if count < max {
1888 return (false, None);
1889 }
1890
1891 let should_emit = self.state.exhausted_hats.insert(hat_id.clone());
1893
1894 if !should_emit {
1895 return (true, None);
1897 }
1898
1899 let mut dropped_topics: Vec<String> = dropped.iter().map(|e| e.topic.to_string()).collect();
1900 dropped_topics.sort();
1901
1902 let payload = format!(
1903 "Hat '{hat}' exhausted.\n- max_activations: {max}\n- activations: {count}\n- dropped_topics:\n - {topics}",
1904 hat = hat_id.as_str(),
1905 max = max,
1906 count = count,
1907 topics = dropped_topics.join("\n - ")
1908 );
1909
1910 warn!(
1911 hat = %hat_id.as_str(),
1912 max_activations = max,
1913 activations = count,
1914 "Hat exhausted (max_activations reached)"
1915 );
1916
1917 (
1918 true,
1919 Some(Event::new(
1920 format!("{}.exhausted", hat_id.as_str()),
1921 payload,
1922 )),
1923 )
1924 }
1925
1926 fn record_hat_activations(&mut self, active_hat_ids: &[HatId]) {
1927 for hat_id in active_hat_ids {
1928 *self
1929 .state
1930 .hat_activation_counts
1931 .entry(hat_id.clone())
1932 .or_insert(0) += 1;
1933 }
1934 }
1935
1936 pub fn get_active_hat_id(&self) -> HatId {
1940 let pending_events = self.peek_pending_regular_events();
1941 if let Some(active_hat_id) = self
1942 .determine_active_hat_ids(&pending_events)
1943 .into_iter()
1944 .next()
1945 {
1946 return active_hat_id;
1947 }
1948 HatId::new("ralph")
1949 }
1950
1951 pub fn check_default_publishes(&mut self, hat_id: &HatId) {
1961 if let Some(config) = self.registry.get_config(hat_id)
1962 && let Some(default_topic) = &config.default_publishes
1963 {
1964 if default_topic.as_str() == self.config.event_loop.completion_promise {
1965 warn!(
1966 hat = %hat_id.as_str(),
1967 topic = %default_topic,
1968 "default_publishes matches completion_promise — requiring explicit agent event"
1969 );
1970 let resume_event = Event::new(
1971 "task.resume",
1972 format!(
1973 "Recovery: hat `{}` produced no events, and its default_publishes is the completion promise `{}`. Ralph will not complete silently; emit explicit completion evidence or a valid non-terminal event.",
1974 hat_id.as_str(),
1975 default_topic
1976 ),
1977 )
1978 .with_target(hat_id.clone());
1979 self.state.record_event(&resume_event);
1980 self.bus.publish(resume_event);
1981 return;
1982 }
1983
1984 let default_event = Event::new(default_topic.as_str(), "").with_source(hat_id.clone());
1985
1986 debug!(
1987 hat = %hat_id.as_str(),
1988 topic = %default_topic,
1989 "No events written by hat, injecting default_publishes event"
1990 );
1991
1992 self.state.record_event(&default_event);
1993 self.bus.publish(default_event);
1994 }
1995 }
1996
1997 pub fn bus(&mut self) -> &mut EventBus {
2002 &mut self.bus
2003 }
2004
2005 pub fn process_output(
2009 &mut self,
2010 hat_id: &HatId,
2011 output: &str,
2012 success: bool,
2013 ) -> Option<TerminationReason> {
2014 self.state.iteration += 1;
2015 self.state.last_hat = Some(hat_id.clone());
2016
2017 if let Some(interval_secs) = self.config.robot.checkin_interval_seconds
2019 && let Some(ref robot_service) = self.robot_service
2020 {
2021 let elapsed = self.state.elapsed();
2022 let interval = std::time::Duration::from_secs(interval_secs);
2023 let last = self
2024 .state
2025 .last_checkin_at
2026 .map(|t| t.elapsed())
2027 .unwrap_or(elapsed);
2028
2029 if last >= interval {
2030 let context = self.build_checkin_context(hat_id);
2031 match robot_service.send_checkin(self.state.iteration, elapsed, Some(&context)) {
2032 Ok(_) => {
2033 self.state.last_checkin_at = Some(std::time::Instant::now());
2034 debug!(iteration = self.state.iteration, "Sent robot check-in");
2035 }
2036 Err(e) => {
2037 warn!(error = %e, "Failed to send robot check-in");
2038 }
2039 }
2040 }
2041 }
2042
2043 self.diagnostics.log_orchestration(
2045 self.state.iteration,
2046 "loop",
2047 crate::diagnostics::OrchestrationEvent::IterationStarted,
2048 );
2049
2050 self.diagnostics.log_orchestration(
2052 self.state.iteration,
2053 "loop",
2054 crate::diagnostics::OrchestrationEvent::HatSelected {
2055 hat: hat_id.to_string(),
2056 reason: "process_output".to_string(),
2057 },
2058 );
2059
2060 if success {
2062 self.state.consecutive_failures = 0;
2063 } else {
2064 self.state.consecutive_failures += 1;
2065 }
2066
2067 let _ = output;
2068
2069 self.audit_file_modifications(hat_id);
2072
2073 self.check_termination()
2079 }
2080
2081 fn audit_file_modifications(&mut self, hat_id: &HatId) {
2087 let config = match self.registry.get_config(hat_id) {
2088 Some(c) => c,
2089 None => return,
2090 };
2091
2092 let has_write_restriction = config
2093 .disallowed_tools
2094 .iter()
2095 .any(|t| t == "Edit" || t == "Write");
2096
2097 if !has_write_restriction {
2098 return;
2099 }
2100
2101 let workspace = &self.config.core.workspace_root;
2102 let diff_output = std::process::Command::new("git")
2103 .args(["diff", "--stat", "HEAD"])
2104 .current_dir(workspace)
2105 .output();
2106
2107 match diff_output {
2108 Ok(output) if !output.stdout.is_empty() => {
2109 let diff_stat = String::from_utf8_lossy(&output.stdout).trim().to_string();
2110 warn!(
2111 hat = %hat_id.as_str(),
2112 diff = %diff_stat,
2113 "Hat modified files despite tool restrictions (scope violation)"
2114 );
2115
2116 let violation_topic = format!("{}.scope_violation", hat_id.as_str());
2117 let violation = Event::new(
2118 violation_topic.as_str(),
2119 format!(
2120 "Hat '{}' modified files with Edit/Write disallowed:\n{}",
2121 hat_id.as_str(),
2122 diff_stat
2123 ),
2124 );
2125 self.bus.publish(violation);
2126 }
2127 Err(e) => {
2128 debug!(error = %e, "Could not run git diff for file-modification audit");
2129 }
2130 _ => {} }
2132 }
2133
2134 fn extract_task_id(payload: &str) -> String {
2137 payload
2138 .lines()
2139 .next()
2140 .unwrap_or("unknown")
2141 .trim()
2142 .to_string()
2143 }
2144
2145 pub fn add_cost(&mut self, cost: f64) {
2147 self.state.cumulative_cost += cost;
2148 }
2149
2150 fn verify_scratchpad_complete(&self) -> Result<bool, std::io::Error> {
2157 if !self.ralph.active_scratchpad().enabled {
2159 return Ok(true);
2160 }
2161
2162 let scratchpad_path = self.scratchpad_path();
2163
2164 if !scratchpad_path.exists() {
2165 return Err(std::io::Error::new(
2166 std::io::ErrorKind::NotFound,
2167 "Scratchpad does not exist",
2168 ));
2169 }
2170
2171 let content = std::fs::read_to_string(scratchpad_path)?;
2172
2173 let has_pending = content
2174 .lines()
2175 .any(|line| line.trim_start().starts_with("- [ ]"));
2176
2177 Ok(!has_pending)
2178 }
2179
2180 fn current_loop_id(&self) -> Option<String> {
2185 self.loop_context
2186 .as_ref()
2187 .and_then(|ctx| {
2188 let marker_path = ctx.ralph_dir().join("current-loop-id");
2189 std::fs::read_to_string(&marker_path).ok()
2190 })
2191 .map(|id| id.trim().to_string())
2192 .filter(|id| !id.is_empty())
2193 }
2194
2195 fn filter_tasks_by_loop<'a>(
2197 tasks: Vec<&'a crate::task::Task>,
2198 loop_id: Option<&str>,
2199 ) -> Vec<&'a crate::task::Task> {
2200 match loop_id {
2201 Some(id) => tasks
2202 .into_iter()
2203 .filter(|t| t.loop_id.as_deref() == Some(id))
2204 .collect(),
2205 None => tasks,
2206 }
2207 }
2208
2209 fn verify_tasks_complete(&self) -> Result<bool, std::io::Error> {
2210 use crate::task_store::TaskStore;
2211
2212 let tasks_path = self.tasks_path();
2213
2214 if !tasks_path.exists() {
2216 return Ok(true);
2217 }
2218
2219 let store = TaskStore::load(&tasks_path)?;
2220 let current_loop_id = self.current_loop_id();
2221 let open = Self::filter_tasks_by_loop(store.open(), current_loop_id.as_deref());
2222 Ok(open.is_empty())
2223 }
2224
2225 fn build_checkin_context(&self, hat_id: &HatId) -> CheckinContext {
2227 let (open_tasks, closed_tasks) = self.count_tasks();
2228 CheckinContext {
2229 current_hat: Some(hat_id.as_str().to_string()),
2230 open_tasks,
2231 closed_tasks,
2232 cumulative_cost: self.state.cumulative_cost,
2233 }
2234 }
2235
2236 fn count_tasks(&self) -> (usize, usize) {
2241 use crate::task_store::TaskStore;
2242
2243 let tasks_path = self.tasks_path();
2244 if !tasks_path.exists() {
2245 return (0, 0);
2246 }
2247
2248 match TaskStore::load(&tasks_path) {
2249 Ok(store) => {
2250 let current_loop_id = self.current_loop_id();
2251 let all = Self::filter_tasks_by_loop(
2252 store.all().iter().collect(),
2253 current_loop_id.as_deref(),
2254 );
2255 let open = Self::filter_tasks_by_loop(store.open(), current_loop_id.as_deref());
2256 let closed = all.len() - open.len();
2257 (open.len(), closed)
2258 }
2259 Err(_) => (0, 0),
2260 }
2261 }
2262
2263 fn get_open_task_list(&self) -> Vec<String> {
2265 use crate::task_store::TaskStore;
2266
2267 let tasks_path = self.tasks_path();
2268 if let Ok(store) = TaskStore::load(&tasks_path) {
2269 let current_loop_id = self.current_loop_id();
2270 let open = Self::filter_tasks_by_loop(store.open(), current_loop_id.as_deref());
2271 return open
2272 .iter()
2273 .map(|t| format!("{}: {}", t.id, t.title))
2274 .collect();
2275 }
2276 vec![]
2277 }
2278
2279 fn warn_on_mutation_evidence(&self, evidence: &crate::event_parser::BackpressureEvidence) {
2280 let threshold = self.config.event_loop.mutation_score_warn_threshold;
2281
2282 match &evidence.mutants {
2283 Some(mutants) => {
2284 if let Some(reason) = Self::mutation_warning_reason(mutants, threshold) {
2285 warn!(
2286 reason = %reason,
2287 mutants_status = ?mutants.status,
2288 mutants_score = mutants.score_percent,
2289 mutants_threshold = threshold,
2290 "Mutation testing warning"
2291 );
2292 }
2293 }
2294 None => {
2295 if let Some(threshold) = threshold {
2296 warn!(
2297 mutants_threshold = threshold,
2298 "Mutation testing warning: missing mutation evidence in build.done payload"
2299 );
2300 }
2301 }
2302 }
2303 }
2304
2305 fn mutation_warning_reason(
2306 mutants: &MutationEvidence,
2307 threshold: Option<f64>,
2308 ) -> Option<String> {
2309 match mutants.status {
2310 MutationStatus::Fail => Some("mutation testing failed".to_string()),
2311 MutationStatus::Warn => Some(Self::format_mutation_message(
2312 "mutation score below threshold",
2313 mutants.score_percent,
2314 )),
2315 MutationStatus::Unknown => Some("mutation testing status unknown".to_string()),
2316 MutationStatus::Pass => {
2317 let threshold = threshold?;
2318
2319 match mutants.score_percent {
2320 Some(score) if score < threshold => Some(format!(
2321 "mutation score {:.2}% below threshold {:.2}%",
2322 score, threshold
2323 )),
2324 Some(_) => None,
2325 None => Some(format!(
2326 "mutation score missing (threshold {:.2}%)",
2327 threshold
2328 )),
2329 }
2330 }
2331 }
2332 }
2333
2334 fn format_mutation_message(message: &str, score: Option<f64>) -> String {
2335 match score {
2336 Some(score) => format!("{message} ({score:.2}%)"),
2337 None => message.to_string(),
2338 }
2339 }
2340
2341 fn parse_human_interact_context(payload: &str) -> Value {
2342 let mut context = match serde_json::from_str::<Value>(payload) {
2343 Ok(Value::Object(map)) => map,
2344 Ok(value) => {
2345 let mut map = Map::new();
2346 map.insert("question".to_string(), value);
2347 map
2348 }
2349 Err(_) => {
2350 let mut map = Map::new();
2351 map.insert("question".to_string(), Value::String(payload.to_string()));
2352 map
2353 }
2354 };
2355
2356 if !context.contains_key("question") {
2357 context.insert("question".to_string(), Value::String(payload.to_string()));
2358 }
2359
2360 Value::Object(context)
2361 }
2362
2363 fn is_restart_request_payload(payload: &str) -> bool {
2364 let payload = payload.to_ascii_lowercase();
2365 payload.contains("restart yourself") || payload.contains("restart ralph")
2366 }
2367
2368 fn is_restart_request_event(event: &Event) -> bool {
2369 matches!(event.topic.as_str(), "human.response" | "user.prompt")
2370 && Self::is_restart_request_payload(&event.payload)
2371 }
2372
2373 fn mark_restart_requested(&self, source: &str) {
2374 let restart_path =
2375 std::path::Path::new(&self.config.core.workspace_root).join(".ralph/restart-requested");
2376
2377 if let Some(parent) = restart_path.parent()
2378 && let Err(err) = std::fs::create_dir_all(parent)
2379 {
2380 warn!(
2381 error = %err,
2382 path = %parent.display(),
2383 "Failed to create restart-requested parent directory"
2384 );
2385 return;
2386 }
2387
2388 if let Err(err) = std::fs::write(&restart_path, source) {
2389 warn!(
2390 error = %err,
2391 path = %restart_path.display(),
2392 "Failed to write restart-requested signal"
2393 );
2394 return;
2395 }
2396
2397 info!(
2398 source,
2399 path = %restart_path.display(),
2400 "Restart requested from human text"
2401 );
2402 }
2403
2404 pub fn process_events_from_jsonl(&mut self) -> std::io::Result<ProcessedEvents> {
2416 let result = self.event_reader.read_new_events()?;
2417 self.process_parse_result(result)
2418 }
2419
2420 fn process_parse_result(
2426 &mut self,
2427 result: crate::event_reader::ParseResult,
2428 ) -> std::io::Result<ProcessedEvents> {
2429 for malformed in &result.malformed {
2431 let payload = format!(
2432 "Line {}: {}\nContent: {}",
2433 malformed.line_number, malformed.error, &malformed.content
2434 );
2435 let event = Event::new("event.malformed", &payload);
2436 self.bus.publish(event);
2437 self.state.consecutive_malformed_events += 1;
2438 warn!(
2439 line = malformed.line_number,
2440 consecutive = self.state.consecutive_malformed_events,
2441 "Malformed event line detected"
2442 );
2443 }
2444
2445 if !result.events.is_empty() {
2447 self.state.consecutive_malformed_events = 0;
2448 }
2449
2450 if result.events.is_empty() && result.malformed.is_empty() {
2451 return Ok(ProcessedEvents {
2452 had_events: false,
2453 had_plan_events: false,
2454 human_interact_context: None,
2455 has_orphans: false,
2456 });
2457 }
2458
2459 let events: Vec<_> = result
2463 .events
2464 .into_iter()
2465 .filter(|event| event.topic.as_str() != "iteration.summary")
2466 .collect();
2467
2468 let events = if self.config.event_loop.enforce_hat_scope {
2471 let active_hats = self.state.last_active_hat_ids.clone();
2472 let (in_scope, out_of_scope): (Vec<_>, Vec<_>) =
2473 events.into_iter().partition(|event| {
2474 if active_hats.is_empty() {
2475 return true; }
2477 active_hats
2478 .iter()
2479 .any(|hat_id| self.registry.can_publish(hat_id, event.topic.as_str()))
2480 });
2481
2482 for event in &out_of_scope {
2483 let violation_hat = active_hats.first().map(|h| h.as_str()).unwrap_or("unknown");
2484 warn!(
2485 active_hats = ?active_hats,
2486 topic = %event.topic,
2487 "Scope violation: active hat(s) cannot publish this topic — dropping event"
2488 );
2489 let violation_topic = format!("{}.scope_violation", violation_hat);
2490 let violation_payload = format!(
2491 "Attempted to publish '{}': {}",
2492 event.topic,
2493 event.payload.clone().unwrap_or_default()
2494 );
2495 let violation = Event::new(violation_topic, violation_payload);
2496 self.bus.publish(violation);
2497 }
2498
2499 in_scope
2500 } else {
2501 events
2502 };
2503 let mut has_orphans = false;
2506
2507 let mut validated_events = Vec::new();
2509 let completion_topic = self.config.event_loop.completion_promise.clone();
2510 let cancellation_topic = self.config.event_loop.cancellation_promise.clone();
2511 let total_events = events.len();
2512 for (index, event) in events.into_iter().enumerate() {
2513 let payload = event.payload.clone().unwrap_or_default();
2514
2515 if !cancellation_topic.is_empty() && event.topic.as_str() == cancellation_topic {
2517 info!(
2518 payload = %payload,
2519 "loop.cancel event detected — scheduling graceful termination"
2520 );
2521 self.state.cancellation_requested = true;
2522 continue;
2524 }
2525
2526 if event.topic.as_str() == "human.guidance" {
2527 self.state.unacknowledged_guidance.push(payload.clone());
2528 validated_events.push(Event::new(event.topic.as_str(), &payload));
2529 continue;
2530 }
2531
2532 if event.topic.as_str() == "human.guidance.ack" {
2533 info!(
2534 payload = %payload,
2535 guidance_count = self.state.unacknowledged_guidance.len(),
2536 "human.guidance acknowledged"
2537 );
2538 self.state.unacknowledged_guidance.clear();
2539 validated_events.push(Event::new(event.topic.as_str(), &payload));
2540 continue;
2541 }
2542
2543 if event.topic.as_str() == completion_topic {
2544 if index + 1 == total_events {
2545 self.state.completion_requested = true;
2546 self.diagnostics.log_orchestration(
2547 self.state.iteration,
2548 "jsonl",
2549 crate::diagnostics::OrchestrationEvent::EventPublished {
2550 topic: event.topic.clone(),
2551 },
2552 );
2553 info!(
2554 topic = %event.topic,
2555 "Completion event detected in JSONL"
2556 );
2557 } else {
2558 warn!(
2559 topic = %event.topic,
2560 index = index,
2561 total_events = total_events,
2562 "Completion event ignored because it was not the last event"
2563 );
2564 }
2565 continue;
2566 }
2567
2568 if event.topic == "build.done" {
2569 if let Some(evidence) = EventParser::parse_backpressure_evidence(&payload) {
2571 if evidence.all_passed() {
2572 self.warn_on_mutation_evidence(&evidence);
2573 validated_events.push(Event::new(event.topic.as_str(), &payload));
2574 } else {
2575 warn!(
2577 tests = evidence.tests_passed,
2578 lint = evidence.lint_passed,
2579 typecheck = evidence.typecheck_passed,
2580 audit = evidence.audit_passed,
2581 coverage = evidence.coverage_passed,
2582 complexity = evidence.complexity_score,
2583 duplication = evidence.duplication_passed,
2584 performance = evidence.performance_regression,
2585 specs = evidence.specs_verified,
2586 "build.done rejected: backpressure checks failed"
2587 );
2588
2589 let complexity = evidence
2590 .complexity_score
2591 .map(|value| format!("{value:.2}"))
2592 .unwrap_or_else(|| "missing".to_string());
2593 let performance = match evidence.performance_regression {
2594 Some(true) => "regression".to_string(),
2595 Some(false) => "pass".to_string(),
2596 None => "missing".to_string(),
2597 };
2598 let specs = match evidence.specs_verified {
2599 Some(true) => "pass".to_string(),
2600 Some(false) => "fail".to_string(),
2601 None => "not reported".to_string(),
2602 };
2603
2604 self.diagnostics.log_orchestration(
2605 self.state.iteration,
2606 "jsonl",
2607 crate::diagnostics::OrchestrationEvent::BackpressureTriggered {
2608 reason: format!(
2609 "backpressure checks failed: tests={}, lint={}, typecheck={}, audit={}, coverage={}, complexity={}, duplication={}, performance={}, specs={}",
2610 evidence.tests_passed,
2611 evidence.lint_passed,
2612 evidence.typecheck_passed,
2613 evidence.audit_passed,
2614 evidence.coverage_passed,
2615 complexity,
2616 evidence.duplication_passed,
2617 performance,
2618 specs
2619 ),
2620 },
2621 );
2622
2623 validated_events.push(Event::new(
2624 "build.blocked",
2625 "Backpressure checks failed. Fix tests/lint/typecheck/audit/coverage/complexity/duplication/specs before emitting build.done.",
2626 ));
2627 }
2628 } else {
2629 warn!("build.done rejected: missing backpressure evidence");
2631
2632 self.diagnostics.log_orchestration(
2633 self.state.iteration,
2634 "jsonl",
2635 crate::diagnostics::OrchestrationEvent::BackpressureTriggered {
2636 reason: "missing backpressure evidence".to_string(),
2637 },
2638 );
2639
2640 validated_events.push(Event::new(
2641 "build.blocked",
2642 "Missing backpressure evidence. Include 'tests: pass', 'lint: pass', 'typecheck: pass', 'audit: pass', 'coverage: pass', 'complexity: <score>', 'duplication: pass', 'performance: pass' (optional), 'specs: pass' (optional) in build.done payload.",
2643 ));
2644 }
2645 } else if event.topic == "review.done" && !event.is_wave_event() {
2646 if let Some(evidence) = EventParser::parse_review_evidence(&payload) {
2650 if evidence.is_verified() {
2651 validated_events.push(Event::new(event.topic.as_str(), &payload));
2652 } else {
2653 warn!(
2655 tests = evidence.tests_passed,
2656 build = evidence.build_passed,
2657 "review.done rejected: verification checks failed"
2658 );
2659
2660 self.diagnostics.log_orchestration(
2661 self.state.iteration,
2662 "jsonl",
2663 crate::diagnostics::OrchestrationEvent::BackpressureTriggered {
2664 reason: format!(
2665 "review verification failed: tests={}, build={}",
2666 evidence.tests_passed, evidence.build_passed
2667 ),
2668 },
2669 );
2670
2671 validated_events.push(Event::new(
2672 "review.blocked",
2673 "Review verification failed. Run tests and build before emitting review.done.",
2674 ));
2675 }
2676 } else {
2677 warn!("review.done rejected: missing verification evidence");
2679
2680 self.diagnostics.log_orchestration(
2681 self.state.iteration,
2682 "jsonl",
2683 crate::diagnostics::OrchestrationEvent::BackpressureTriggered {
2684 reason: "missing review verification evidence".to_string(),
2685 },
2686 );
2687
2688 validated_events.push(Event::new(
2689 "review.blocked",
2690 "Missing verification evidence. Include 'tests: pass' and 'build: pass' in review.done payload.",
2691 ));
2692 }
2693 } else if event.topic == "verify.passed" {
2694 if let Some(report) = EventParser::parse_quality_report(&payload) {
2695 if report.meets_thresholds() {
2696 validated_events.push(Event::new(event.topic.as_str(), &payload));
2697 } else {
2698 let failed = report.failed_dimensions();
2699 let reason = if failed.is_empty() {
2700 "quality thresholds failed".to_string()
2701 } else {
2702 format!("quality thresholds failed: {}", failed.join(", "))
2703 };
2704
2705 warn!(
2706 failed_dimensions = ?failed,
2707 "verify.passed rejected: quality thresholds failed"
2708 );
2709
2710 self.diagnostics.log_orchestration(
2711 self.state.iteration,
2712 "jsonl",
2713 crate::diagnostics::OrchestrationEvent::BackpressureTriggered {
2714 reason,
2715 },
2716 );
2717
2718 validated_events.push(Event::new(
2719 "verify.failed",
2720 "Quality thresholds failed. Include quality.tests, quality.coverage, quality.lint, quality.audit, quality.mutation, quality.complexity with thresholds in verify.passed payload.",
2721 ));
2722 }
2723 } else {
2724 warn!("verify.passed rejected: missing quality report");
2726
2727 self.diagnostics.log_orchestration(
2728 self.state.iteration,
2729 "jsonl",
2730 crate::diagnostics::OrchestrationEvent::BackpressureTriggered {
2731 reason: "missing quality report".to_string(),
2732 },
2733 );
2734
2735 validated_events.push(Event::new(
2736 "verify.failed",
2737 "Missing quality report. Include quality.tests, quality.coverage, quality.lint, quality.audit, quality.mutation, quality.complexity in verify.passed payload.",
2738 ));
2739 }
2740 } else if event.topic == "verify.failed" {
2741 if EventParser::parse_quality_report(&payload).is_none() {
2742 warn!("verify.failed missing quality report");
2743 }
2744 validated_events.push(Event::new(event.topic.as_str(), &payload));
2745 } else {
2746 validated_events.push(Event::new(event.topic.as_str(), &payload));
2748 }
2749 }
2750
2751 let blocked_events: Vec<_> = validated_events
2753 .iter()
2754 .filter(|e| e.topic == "build.blocked".into())
2755 .collect();
2756
2757 for blocked_event in &blocked_events {
2758 let task_id = Self::extract_task_id(&blocked_event.payload);
2759
2760 let count = self
2761 .state
2762 .task_block_counts
2763 .entry(task_id.clone())
2764 .or_insert(0);
2765 *count += 1;
2766
2767 debug!(
2768 task_id = %task_id,
2769 block_count = *count,
2770 "Task blocked"
2771 );
2772
2773 if *count >= 3 && !self.state.abandoned_tasks.contains(&task_id) {
2775 warn!(
2776 task_id = %task_id,
2777 "Task abandoned after 3 consecutive blocks"
2778 );
2779
2780 self.state.abandoned_tasks.push(task_id.clone());
2781
2782 self.diagnostics.log_orchestration(
2783 self.state.iteration,
2784 "jsonl",
2785 crate::diagnostics::OrchestrationEvent::TaskAbandoned {
2786 reason: format!(
2787 "3 consecutive build.blocked events for task '{}'",
2788 task_id
2789 ),
2790 },
2791 );
2792
2793 let abandoned_event = Event::new(
2794 "build.task.abandoned",
2795 format!(
2796 "Task '{}' abandoned after 3 consecutive build.blocked events",
2797 task_id
2798 ),
2799 );
2800
2801 self.bus.publish(abandoned_event);
2802 }
2803 }
2804
2805 let has_blocked_event = !blocked_events.is_empty();
2807
2808 if has_blocked_event {
2809 self.state.consecutive_blocked += 1;
2810 } else {
2811 self.state.consecutive_blocked = 0;
2812 self.state.last_blocked_hat = None;
2813 }
2814
2815 let mut response_event = None;
2819 let mut human_interact_context = None;
2820 let mut events_arrived_during_robot_wait = Vec::new();
2821 let ask_human_idx = validated_events
2822 .iter()
2823 .position(|e| e.topic == "human.interact".into());
2824
2825 if let Some(idx) = ask_human_idx {
2826 let ask_event = &validated_events[idx];
2827 let payload = ask_event.payload.clone();
2828
2829 let mut context = match Self::parse_human_interact_context(&payload) {
2830 Value::Object(map) => map,
2831 _ => Map::new(),
2832 };
2833 context
2834 .entry("iteration".to_string())
2835 .or_insert_with(|| Value::from(self.state.iteration));
2836 context.entry("hat".to_string()).or_insert_with(|| {
2837 ask_event
2838 .source
2839 .as_ref()
2840 .or(ask_event.target.as_ref())
2841 .map(|hat| Value::String(hat.as_str().to_string()))
2842 .unwrap_or_else(|| Value::String(self.get_active_hat_id().as_str().to_string()))
2843 });
2844
2845 if let Some(ref robot_service) = self.robot_service {
2846 let robot_payload = Value::Object(context.clone()).to_string();
2847 info!(
2848 payload = %payload,
2849 "human.interact event detected — sending question via robot service"
2850 );
2851
2852 let send_ok = match robot_service.send_question(&robot_payload) {
2854 Ok(_message_id) => true,
2855 Err(e) => {
2856 warn!(
2857 error = %e,
2858 "Failed to send human.interact question after retries — treating as timeout"
2859 );
2860 self.diagnostics.log_error(
2862 self.state.iteration,
2863 "telegram",
2864 crate::diagnostics::DiagnosticError::TelegramSendError {
2865 operation: "send_question".to_string(),
2866 error: e.to_string(),
2867 retry_count: 3,
2868 },
2869 );
2870 context.insert(
2871 "outcome".to_string(),
2872 Value::String("send_failure".to_string()),
2873 );
2874 context.insert("error".to_string(), Value::String(e.to_string()));
2875 false
2876 }
2877 };
2878
2879 if send_ok {
2882 let events_path = self
2885 .loop_context
2886 .as_ref()
2887 .and_then(|ctx| {
2888 std::fs::read_to_string(ctx.current_events_marker())
2889 .ok()
2890 .map(|s| ctx.workspace().join(s.trim()))
2891 })
2892 .or_else(|| {
2893 std::fs::read_to_string(".ralph/current-events")
2894 .ok()
2895 .map(|s| PathBuf::from(s.trim()))
2896 })
2897 .unwrap_or_else(|| {
2898 self.loop_context
2899 .as_ref()
2900 .map(|ctx| ctx.events_path())
2901 .unwrap_or_else(|| self.event_reader.path().to_path_buf())
2902 });
2903
2904 match robot_service.wait_for_response(&events_path) {
2905 Ok(Some(response)) => {
2906 info!(
2907 response = %response,
2908 "Received human.response — continuing loop"
2909 );
2910 context.insert(
2911 "outcome".to_string(),
2912 Value::String("response".to_string()),
2913 );
2914 context.insert("response".to_string(), Value::String(response.clone()));
2915 let event = Event::new("human.response", &response);
2916 if robot_service.response_events_are_durable() {
2917 events_arrived_during_robot_wait.extend(
2918 self.skip_consumed_robot_response_event(
2919 &events_path,
2920 &response,
2921 ),
2922 );
2923 } else {
2924 match self.append_robot_response_event(&events_path, &event) {
2925 Ok(events) => {
2926 events_arrived_during_robot_wait.extend(events);
2927 }
2928 Err(error) => {
2929 warn!(
2930 error = %error,
2931 path = %events_path.display(),
2932 "Failed to record human.response event"
2933 );
2934 }
2935 }
2936 }
2937 response_event = Some(event);
2938 }
2939 Ok(None) => {
2940 warn!(
2941 timeout_secs = robot_service.timeout_secs(),
2942 "Human response timeout — injecting human.timeout event"
2943 );
2944 context.insert(
2945 "outcome".to_string(),
2946 Value::String("timeout".to_string()),
2947 );
2948 context.insert(
2949 "timeout_seconds".to_string(),
2950 Value::from(robot_service.timeout_secs()),
2951 );
2952 let timeout_event = Event::new(
2953 "human.timeout",
2954 format!(
2955 "No response after {}s. Original question: {}",
2956 robot_service.timeout_secs(),
2957 payload
2958 ),
2959 );
2960 response_event = Some(timeout_event);
2961 }
2962 Err(e) => {
2963 warn!(
2964 error = %e,
2965 "Error waiting for human response — injecting human.timeout event"
2966 );
2967 context.insert(
2968 "outcome".to_string(),
2969 Value::String("wait_error".to_string()),
2970 );
2971 context.insert("error".to_string(), Value::String(e.to_string()));
2972 let timeout_event = Event::new(
2973 "human.timeout",
2974 format!(
2975 "Error waiting for response: {}. Original question: {}",
2976 e, payload
2977 ),
2978 );
2979 response_event = Some(timeout_event);
2980 }
2981 }
2982 }
2983 } else {
2984 debug!(
2985 "human.interact event detected but no robot service active — passing through"
2986 );
2987 context.insert(
2988 "outcome".to_string(),
2989 Value::String("no_robot_service".to_string()),
2990 );
2991 }
2992
2993 human_interact_context = Some(Value::Object(context));
2994 }
2995
2996 let intervening_count = events_arrived_during_robot_wait.len();
2997 for (index, event) in events_arrived_during_robot_wait.into_iter().enumerate() {
2998 let payload = event.payload.clone();
2999
3000 if !cancellation_topic.is_empty() && event.topic.as_str() == cancellation_topic {
3001 info!(
3002 payload = %payload,
3003 "loop.cancel event detected during robot wait — scheduling graceful termination"
3004 );
3005 self.state.cancellation_requested = true;
3006 continue;
3007 }
3008
3009 if event.topic.as_str() == "human.guidance" {
3010 self.state.unacknowledged_guidance.push(payload);
3011 validated_events.push(event);
3012 continue;
3013 }
3014
3015 if event.topic.as_str() == "human.guidance.ack" {
3016 info!(
3017 payload = %payload,
3018 guidance_count = self.state.unacknowledged_guidance.len(),
3019 "human.guidance acknowledged during robot wait"
3020 );
3021 self.state.unacknowledged_guidance.clear();
3022 validated_events.push(event);
3023 continue;
3024 }
3025
3026 if event.topic.as_str() == completion_topic {
3027 if index + 1 == intervening_count {
3028 self.state.completion_requested = true;
3029 self.diagnostics.log_orchestration(
3030 self.state.iteration,
3031 "jsonl",
3032 crate::diagnostics::OrchestrationEvent::EventPublished {
3033 topic: event.topic.to_string(),
3034 },
3035 );
3036 info!(
3037 topic = %event.topic,
3038 "Completion event detected during robot wait"
3039 );
3040 } else {
3041 warn!(
3042 topic = %event.topic,
3043 index = index,
3044 total_events = intervening_count,
3045 "Completion event during robot wait ignored because it was not the last event"
3046 );
3047 }
3048 continue;
3049 }
3050
3051 validated_events.push(event);
3052 }
3053
3054 let restart_requested = validated_events.iter().any(Self::is_restart_request_event)
3055 || response_event
3056 .as_ref()
3057 .is_some_and(Self::is_restart_request_event);
3058 if restart_requested {
3059 self.mark_restart_requested("human_text");
3060 }
3061
3062 let had_events = !validated_events.is_empty();
3064 let had_plan_events = validated_events
3065 .iter()
3066 .any(|event| event.topic.as_str().starts_with("plan."));
3067
3068 for event in validated_events {
3073 self.state.record_event(&event);
3075
3076 self.diagnostics.log_orchestration(
3077 self.state.iteration,
3078 "jsonl",
3079 crate::diagnostics::OrchestrationEvent::EventPublished {
3080 topic: event.topic.to_string(),
3081 },
3082 );
3083
3084 if !self.registry.has_subscriber(event.topic.as_str()) {
3085 has_orphans = true;
3086 }
3087
3088 debug!(
3089 topic = %event.topic,
3090 "Publishing event from JSONL"
3091 );
3092 self.bus.publish(event);
3093 }
3094
3095 if let Some(response) = response_event {
3097 self.state.record_event(&response);
3098 info!(
3099 topic = %response.topic,
3100 "Publishing human.response event from robot service"
3101 );
3102 self.bus.publish(response);
3103 }
3104
3105 Ok(ProcessedEvents {
3106 had_events,
3107 had_plan_events,
3108 human_interact_context,
3109 has_orphans,
3110 })
3111 }
3112
3113 fn append_robot_response_event(
3114 &mut self,
3115 events_path: &Path,
3116 event: &Event,
3117 ) -> std::io::Result<Vec<Event>> {
3118 if let Some(parent) = events_path.parent() {
3119 std::fs::create_dir_all(parent)?;
3120 }
3121
3122 let reader_position = self.event_reader.position();
3123 let before_len = std::fs::metadata(events_path)
3124 .map(|metadata| metadata.len())
3125 .unwrap_or(0);
3126 let line = Self::serialize_jsonl_event(event)?;
3127 let mut file = std::fs::OpenOptions::new()
3128 .create(true)
3129 .append(true)
3130 .open(events_path)?;
3131 writeln!(file, "{line}")?;
3132 file.flush()?;
3133 let after_len = std::fs::metadata(events_path)?.len();
3134
3135 if self.event_reader.path() == events_path && self.event_reader.position() == before_len {
3136 self.event_reader.set_position(after_len);
3137 return Ok(Vec::new());
3138 }
3139
3140 if self.event_reader.path() == events_path && reader_position < before_len {
3141 let intervening_events =
3142 Self::read_jsonl_events_between(events_path, reader_position, before_len)?;
3143 self.event_reader.set_position(after_len);
3144 return Ok(intervening_events);
3145 }
3146
3147 Ok(Vec::new())
3148 }
3149
3150 fn serialize_jsonl_event(event: &Event) -> std::io::Result<String> {
3151 let mut value = serde_json::json!({
3152 "topic": event.topic.as_str(),
3153 "payload": event.payload,
3154 "ts": chrono::Utc::now().to_rfc3339(),
3155 });
3156
3157 if let Some(wave_id) = &event.wave_id {
3158 value["wave_id"] = Value::String(wave_id.clone());
3159 }
3160 if let Some(wave_index) = event.wave_index {
3161 value["wave_index"] = Value::from(wave_index);
3162 }
3163 if let Some(wave_total) = event.wave_total {
3164 value["wave_total"] = Value::from(wave_total);
3165 }
3166
3167 serde_json::to_string(&value).map_err(std::io::Error::other)
3168 }
3169
3170 fn skip_consumed_robot_response_event(
3171 &mut self,
3172 events_path: &Path,
3173 response: &str,
3174 ) -> Vec<Event> {
3175 if self.event_reader.path() != events_path {
3176 return Vec::new();
3177 }
3178
3179 let Ok(mut file) = std::fs::File::open(events_path) else {
3180 return Vec::new();
3181 };
3182 let mut current_pos = self.event_reader.position();
3183 if file.seek(SeekFrom::Start(current_pos)).is_err() {
3184 return Vec::new();
3185 }
3186
3187 let mut intervening_events = Vec::new();
3188 let reader = BufReader::new(file);
3189 for line in reader.lines() {
3190 let Ok(line) = line else {
3191 return intervening_events;
3192 };
3193 current_pos += line.len() as u64 + 1;
3194
3195 if line.trim().is_empty() {
3196 continue;
3197 }
3198
3199 let Ok(event) = serde_json::from_str::<crate::event_reader::Event>(&line) else {
3200 continue;
3201 };
3202 if event.topic == "human.response" && event.payload.as_deref() == Some(response) {
3203 self.event_reader.set_position(current_pos);
3204 return intervening_events;
3205 }
3206 intervening_events.push(event.into());
3207 }
3208 intervening_events
3209 }
3210
3211 fn read_jsonl_events_between(
3212 events_path: &Path,
3213 start: u64,
3214 end: u64,
3215 ) -> std::io::Result<Vec<Event>> {
3216 let mut file = std::fs::File::open(events_path)?;
3217 file.seek(SeekFrom::Start(start))?;
3218
3219 let reader = BufReader::new(file);
3220 let mut current_pos = start;
3221 let mut events = Vec::new();
3222 for line in reader.lines() {
3223 let line = line?;
3224 current_pos += line.len() as u64 + 1;
3225 if current_pos > end {
3226 break;
3227 }
3228 if line.trim().is_empty() {
3229 continue;
3230 }
3231 if let Ok(event) = serde_json::from_str::<crate::event_reader::Event>(&line) {
3232 events.push(event.into());
3233 }
3234 }
3235 Ok(events)
3236 }
3237
3238 pub fn process_events_from_jsonl_with_waves(
3244 &mut self,
3245 ) -> std::io::Result<ProcessedEventsWithWaves> {
3246 let result = self.event_reader.read_new_events()?;
3247
3248 let (wave_events, regular_events): (Vec<_>, Vec<_>) =
3256 result.events.into_iter().partition(|e| {
3257 e.wave_id.is_some()
3258 && self
3259 .registry
3260 .find_by_trigger(e.topic.as_str())
3261 .and_then(|hat_id| self.registry.get_config(hat_id))
3262 .is_some_and(|hat_config| hat_config.concurrency > 1)
3263 });
3264
3265 if !wave_events.is_empty() {
3266 debug!(
3267 wave_count = wave_events.len(),
3268 regular_count = regular_events.len(),
3269 "Partitioned wave events from regular events"
3270 );
3271 }
3272
3273 let regular_result = crate::event_reader::ParseResult {
3276 events: regular_events,
3277 malformed: result.malformed,
3278 };
3279 let processed = self.process_parse_result(regular_result)?;
3280
3281 Ok(ProcessedEventsWithWaves {
3282 processed,
3283 wave_events,
3284 })
3285 }
3286
3287 pub fn check_ralph_completion(&self, output: &str) -> bool {
3291 let events = EventParser::new().parse(output);
3292 events
3293 .iter()
3294 .any(|event| event.topic.as_str() == self.config.event_loop.completion_promise)
3295 }
3296
3297 pub fn publish_terminate_event(&mut self, reason: &TerminationReason) -> Event {
3304 self.stop_robot_service();
3306
3307 let elapsed = self.state.elapsed();
3308 let duration_str = format_duration(elapsed);
3309
3310 let payload = format!(
3311 "## Reason\n{}\n\n## Status\n{}\n\n## Summary\n- Iterations: {}\n- Duration: {}\n- Exit code: {}",
3312 reason.as_str(),
3313 termination_status_text(reason),
3314 self.state.iteration,
3315 duration_str,
3316 reason.exit_code()
3317 );
3318
3319 let event = Event::new("loop.terminate", &payload);
3320
3321 self.bus.publish(event.clone());
3323
3324 info!(
3325 reason = %reason.as_str(),
3326 iterations = self.state.iteration,
3327 duration = %duration_str,
3328 "Wrapping up: {}. {} iterations in {}.",
3329 reason.as_str(),
3330 self.state.iteration,
3331 duration_str
3332 );
3333
3334 event
3335 }
3336
3337 pub fn robot_shutdown_flag(&self) -> Option<Arc<AtomicBool>> {
3342 self.robot_service.as_ref().map(|s| s.shutdown_flag())
3343 }
3344
3345 fn stop_robot_service(&mut self) {
3349 if let Some(service) = self.robot_service.take() {
3350 service.stop();
3351 }
3352 }
3353
3354 pub fn check_for_user_prompt(&self, events: &[Event]) -> Option<UserPrompt> {
3362 events
3363 .iter()
3364 .find(|e| e.topic.as_str() == "user.prompt")
3365 .map(|e| UserPrompt {
3366 id: Self::extract_prompt_id(&e.payload),
3367 text: e.payload.clone(),
3368 })
3369 }
3370
3371 fn extract_prompt_id(payload: &str) -> String {
3376 if let Some(start) = payload.find("id=\"")
3378 && let Some(end) = payload[start + 4..].find('"')
3379 {
3380 return payload[start + 4..start + 4 + end].to_string();
3381 }
3382
3383 format!("q{}", Self::generate_prompt_id())
3385 }
3386
3387 fn generate_prompt_id() -> String {
3390 use std::time::{SystemTime, UNIX_EPOCH};
3391 let nanos = SystemTime::now()
3392 .duration_since(UNIX_EPOCH)
3393 .unwrap()
3394 .as_nanos();
3395 format!("{:x}", nanos % 0xFFFF_FFFF)
3396 }
3397}
3398
3399#[derive(Debug, Clone)]
3403pub struct UserPrompt {
3404 pub id: String,
3406 pub text: String,
3408}
3409
3410fn format_duration(d: Duration) -> String {
3412 let total_secs = d.as_secs();
3413 let hours = total_secs / 3600;
3414 let minutes = (total_secs % 3600) / 60;
3415 let seconds = total_secs % 60;
3416
3417 if hours > 0 {
3418 format!("{}h {}m {}s", hours, minutes, seconds)
3419 } else if minutes > 0 {
3420 format!("{}m {}s", minutes, seconds)
3421 } else {
3422 format!("{}s", seconds)
3423 }
3424}
3425
3426fn termination_status_text(reason: &TerminationReason) -> &'static str {
3428 match reason {
3429 TerminationReason::CompletionPromise => "All tasks completed successfully.",
3430 TerminationReason::MaxIterations => "Stopped at iteration limit.",
3431 TerminationReason::MaxRuntime => "Stopped at runtime limit.",
3432 TerminationReason::MaxCost => "Stopped at cost limit.",
3433 TerminationReason::ConsecutiveFailures => "Too many consecutive failures.",
3434 TerminationReason::LoopThrashing => {
3435 "Loop thrashing detected - same hat repeatedly blocked."
3436 }
3437 TerminationReason::LoopStale => {
3438 "Stale loop detected - same topic emitted 3+ times consecutively."
3439 }
3440 TerminationReason::ValidationFailure => "Too many consecutive malformed JSONL events.",
3441 TerminationReason::Stopped => "Manually stopped.",
3442 TerminationReason::Interrupted => "Interrupted by signal.",
3443 TerminationReason::RestartRequested => "Restarting by human request.",
3444 TerminationReason::WorkspaceGone => "Workspace directory removed externally.",
3445 TerminationReason::Cancelled => "Cancelled gracefully (human rejection or timeout).",
3446 }
3447}