@@ -74,6 +74,14 @@ pub struct Evidence<'a> {
7474 /// diff, a list of files, whatever the host counts as proof. Empty is
7575 /// honest; a fabricated summary is not.
7676 pub changed : String ,
77+ /// The runner's own report that the run broke — a node that errored, a
78+ /// script that exited nonzero, a deadline. `None` when the run completed
79+ /// on its own terms, whatever it achieved.
80+ ///
81+ /// Carried separately from the outcome because it decides something the
82+ /// model may not: whether another attempt is worth making. See
83+ /// [`judge`]'s downgrade.
84+ pub failed : Option < String > ,
7785}
7886
7987impl Evidence < ' _ > {
@@ -183,15 +191,35 @@ pub async fn judge(
183191
184192 let answer = ask ( caps, conn, Tier :: Judge , SYSTEM , & user) . await ?;
185193 let satisfied = answer[ "satisfied" ] . as_bool ( ) . unwrap_or ( false ) ;
186- Ok ( Verdict {
187- satisfied,
194+ let blocker = if satisfied {
188195 // A satisfied verdict has no blocker whatever the model wrote in the
189196 // field; the two disagreeing is a state nothing downstream can read.
190- blocker : if satisfied {
191- Blocker :: None
197+ Blocker :: None
198+ } else {
199+ let claimed = Blocker :: parse ( answer[ "blocker" ] . as_str ( ) . unwrap_or_default ( ) ) ;
200+ // The one place the loop overrules the judge, and it does so on a
201+ // fact rather than an opinion: the RUNNER said the run broke. A
202+ // mechanical break is the most fixable thing an episode can hit —
203+ // rewrite the script, correct the flag — so calling it terminal
204+ // spends the remaining attempts on nothing. The prompt says this
205+ // too; saying it is not enough, because a model that misreads it
206+ // ends the episode and no later round can undo that.
207+ //
208+ // Needs-input and external-wait survive: both are terminal because
209+ // something OUTSIDE the loop must move, which a broken run does not
210+ // change.
211+ if evidence. failed . is_some ( )
212+ && !claimed. continuable ( )
213+ && !matches ! ( claimed, Blocker :: NeedsInput | Blocker :: ExternalWait )
214+ {
215+ Blocker :: GoalNotMet
192216 } else {
193- Blocker :: parse ( answer[ "blocker" ] . as_str ( ) . unwrap_or_default ( ) )
194- } ,
217+ claimed
218+ }
219+ } ;
220+ Ok ( Verdict {
221+ satisfied,
222+ blocker,
195223 gap : answer[ "gap" ] . as_str ( ) . unwrap_or_default ( ) . to_string ( ) ,
196224 attributed_to : answer[ "attributed_to" ]
197225 . as_str ( )
@@ -263,6 +291,79 @@ fn without_a_model(evidence: &Evidence<'_>) -> Option<Verdict> {
263291#[ cfg( test) ]
264292mod tests {
265293 use super :: * ;
294+
295+ /// A provider that answers the judge with a fixed blocker.
296+ struct Says ( & ' static str ) ;
297+
298+ #[ async_trait:: async_trait]
299+ impl tinyflows:: caps:: LlmProvider for Says {
300+ async fn complete (
301+ & self ,
302+ _request : serde_json:: Value ,
303+ _conn : Option < & str > ,
304+ ) -> tinyflows:: error:: Result < serde_json:: Value > {
305+ Ok ( serde_json:: json!( {
306+ "satisfied" : false ,
307+ "blocker" : self . 0 ,
308+ "gap" : "nothing was fetched" ,
309+ } ) )
310+ }
311+ }
312+
313+ async fn verdict_for ( blocker : & ' static str , failed : Option < String > ) -> Verdict {
314+ let outcome = tinyflows:: engine:: RunOutcome {
315+ // Non-empty: the mechanical pre-judge must not settle this one,
316+ // because the point is what the MODEL's answer becomes.
317+ output : serde_json:: json!( { "nodes" : { "fetch" : { "json" : 1 } } } ) ,
318+ pending_approvals : Vec :: new ( ) ,
319+ cancelled : false ,
320+ } ;
321+ let diagnosis = Diagnosis :: default ( ) ;
322+ let evidence = Evidence {
323+ outcome : & outcome,
324+ diagnosis : & diagnosis,
325+ changed : String :: new ( ) ,
326+ failed,
327+ } ;
328+ let caps = tinyflows:: caps:: Capabilities {
329+ llm : std:: sync:: Arc :: new ( Says ( blocker) ) ,
330+ ..tinyflows:: caps:: mock:: mock_capabilities ( )
331+ } ;
332+ judge ( & Goal :: new ( "do the thing" ) , & evidence, & caps, None )
333+ . await
334+ . expect ( "judged" )
335+ }
336+
337+ #[ tokio:: test]
338+ async fn a_mechanically_broken_run_cannot_be_called_terminal ( ) {
339+ // Field observation: a shell step exited nonzero, the judge answered
340+ // `missing_evidence`, and the episode ended with two of its three
341+ // attempts unused — when rewriting the script was the whole fix.
342+ // The prompt says mechanical failures are goal_not_met; a model that
343+ // misreads it must not get to end the episode anyway.
344+ let verdict = verdict_for ( "missing_evidence" , Some ( "script exited 5" . into ( ) ) ) . await ;
345+ assert_eq ! ( verdict. blocker, Blocker :: GoalNotMet ) ;
346+ assert ! ( verdict. blocker. continuable( ) ) ;
347+ }
348+
349+ #[ tokio:: test]
350+ async fn a_run_that_completed_keeps_the_judges_terminal_verdict ( ) {
351+ // No mechanical failure: the judge is the authority on whether
352+ // another attempt could help, and this downgrade must not become a
353+ // blanket refusal to ever stand down.
354+ let verdict = verdict_for ( "missing_evidence" , None ) . await ;
355+ assert_eq ! ( verdict. blocker, Blocker :: MissingEvidence ) ;
356+ }
357+
358+ #[ tokio:: test]
359+ async fn a_broken_run_still_waiting_on_a_person_stays_terminal ( ) {
360+ // NeedsInput and ExternalWait survive the downgrade: both mean
361+ // something OUTSIDE the loop must move, which a broken run does not
362+ // change.
363+ let verdict = verdict_for ( "needs_input" , Some ( "script exited 5" . into ( ) ) ) . await ;
364+ assert_eq ! ( verdict. blocker, Blocker :: NeedsInput ) ;
365+ }
366+
266367 use serde_json:: json;
267368 use tinyflows:: diagnostics:: { HiddenError , NeverRan , NullBinding } ;
268369
@@ -279,6 +380,7 @@ mod tests {
279380 outcome : o,
280381 diagnosis : d,
281382 changed : String :: new ( ) ,
383+ failed : None ,
282384 }
283385 }
284386
0 commit comments