@@ -96,12 +96,18 @@ enum ParsedLine {
9696 NonJson ( String ) ,
9797}
9898
99- /// Parse a stdout blob into per-line results. Blank lines are ignored (they are
100- /// not stream content); every other line must parse as an [`AgentRunEvent`].
99+ /// Parse a stdout blob into per-line results, ONE per line, dropping nothing.
100+ ///
101+ /// In `--json` mode stdout is pure event JSONL: every line must parse as an
102+ /// [`AgentRunEvent`], and a blank/whitespace-only line is itself a purity
103+ /// violation (it is not valid event JSONL), so it is surfaced as a
104+ /// [`ParsedLine::NonJson`] for [`validate_event_contract`] to reject rather than
105+ /// silently swallowed. (`str::lines()` already omits a single trailing newline,
106+ /// so a normal `println!`-terminated stream does not produce a spurious blank
107+ /// final line.)
101108fn parse_lines ( stdout : & str ) -> Vec < ParsedLine > {
102109 stdout
103110 . lines ( )
104- . filter ( |line| !line. trim ( ) . is_empty ( ) )
105111 . map ( |line| match serde_json:: from_str :: < AgentRunEvent > ( line) {
106112 Ok ( event) => ParsedLine :: Event ( Box :: new ( event) ) ,
107113 Err ( _) => ParsedLine :: NonJson ( line. to_string ( ) ) ,
@@ -152,7 +158,11 @@ enum ResultDelivery {
152158/// * zero non-JSON lines on stdout (`--json` mode is pure event JSONL);
153159/// * `seq` monotonic from 0 with no gaps;
154160/// * `run_id` identical across every line;
155- /// * exactly one `session_end`;
161+ /// * exactly one `session_end`, and it is the LAST lifecycle event - no
162+ /// `tool_start`/`tool_end`/`token_usage`/further `session_end` may follow it
163+ /// (in BOTH modes). In `OnStream` mode the terminal `type:"result"` line is
164+ /// the only event allowed after `session_end`; in `ResultFile` mode nothing
165+ /// may follow it;
156166/// * the final result is delivered via the expected channel: exactly one
157167/// `type:"result"` line for [`ResultDelivery::OnStream`], and ZERO for
158168/// [`ResultDelivery::ResultFile`] (stdout stays pure lifecycle events).
@@ -203,6 +213,25 @@ fn validate_event_contract(parsed: &[ParsedLine], delivery: ResultDelivery) -> R
203213 ) ) ;
204214 }
205215
216+ // session_end is the TERMINAL lifecycle event: the orchestrator emits it
217+ // last, immediately before the (optional) terminal result. So nothing but a
218+ // `result` line may appear after it - any lifecycle event
219+ // (tool_start/tool_end/token_usage/another session_end) following
220+ // session_end is a contract violation in BOTH modes. (`ResultFile` also
221+ // forbids result lines entirely, below, so there it means NOTHING follows.)
222+ let session_end_index = events
223+ . iter ( )
224+ . position ( |event| matches ! ( event. payload, AgentRunEventPayload :: SessionEnd { .. } ) )
225+ . expect ( "exactly one session_end confirmed above" ) ;
226+ for ( offset, event) in events. iter ( ) . enumerate ( ) . skip ( session_end_index + 1 ) {
227+ if !matches ! ( event. payload, AgentRunEventPayload :: Result { .. } ) {
228+ return Err ( format ! (
229+ "session_end is not terminal: a non-result event (seq {}, line {offset}) follows it" ,
230+ event. seq
231+ ) ) ;
232+ }
233+ }
234+
206235 // Final-result delivery channel.
207236 let result_lines = events
208237 . iter ( )
@@ -329,13 +358,12 @@ impl Signaler {
329358 }
330359}
331360
332- /// A streamed run: every stdout line tagged with its arrival [`Instant`], plus
333- /// the terminal exit status. Used by E3 (arrival spread) and E4 (signal at a
334- /// stream boundary).
361+ /// A streamed run: every stdout line tagged with its arrival [`Instant`]. Used
362+ /// by E3 (arrival spread) and E4 (signal at a stream boundary). The terminal
363+ /// verdict is read from the parsed result, not the process exit code, so no
364+ /// exit status is retained here.
335365struct StreamedRun {
336366 lines : Vec < ( Instant , String ) > ,
337- #[ allow( dead_code) ]
338- exit_ok : bool ,
339367}
340368
341369impl StreamedRun {
@@ -387,11 +415,10 @@ fn stream_run(
387415 lines. push ( ( at, line) ) ;
388416 }
389417 let _ = reader. join ( ) ;
390- let status = child. wait ( ) ?;
391- Ok ( StreamedRun {
392- lines,
393- exit_ok : status. success ( ) ,
394- } )
418+ // Reap the child so it does not linger as a zombie; the terminal verdict is
419+ // taken from the parsed result stream, not this exit status.
420+ let _ = child. wait ( ) ?;
421+ Ok ( StreamedRun { lines } )
395422}
396423
397424/// Is a line a `tool_end submit` or `tool_start await` event? That is the
@@ -578,6 +605,43 @@ mod plumbing_tests {
578605 assert ! ( err. contains( "non-JSON" ) , "err: {err}" ) ;
579606 }
580607
608+ #[ test]
609+ fn validator_rejects_an_embedded_blank_line ( ) {
610+ // A blank line on stdout in --json mode is a purity violation: it is
611+ // not valid event JSONL and must NOT be silently dropped. Inject one
612+ // between two otherwise-valid events.
613+ let sample = good_sample ( ) ;
614+ let ( head, tail) = sample. split_once ( '\n' ) . expect ( "multi-line sample" ) ;
615+ let corrupted = format ! ( "{head}\n \n {tail}" ) ;
616+ let parsed = parse_lines ( & corrupted) ;
617+ let err = validate_event_contract ( & parsed, ResultDelivery :: OnStream ) . unwrap_err ( ) ;
618+ assert ! ( err. contains( "non-JSON" ) , "err: {err}" ) ;
619+ }
620+
621+ #[ test]
622+ fn validator_rejects_a_lifecycle_event_after_session_end_on_stream ( ) {
623+ // session_end must be terminal: a tool_start AFTER it (before the
624+ // result line) is rejected even though counts/seq are otherwise valid.
625+ let stream = "\
626+ {\" run_id\" :\" r\" ,\" seq\" :0,\" ts\" :\" t\" ,\" type\" :\" session_end\" ,\" outcome\" :{\" success\" :true,\" summary\" :\" ok\" }}
627+ {\" run_id\" :\" r\" ,\" seq\" :1,\" ts\" :\" t\" ,\" type\" :\" tool_start\" ,\" tool_name\" :\" provision\" ,\" tool_input\" :null}
628+ {\" run_id\" :\" r\" ,\" seq\" :2,\" ts\" :\" t\" ,\" type\" :\" result\" ,\" result\" :{\" result\" :{\" success\" :true,\" summary\" :\" ok\" },\" output_artifacts\" :[],\" session_log\" :\" \" ,\" observability\" :null}}" ;
629+ let parsed = parse_lines ( stream) ;
630+ let err = validate_event_contract ( & parsed, ResultDelivery :: OnStream ) . unwrap_err ( ) ;
631+ assert ! ( err. contains( "session_end is not terminal" ) , "err: {err}" ) ;
632+ }
633+
634+ #[ test]
635+ fn validator_rejects_a_lifecycle_event_after_session_end_with_result_file ( ) {
636+ // Same rule in --result-file mode: nothing may follow session_end.
637+ let stream = "\
638+ {\" run_id\" :\" r\" ,\" seq\" :0,\" ts\" :\" t\" ,\" type\" :\" session_end\" ,\" outcome\" :{\" success\" :true,\" summary\" :\" ok\" }}
639+ {\" run_id\" :\" r\" ,\" seq\" :1,\" ts\" :\" t\" ,\" type\" :\" tool_end\" ,\" tool_name\" :\" capture\" ,\" success\" :true,\" output_summary\" :null}" ;
640+ let parsed = parse_lines ( stream) ;
641+ let err = validate_event_contract ( & parsed, ResultDelivery :: ResultFile ) . unwrap_err ( ) ;
642+ assert ! ( err. contains( "session_end is not terminal" ) , "err: {err}" ) ;
643+ }
644+
581645 #[ test]
582646 fn validator_rejects_a_seq_gap ( ) {
583647 // Drop the seq=1 line so the sequence jumps 0 -> 2.
@@ -831,6 +895,12 @@ fn e3_live_events_stream_incrementally() {
831895 first_tool_start_ts. expect ( "a tool_start" ) ,
832896 session_end_ts. expect ( "a session_end" ) ,
833897 ) ;
898+ // Lexicographic compare is sound here because the emitter
899+ // (`workspace_executor::now_rfc3339`) always produces fixed-width,
900+ // second-precision UTC timestamps with a literal `Z` offset
901+ // (`YYYY-MM-DDThh:mm:ssZ`), so string order equals chronological order. The
902+ // crate carries no datetime parser (it hand-rolls RFC3339), and adding one
903+ // solely for this assertion is not worth the dependency weight.
834904 assert ! (
835905 ts_start <= ts_end,
836906 "tool_start {ts_start} must be <= session_end {ts_end}"
0 commit comments