@@ -30,6 +30,28 @@ import { installBuiltinNodes } from './index.js';
3030function silentLogger ( ) {
3131 return { info ( ) { } , warn ( ) { } , error ( ) { } , debug ( ) { } , child ( ) { return silentLogger ( ) ; } } as any ;
3232}
33+
34+ /**
35+ * A logger that RECORDS what it was told, level included — the engine's own
36+ * sink for the tests that pin a log branch. `silentLogger` discards, so a
37+ * branch can only be pinned on absence against something that keeps the calls
38+ * (#14392).
39+ */
40+ function recordingLogger ( sink : Array < { level : string ; message : string } > ) {
41+ const rec = ( level : string ) => ( message : unknown ) => { sink . push ( { level, message : String ( message ) } ) ; } ;
42+ const self : any = {
43+ info : rec ( 'info' ) , warn : rec ( 'warn' ) , error : rec ( 'error' ) , debug : rec ( 'debug' ) ,
44+ child ( ) { return self ; } ,
45+ } ;
46+ return self ;
47+ }
48+
49+ /**
50+ * The degraded up-bubble sentence, verbatim (#14392). Asserted by CONTENT and
51+ * not by "some warn fired": the card is that this exact sentence described the
52+ * healthy path.
53+ */
54+ const DEGRADED_SENTENCE = 'is gone — continuing without child output' ;
3355function ctx ( ) {
3456 return { logger : silentLogger ( ) , getService ( ) { return undefined ; } } as any ;
3557}
@@ -165,9 +187,12 @@ describe('signal-less resume of a pause that declares no contract proceeds (#136
165187describe ( "engine-built continuation stays exempt — the flag is the ONLY exemption (#13648 negative control)" , ( ) => {
166188 let engine : AutomationEngine ;
167189 let captured : unknown [ ] ;
190+ /** #14392 — the engine's log records, so the up-bubble branch is pinnable. */
191+ let records : Array < { level : string ; message : string } > ;
168192
169193 beforeEach ( ( ) => {
170- engine = new AutomationEngine ( silentLogger ( ) ) ;
194+ records = [ ] ;
195+ engine = new AutomationEngine ( recordingLogger ( records ) ) ;
171196 installBuiltinNodes ( engine , ctx ( ) ) ;
172197 captured = [ ] ;
173198 // Copies the screen-collected `kind` into the child's declared output.
@@ -241,6 +266,54 @@ describe("engine-built continuation stays exempt — the flag is the ONLY exempt
241266 expect ( childRes . status ) . toBeUndefined ( ) ;
242267 expect ( captured ) . toEqual ( [ { result : 'escalate' } ] ) ;
243268 expect ( engine . listSuspendedRuns ( ) ) . toHaveLength ( 0 ) ;
269+
270+ // [#14392] Pinned on the BRANCH, not on the sentence. This IS the
271+ // engine-built up-bubble: `captured` one line up is the child's output
272+ // arriving in the parent, so the parent continued *with* it. The
273+ // degraded line claims the opposite and must be absent — it used to
274+ // fire here on every healthy subflow completion, because the parent's
275+ // `subflow:` correlation is looked up with `loadSuspendedRun` and a
276+ // COMPLETED child has no suspension to find.
277+ expect ( records . filter ( ( r ) => r . message . includes ( DEGRADED_SENTENCE ) ) ) . toEqual ( [ ] ) ;
278+ // Not silent by accident either: the healthy continuation says what it
279+ // is, once, at `debug` — never `warn`.
280+ const bubbled = records . filter ( ( r ) => r . message . includes ( 'engine-built up-bubble signal' ) ) ;
281+ expect ( bubbled ) . toHaveLength ( 1 ) ;
282+ expect ( bubbled [ 0 ] . level ) . toBe ( 'debug' ) ;
283+ expect ( bubbled [ 0 ] . message ) . toContain ( child . runId ) ;
284+ } ) ;
285+
286+ it ( '[#14392 positive control] the degraded line still fires when the child run is gone AND nothing carries its output' , async ( ) => {
287+ const started = await engine . execute ( 'parent' , { } as any ) ;
288+ const parentRunId = started . runId ! ;
289+ const child = engine . listSuspendedRuns ( ) . find ( ( r ) => r . flowName === 'child' ) ! ;
290+
291+ // Make the child GENUINELY gone without bubbling anything up:
292+ // `cancelRun` consumes the child's continuation only, leaving this
293+ // parent parked on a `subflow:` correlation that now dangles.
294+ expect ( await engine . cancelRun ( child . runId , 'abandoned' ) ) . toBe ( true ) ;
295+ records . length = 0 ;
296+
297+ // A CALLER's signal this time — not the engine's — so nothing carries
298+ // the child's output. `kind` satisfies the child's screen, which the
299+ // parent surfaces as its own.
300+ const res = await engine . resume ( parentRunId , { variables : { kind : 'escalate' } } ) ;
301+
302+ const degraded = records . filter ( ( r ) => r . message . includes ( DEGRADED_SENTENCE ) ) ;
303+ expect ( degraded ) . toHaveLength ( 1 ) ;
304+ // The level does NOT move (#13398-class): this branch keeps `warn`.
305+ expect ( degraded [ 0 ] . level ) . toBe ( 'warn' ) ;
306+ expect ( degraded [ 0 ] . message ) . toContain ( child . runId ) ;
307+ expect ( degraded [ 0 ] . message ) . toContain ( parentRunId ) ;
308+ // ...and no debug up-bubble line, since there was no up-bubble.
309+ expect ( records . filter ( ( r ) => r . message . includes ( 'engine-built up-bubble signal' ) ) ) . toEqual ( [ ] ) ;
310+
311+ // Behaviour unchanged: the branch still CONTINUES the parent, which is
312+ // the whole point of the `else` — downstream ran, with no subflow
313+ // output to map into `subResult`.
314+ expect ( res . success ) . toBe ( true ) ;
315+ expect ( captured ) . toEqual ( [ undefined ] ) ;
316+ expect ( await engine . hasSuspendedRun ( parentRunId ) ) . toBe ( false ) ;
244317 } ) ;
245318
246319 it ( "a signal-less resume of the CHILD is still refused — the flag exempts the engine's signal, not the run" , async ( ) => {
0 commit comments