From 0847541496589a430e85c5d9ac55be8783ab1386 Mon Sep 17 00:00:00 2001 From: carlos4s <71615127+carlos4s@users.noreply.github.com> Date: Mon, 18 May 2026 03:29:34 +0000 Subject: [PATCH 1/2] keep systemd core out of push-to-talk --- crates/genie-core/src/main.rs | 47 ++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/genie-core/src/main.rs b/crates/genie-core/src/main.rs index 99bd1c6d..59d9d8fe 100644 --- a/crates/genie-core/src/main.rs +++ b/crates/genie-core/src/main.rs @@ -170,13 +170,24 @@ async fn main() -> Result<()> { let voice_requested = std::env::args().any(|a| a == "--voice") || std::env::var("GENIEPOD_VOICE").unwrap_or_default() == "1" || config.core.voice_enabled; + let wakeword_available = + !config.core.wakeword_script.as_os_str().is_empty() && config.core.wakeword_script.exists(); + let voice_runnable = should_enter_voice_mode(voice_requested, interactive, wakeword_available); // Whether the running binary actually has the voice subsystem compiled in. // When voice was requested but the binary is chat-only (issue #41 feature // gate), emit one warning and fall through to the chat path so an existing // voice-tagged `geniepod.toml` still deploys cleanly on a chat-only build. #[cfg(feature = "voice")] - let voice_mode = voice_requested; + let voice_mode = { + if voice_requested && !voice_runnable { + tracing::warn!( + "push-to-talk voice mode requested without an interactive terminal and no wakeword script is available; \ + running HTTP API only" + ); + } + voice_runnable + }; #[cfg(not(feature = "voice"))] let voice_mode = { if voice_requested { @@ -187,6 +198,7 @@ async fn main() -> Result<()> { enable the voice loop." ); } + let _ = voice_runnable; false }; @@ -360,6 +372,39 @@ fn atty_check() -> bool { } } +fn should_enter_voice_mode( + voice_requested: bool, + stdin_interactive: bool, + wakeword_available: bool, +) -> bool { + voice_requested && (stdin_interactive || wakeword_available) +} + +#[cfg(test)] +mod voice_mode_tests { + use super::should_enter_voice_mode; + + #[test] + fn systemd_push_to_talk_falls_back_to_http() { + assert!(!should_enter_voice_mode(true, false, false)); + } + + #[test] + fn terminal_push_to_talk_still_runs() { + assert!(should_enter_voice_mode(true, true, false)); + } + + #[test] + fn wakeword_voice_can_run_without_terminal() { + assert!(should_enter_voice_mode(true, false, true)); + } + + #[test] + fn voice_disabled_stays_http_only() { + assert!(!should_enter_voice_mode(false, true, true)); + } +} + // The only tests in this bin target are local_http_host_* which exercise the // telegram adapter's bind-host helper. Gate the whole module so chat-only / // no-telegram builds don't emit an `unused_imports` lint on `use super::*;`. From 9ab3e712ca548a65e9837f92773388c83cfcc5e3 Mon Sep 17 00:00:00 2001 From: carlos4s <71615127+carlos4s@users.noreply.github.com> Date: Mon, 18 May 2026 03:36:00 +0000 Subject: [PATCH 2/2] extract core startup mode decision --- crates/genie-core/src/lib.rs | 1 + crates/genie-core/src/main.rs | 42 ++-------- crates/genie-core/src/runtime_mode.rs | 108 ++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 37 deletions(-) create mode 100644 crates/genie-core/src/runtime_mode.rs diff --git a/crates/genie-core/src/lib.rs b/crates/genie-core/src/lib.rs index 2192cca2..f97d25d5 100644 --- a/crates/genie-core/src/lib.rs +++ b/crates/genie-core/src/lib.rs @@ -52,6 +52,7 @@ pub mod prompt; pub mod reasoning; pub mod repl; pub mod runtime_contract; +pub mod runtime_mode; pub mod security; pub mod server; pub mod skills; diff --git a/crates/genie-core/src/main.rs b/crates/genie-core/src/main.rs index 59d9d8fe..69aef1b8 100644 --- a/crates/genie-core/src/main.rs +++ b/crates/genie-core/src/main.rs @@ -172,7 +172,8 @@ async fn main() -> Result<()> { || config.core.voice_enabled; let wakeword_available = !config.core.wakeword_script.as_os_str().is_empty() && config.core.wakeword_script.exists(); - let voice_runnable = should_enter_voice_mode(voice_requested, interactive, wakeword_available); + let startup_decision = + runtime_mode::decide_startup_mode(voice_requested, interactive, wakeword_available); // Whether the running binary actually has the voice subsystem compiled in. // When voice was requested but the binary is chat-only (issue #41 feature @@ -180,13 +181,13 @@ async fn main() -> Result<()> { // voice-tagged `geniepod.toml` still deploys cleanly on a chat-only build. #[cfg(feature = "voice")] let voice_mode = { - if voice_requested && !voice_runnable { + if startup_decision.blocked_push_to_talk() { tracing::warn!( "push-to-talk voice mode requested without an interactive terminal and no wakeword script is available; \ running HTTP API only" ); } - voice_runnable + startup_decision.enters_voice() }; #[cfg(not(feature = "voice"))] let voice_mode = { @@ -198,7 +199,7 @@ async fn main() -> Result<()> { enable the voice loop." ); } - let _ = voice_runnable; + let _ = startup_decision; false }; @@ -372,39 +373,6 @@ fn atty_check() -> bool { } } -fn should_enter_voice_mode( - voice_requested: bool, - stdin_interactive: bool, - wakeword_available: bool, -) -> bool { - voice_requested && (stdin_interactive || wakeword_available) -} - -#[cfg(test)] -mod voice_mode_tests { - use super::should_enter_voice_mode; - - #[test] - fn systemd_push_to_talk_falls_back_to_http() { - assert!(!should_enter_voice_mode(true, false, false)); - } - - #[test] - fn terminal_push_to_talk_still_runs() { - assert!(should_enter_voice_mode(true, true, false)); - } - - #[test] - fn wakeword_voice_can_run_without_terminal() { - assert!(should_enter_voice_mode(true, false, true)); - } - - #[test] - fn voice_disabled_stays_http_only() { - assert!(!should_enter_voice_mode(false, true, true)); - } -} - // The only tests in this bin target are local_http_host_* which exercise the // telegram adapter's bind-host helper. Gate the whole module so chat-only / // no-telegram builds don't emit an `unused_imports` lint on `use super::*;`. diff --git a/crates/genie-core/src/runtime_mode.rs b/crates/genie-core/src/runtime_mode.rs new file mode 100644 index 00000000..8db2f27a --- /dev/null +++ b/crates/genie-core/src/runtime_mode.rs @@ -0,0 +1,108 @@ +//! Startup-mode selection for genie-core. + +/// High-level interface selected at process startup. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StartupMode { + /// Run the voice loop. + Voice, + /// Run the HTTP API / daemon path. + HttpOnly, +} + +/// Why startup selected a mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StartupReason { + VoiceNotRequested, + InteractivePushToTalk, + WakewordDaemon, + PushToTalkNeedsTerminal, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StartupDecision { + pub mode: StartupMode, + pub reason: StartupReason, +} + +impl StartupDecision { + pub fn enters_voice(self) -> bool { + self.mode == StartupMode::Voice + } + + pub fn blocked_push_to_talk(self) -> bool { + self.reason == StartupReason::PushToTalkNeedsTerminal + } +} + +/// Decide whether startup may enter voice mode. +/// +/// Push-to-talk consumes stdin, so it is only valid with an interactive +/// terminal. Wake-word mode owns its own listener process and can run as a +/// daemon under systemd. +pub fn decide_startup_mode( + voice_requested: bool, + stdin_interactive: bool, + wakeword_available: bool, +) -> StartupDecision { + if !voice_requested { + return StartupDecision { + mode: StartupMode::HttpOnly, + reason: StartupReason::VoiceNotRequested, + }; + } + + if wakeword_available { + return StartupDecision { + mode: StartupMode::Voice, + reason: StartupReason::WakewordDaemon, + }; + } + + if stdin_interactive { + return StartupDecision { + mode: StartupMode::Voice, + reason: StartupReason::InteractivePushToTalk, + }; + } + + StartupDecision { + mode: StartupMode::HttpOnly, + reason: StartupReason::PushToTalkNeedsTerminal, + } +} + +#[cfg(test)] +mod tests { + use super::{StartupMode, StartupReason, decide_startup_mode}; + + #[test] + fn systemd_push_to_talk_falls_back_to_http() { + let decision = decide_startup_mode(true, false, false); + assert_eq!(decision.mode, StartupMode::HttpOnly); + assert_eq!(decision.reason, StartupReason::PushToTalkNeedsTerminal); + assert!(decision.blocked_push_to_talk()); + } + + #[test] + fn terminal_push_to_talk_still_runs() { + let decision = decide_startup_mode(true, true, false); + assert_eq!(decision.mode, StartupMode::Voice); + assert_eq!(decision.reason, StartupReason::InteractivePushToTalk); + assert!(decision.enters_voice()); + } + + #[test] + fn wakeword_voice_can_run_without_terminal() { + let decision = decide_startup_mode(true, false, true); + assert_eq!(decision.mode, StartupMode::Voice); + assert_eq!(decision.reason, StartupReason::WakewordDaemon); + assert!(decision.enters_voice()); + } + + #[test] + fn voice_disabled_stays_http_only() { + let decision = decide_startup_mode(false, true, true); + assert_eq!(decision.mode, StartupMode::HttpOnly); + assert_eq!(decision.reason, StartupReason::VoiceNotRequested); + } +}