22 * File : AnsibleRunOutcome.java
33 * Author : AppDevForAll
44 * Copyright : Copyright (c) 2026 AppDevForAll
5- * Description : ADFA-4435 — pure decision for whether a runrole/Ansible execution failed.
6- * Ansible can print its failure to stdout yet still exit 0 (e.g. the /dev/shm
7- * multiprocessing crash), so the verdict considers the output as well as the
8- * exit code. No Android dependencies -> unit-testable on the JVM.
5+ * Description : ADFA-4435 / K2GO-450 - pure decision for whether a runrole/Ansible execution
6+ * failed. The PLAY RECAP (per-host "... unreachable=N failed=M ...") is Ansible's
7+ * authoritative verdict, so when a recap is present it decides: an IGNORED error
8+ * (ignore_errors -> counted in ignored=, not failed=) prints [ERROR]/fatal lines but
9+ * is NOT a failure. Only when no recap appears (Ansible died before it could
10+ * summarize, e.g. the /dev/shm multiprocessing crash) do the [ERROR]/crash
11+ * signatures stand in. A non-zero process exit is always a failure.
12+ * No Android dependencies -> unit-testable on the JVM.
913 */
1014package org .appdevforall .k2go .install .domain ;
1115
1216public final class AnsibleRunOutcome {
1317
18+ // Pre-recap failure hints: used ONLY when no PLAY RECAP was seen (Ansible crashed before it
19+ // could summarize). When a recap IS present these are ignored, because Ansible prints [ERROR]
20+ // and fatal lines for ignored errors too, which are not failures.
1421 private boolean sawError = false ;
22+ private boolean sawRecap = false ; // a PLAY RECAP host-summary line appeared
23+ private boolean recapFailure = false ; // a recap host line reported failed>0 or unreachable>0
1524
1625 /** Feed each output line as it streams from the container. */
1726 public void observe (String line ) {
@@ -21,10 +30,36 @@ public void observe(String line) {
2130 || line .contains ("HEARTBEAT SESSION STOPPED" )) {
2231 sawError = true ;
2332 }
33+ // A PLAY RECAP host summary carries BOTH "unreachable=" and "failed=" (every real recap line
34+ // does); that pair is what distinguishes it from a stray task line that happens to contain one.
35+ int failed = count (line , "failed=" );
36+ int unreachable = count (line , "unreachable=" );
37+ if (failed >= 0 && unreachable >= 0 ) {
38+ sawRecap = true ;
39+ if (failed > 0 || unreachable > 0 ) recapFailure = true ;
40+ }
2441 }
2542
26- /** True if the run failed: a non-zero exit OR an error seen in the output. */
43+ /** True if the run failed. A non-zero exit always fails; otherwise trust the PLAY RECAP when
44+ * present, and fall back to the [ERROR]/crash signatures only when no recap was emitted. */
2745 public boolean failed (int exitCode ) {
28- return exitCode != 0 || sawError ;
46+ if (exitCode != 0 ) return true ;
47+ if (sawRecap ) return recapFailure ;
48+ return sawError ;
49+ }
50+
51+ /** The non-negative integer immediately after {@code key} in {@code line}, or -1 if absent. */
52+ private static int count (String line , String key ) {
53+ int i = line .indexOf (key );
54+ if (i < 0 ) return -1 ;
55+ int start = i + key .length ();
56+ int j = start ;
57+ while (j < line .length () && Character .isDigit (line .charAt (j ))) j ++;
58+ if (j == start ) return -1 ;
59+ try {
60+ return Integer .parseInt (line .substring (start , j ));
61+ } catch (NumberFormatException e ) {
62+ return -1 ;
63+ }
2964 }
3065}
0 commit comments