fix: shutdown safety, CLI hardening, exit code collision

Shutdown signal improvements:
- Upgrade ShutdownSignal from Relaxed to Release/Acquire ordering.
  Relaxed was technically sufficient for a single flag but
  Release/Acquire is the textbook correct pattern and ensures
  visibility guarantees across threads without relying on x86 TSO.
- Add double Ctrl+C support to all three signal handlers (ingest,
  embed, sync). First Ctrl+C sets cooperative flag with user message;
  second Ctrl+C force-exits with code 130 (standard SIGINT convention).

CLI hardening:
- LORE_ROBOT env var now checks for truthy values (!empty, !="0",
  !="false") instead of mere existence. Setting LORE_ROBOT=0 or
  LORE_ROBOT=false no longer activates robot mode.
- Replace unreachable!() in color mode match with defensive warning
  and fallback to auto. Clap validates the values but defense in depth
  prevents panics if the value_parser is ever changed.
- Replace unreachable!() in completions shell match with proper error
  return for unsupported shells.

Exit code collision fix:
- ConfigNotFound was mapped to exit code 2 (error.rs:56) which
  collided with handle_clap_error() also using exit code 2 for parse
  errors. Agents calling lore --robot could not distinguish "bad
  arguments" from "missing config file."
- Restore ConfigNotFound to exit code 20 (its original dedicated code).
- Update robot-docs exit code table: code 2 = "Usage error", code 20 =
  "Config not found".

Build script:
- Track .git/refs/heads directory for Cargo rebuild triggers. Ensures
  GIT_HASH env var updates when branch refs change, not just HEAD.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-06 22:42:59 -05:00
parent f3f3560e0d
commit a855759bf8
5 changed files with 22 additions and 6 deletions

View File

@@ -7,4 +7,5 @@ fn main() {
.unwrap_or_default();
println!("cargo:rustc-env=GIT_HASH={}", hash.trim());
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/refs/heads");
}

View File

@@ -76,7 +76,9 @@ impl Cli {
let args: Vec<String> = std::env::args().collect();
args.iter()
.any(|a| a == "--robot" || a == "-J" || a == "--json")
|| std::env::var("LORE_ROBOT").is_ok()
|| std::env::var("LORE_ROBOT")
.ok()
.is_some_and(|v| !v.is_empty() && v != "0" && v != "false")
|| !std::io::stdout().is_terminal()
}
}

View File

@@ -53,7 +53,7 @@ impl ErrorCode {
pub fn exit_code(&self) -> i32 {
match self {
Self::InternalError => 1,
Self::ConfigNotFound => 2,
Self::ConfigNotFound => 20,
Self::ConfigInvalid => 3,
Self::TokenNotSet => 4,
Self::GitLabAuthFailed => 5,

View File

@@ -19,11 +19,11 @@ impl ShutdownSignal {
}
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::Relaxed);
self.cancelled.store(true, Ordering::Release);
}
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::Relaxed)
self.cancelled.load(Ordering::Acquire)
}
}

View File

@@ -134,7 +134,9 @@ async fn main() {
"never" => console::set_colors_enabled(false),
"always" => console::set_colors_enabled(true),
"auto" => {}
_ => unreachable!(),
other => {
eprintln!("Warning: unknown color mode '{}', using auto", other);
}
}
let quiet = cli.quiet;
@@ -664,7 +666,10 @@ async fn handle_ingest(
let signal_for_handler = signal.clone();
tokio::spawn(async move {
let _ = tokio::signal::ctrl_c().await;
eprintln!("\nInterrupted, finishing current batch... (Ctrl+C again to force quit)");
signal_for_handler.cancel();
let _ = tokio::signal::ctrl_c().await;
std::process::exit(130);
});
let ingest_result: std::result::Result<(), Box<dyn std::error::Error>> = async {
@@ -1264,7 +1269,9 @@ fn handle_completions(shell: &str) -> Result<(), Box<dyn std::error::Error>> {
"zsh" => Shell::Zsh,
"fish" => Shell::Fish,
"powershell" => Shell::PowerShell,
_ => unreachable!(),
other => {
return Err(format!("Unsupported shell: {other}").into());
}
};
let mut cmd = Cli::command();
@@ -1522,7 +1529,10 @@ async fn handle_embed(
let signal_for_handler = signal.clone();
tokio::spawn(async move {
let _ = tokio::signal::ctrl_c().await;
eprintln!("\nInterrupted, finishing current batch... (Ctrl+C again to force quit)");
signal_for_handler.cancel();
let _ = tokio::signal::ctrl_c().await;
std::process::exit(130);
});
let result = run_embed(&config, full, retry_failed, None, &signal).await?;
@@ -1573,7 +1583,10 @@ async fn handle_sync_cmd(
let signal_for_handler = signal.clone();
tokio::spawn(async move {
let _ = tokio::signal::ctrl_c().await;
eprintln!("\nInterrupted, finishing current batch... (Ctrl+C again to force quit)");
signal_for_handler.cancel();
let _ = tokio::signal::ctrl_c().await;
std::process::exit(130);
});
let start = std::time::Instant::now();