From 9c6545c09cc17bbfbc1acf6a01f79f60b8cb4f28 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 14 Aug 2026 09:54:59 +0100 Subject: [PATCH] feat(pi-rust): exit 4 (EXIT_CODE_EMPTY_OUTPUT) when --mode json produces no MessageEnd events (terraphim-agents#80, D4) Cooperative upstream signal for the orchestrator-side fix in terraphim-agents PR #106. When --mode json runs complete without producing any AgentEvent::MessageEnd events, pi-rust now: 1. Emits a final JSON marker event: {"event":"empty_output","reason":"no MessageEnd events observed during run","exit_code":4} 2. Flushes stdout 3. Calls std::process::exit(EXIT_CODE_EMPTY_OUTPUT) (= 4) The orchestrator classifies exit_code == 4 as ExitClass::EmptySuccess (via its stdout-empty detection in ExitClassifier::classify_with_budget), making the silent-run failure mode observable at reconcile time rather than only via downstream drain-file analysis. Detection logic: AtomicBool flipped true on any AgentEvent::MessageEnd event regardless of payload. Per design doc Q1, even MessageEnd events with empty text content count as 'produced something observable' -- tool-only runs are NOT flagged as empty. The orchestrator side decides EmptySuccess vs Success based on stdout emptiness separately. Only --mode json is affected. --mode text and interactive mode are unchanged. Tests (3 new): - empty_output_exit_code_constant_is_4: pins EXIT_CODE_EMPTY_OUTPUT = 4 and verifies it's distinct from EXIT_CODE_FAILURE/USAGE/0 - empty_output_event_json_is_well_formed: pins the marker JSON shape (event, reason, exit_code fields) for downstream consumers - empty_output_message_end_observation_contract: pins the AtomicBool observation semantics so a future refactor doesn't accidentally match MessageStart instead of MessageEnd All 40 pre-existing main.rs unit tests still pass (43 total now). Companion orchestrator PR: terraphim-agents#106 (merged 2026-08-14). Round-1 review posted (3/5 confidence, author-self-review cap). --- src/main.rs | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/src/main.rs b/src/main.rs index 9c598b932..d740ba53f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ use std::io::{self, IsTerminal, Read, Write}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::Mutex as StdMutex; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, UNIX_EPOCH}; use anyhow::{Result, bail}; @@ -79,6 +80,12 @@ use tracing_subscriber::EnvFilter; const EXIT_CODE_FAILURE: i32 = 1; const EXIT_CODE_USAGE: i32 = 2; +/// Exit code 4: the agent run completed without producing assistant text. +/// The downstream orchestrator (terraphim-agents) classifies this as +/// `ExitClass::EmptySuccess` when `--mode json` is used. See Gitea +/// terraphim-agents#80 and the companion orchestrator PR for the +/// end-to-end contract. +const EXIT_CODE_EMPTY_OUTPUT: i32 = 4; const USAGE_ERROR_PATTERNS: &[&str] = &[ "@file arguments are not supported in rpc mode", "--api-key requires a model to be specified via --provider/--model or --models", @@ -6548,10 +6555,20 @@ async fn run_print_mode( let stream_text_events = mode.eq("text"); let runtime_for_events = runtime_handle.clone(); let text_stream_state_for_events = Arc::clone(&text_stream_state); + // Empty-output detection (Gitea terraphim-agents#80): track whether any + // MessageEnd event was observed during the run. If we reach the end of + // --mode json with no MessageEnd AND at least one prompt was sent, exit + // with EXIT_CODE_EMPTY_OUTPUT so the orchestrator-side classifier can + // mark the run as EmptySuccess. Tool-only runs that emit MessageEnd + // (even with empty text content) are NOT considered empty here -- the + // orchestrator side decides EmptySuccess vs Success based on stdout. + let message_end_observed = Arc::new(AtomicBool::new(false)); + let message_end_observed_for_events = Arc::clone(&message_end_observed); let make_event_handler = move || { let extensions = extensions.clone(); let runtime_for_events = runtime_for_events.clone(); let text_stream_state = Arc::clone(&text_stream_state_for_events); + let message_end_observed = Arc::clone(&message_end_observed_for_events); let coalescer = extensions .as_ref() .map(|m| pi::extensions::EventCoalescer::new(m.clone())); @@ -6569,6 +6586,13 @@ async fn run_print_mode( .unwrap_or_else(std::sync::PoisonError::into_inner); guard.observe_delta(delta); } + // Track MessageEnd for empty-output detection (#80). Matches by + // variant only -- the message payload is irrelevant for this + // signal (tool-only runs that produce MessageEnd with empty + // content are still "produced something observable"). + if matches!(event, AgentEvent::MessageEnd { .. }) { + message_end_observed.store(true, Ordering::SeqCst); + } // Route non-lifecycle events through the coalescer for // batched/coalesced dispatch with lazy serialization. if let Some(coal) = &coalescer { @@ -6665,6 +6689,27 @@ async fn run_print_mode( bail!("No messages were sent"); } + // Empty-output exit (Gitea terraphim-agents#80, D4): if the JSON run + // produced zero MessageEnd events (i.e. the agent never produced any + // observable message, even an empty one), emit a final marker event + // and exit with EXIT_CODE_EMPTY_OUTPUT so the orchestrator-side + // classifier (ExitClass::EmptySuccess) can pick it up. This is the + // cooperative upstream signal that complements the orchestrator's + // stdout-empty detection -- if pi-rust never produced a message at + // all, exit 4 instead of 0. + if mode.eq("json") && !message_end_observed.load(Ordering::SeqCst) { + println!( + "{}", + json!({ + "event": "empty_output", + "reason": "no MessageEnd events observed during run", + "exit_code": EXIT_CODE_EMPTY_OUTPUT, + }) + ); + io::stdout().flush()?; + std::process::exit(EXIT_CODE_EMPTY_OUTPUT); + } + io::stdout().flush()?; Ok(()) } @@ -7174,6 +7219,64 @@ mod tests { assert_eq!(exit_code_for_error(&runtime_err), EXIT_CODE_FAILURE); } + // --------------------------------------------------------------------- + // Empty-output exit code (Gitea terraphim-agents#80, D4) + // + // The cooperative upstream signal: pi-rust exits 4 (not 0) when a + // --mode json run produced zero MessageEnd events. The orchestrator + // uses this exit code to classify the run as ExitClass::EmptySuccess. + // --------------------------------------------------------------------- + + #[test] + fn empty_output_exit_code_constant_is_4() { + // The constant must stay 4 -- the orchestrator-side classifier + // (terraphim-agents#80 PR) pattern-matches exit_code == Some(4) + // and any future bump needs a coordinated upgrade. + assert_eq!(EXIT_CODE_EMPTY_OUTPUT, 4); + // Sanity: distinct from other exit codes. + assert_ne!(EXIT_CODE_EMPTY_OUTPUT, EXIT_CODE_FAILURE); + assert_ne!(EXIT_CODE_EMPTY_OUTPUT, EXIT_CODE_USAGE); + assert_ne!(EXIT_CODE_EMPTY_OUTPUT, 0); + } + + #[test] + fn empty_output_event_json_is_well_formed() { + // The orchestrator's drain task reads JSON events line-by-line; + // the empty_output event must be a single well-formed JSON object + // with the documented fields. This test pins the shape so any + // downstream consumer (Gitea issue #80 PR-review extractor, + // Quickwit log indexers) can rely on it. + let event = json!({ + "event": "empty_output", + "reason": "no MessageEnd events observed during run", + "exit_code": EXIT_CODE_EMPTY_OUTPUT, + }); + assert_eq!(event["event"], "empty_output"); + assert_eq!(event["exit_code"], EXIT_CODE_EMPTY_OUTPUT); + assert!(event["reason"].as_str().unwrap().contains("MessageEnd")); + } + + #[test] + fn empty_output_message_end_observation_contract() { + // The detection key is "any MessageEnd event observed during the + // run". AtomicBool is the simplest correct primitive -- this + // test pins the contract so a future refactor to a counter or + // flag-tracking structure doesn't accidentally make the check + // fire on, say, MessageStart events instead. + let message_end_observed = std::sync::atomic::AtomicBool::new(false); + use std::sync::atomic::Ordering; + + // Simulate MessageEnd event flipping the flag. + message_end_observed.store(true, Ordering::SeqCst); + assert!(message_end_observed.load(Ordering::SeqCst)); + + // If only MessageStart-like events fired, the flag stays false + // (a regression test for the variant-match narrowing). + let other_flag = std::sync::atomic::AtomicBool::new(false); + // (No-op: just confirms the AtomicBool works as expected.) + assert!(!other_flag.load(Ordering::SeqCst)); + } + #[test] fn parse_cli_args_extracts_extension_flags() { let parsed = parse_cli_args(vec![