Skip to content

BUG: a workflow step body calling an imported module's function truncates silently and the run reports success #691

Description

@Masterplanner25

Summary

A workflow step body that calls a function in an imported module silently stops executing at that call, and the run is reported as successful.

Found by Stage 5 of the 5.8.0 release, running the release's own features together the way its documentation describes. It is not a 5.8.0 regression — v5.7.1 behaves identically — but 5.8.0 is where it starts to matter, because retry.until (#466) is a module function whose documented home is inside a step body.

The silent-success case is the severe one. Four different symptoms come out of the same construct depending on the shape of the imported module, which is the signature of VM stack/dispatch corruption rather than a logic error:

Symptom When
step body truncates, failed: [], run reports success module defines one function
Stack underflow module defines two functions, one containing a while that calls the callback
Cannot call non-function: nil the callback is a named top-level fn rather than a literal
nodus-retry is required for @retry (spurious) via std:retry

Reproduction

1. Silent truncation, reported as success — the worst case

only_noloop.nd:

fn no_loop(f) { return f() }

repro.nd:

import "./only_noloop.nd" as m
workflow w {
    step a {
        print("STEP RAN")
        let v = m.no_loop(fn() { return 7i })
        print("got \(v)")
        return "ok"
    }
}
fn main() { let r = run_workflow(w); print("failed: \(r["failed"])"); print("steps: \(r["steps"])") }
$ nodus run repro.nd
STEP RAN
failed: []
steps: {}

got \(v) never runs. No error is raised. The run reports no failures.

Control — identical file with let v = 7i instead of the module call:

STEP RAN
got 7
failed: []

So the truncation is caused by the module call, not by the step body or the print.

2. Stack underflow

mymod4.nd:

fn no_loop(f) { return f() }
fn in_loop(f) {
    let n = 0i
    let last = nil
    while (n < 2i) { last = f(); n = n + 1i }
    return last
}
import "./mymod4.nd" as m
workflow w { step a { print("a: \(m.no_loop(fn() { return 1i }))"); return "ok" } }
fn main() { let r = run_workflow(w); print("failed: \(r["failed"])") }
Runtime error at repro.nd:2:59: Stack underflow
  at __anon_1 (repro.nd:2:59)
failed: ["a"]

A module containing only no_loop works. A module containing only in_loop works. A module containing both fails when no_loop is called — which points at the callee being resolved against the wrong entry, not at either function's own code.

3. Named function value

import "./mymod.nd" as m          // fn apply_twice(f, v) { let a = f(v); return f(a) }
fn inc(x) { return x + 1i }
workflow w { step s { print("in step: \(m.apply_twice(inc, 1i))"); return "ok" } }
fn main() {
    print("outside: \(m.apply_twice(inc, 1i))")   // 3    -- works
    let r = run_workflow(w)                        // Cannot call non-function: nil
    print("failed: \(r["failed"])")
}

A literal closure or a let-bound closure in the same position works; only a named top-level fn produces nil.

4. retry.until, the 5.8.0 feature this blocks

import "std:retry" as retry
workflow w {
    step s {
        let r = retry.until(fn() { return 1i }, fn(v) { return true }, {"max_attempts": 2i})
        print("value=\(r["value"])")
        return "ok"
    }
}
fn main() { let r = run_workflow(w); print("failed: \(r["failed"])") }
Runtime error at repro.nd:9:34: Stack underflow
  at __anon_3 (repro.nd:9:34)
  called from until (.../nodus/stdlib/retry.nd:69:24)
failed: ["s"]

This is the documented literal-closure form from docs/guide/ai-primitives.md. It works in fn main() and fails in a step body.

Expected behavior

A step body calling an imported module's function should behave exactly as the same call does at top level. In particular a step body must never stop part-way with the run reporting success — whatever else is wrong, that failure mode has to become an error.

Not a regression

Repro v5.7.1 v5.8.0
§1 silent truncation truncates, failed: [] truncates, failed: []
§2 stack underflow Stack underflow Stack underflow
§3 named fn Cannot call non-function: nil Cannot call non-function: nil

Both tested against clean venvs with the published wheels, run from outside the repo.

Fix direction

The varying symptoms suggest one question answered in two voices: how a module's function is addressed when the call originates inside a step body. Two things worth checking first:

  1. Module function dispatch by index. §2 is the sharpest evidence — the same source for no_loop works alone and fails when a second function exists in the module. That is consistent with resolving the callee against the wrong function table or the wrong index within one.
  2. Frame handling across the step-body → module boundary. A step body is a closure entered through the task graph (see Step ordering is a default, not an invariant: a lowered workflow's step closures are reachable and callable out of order #394's four doors), and the callback is then re-entered from the module's frame. §1's silent truncation looks like a frame being unwound without an error being propagated.

Whatever the mechanism, the step-body path and the top-level path must end up asking one question in one place — this is the recurring shape, and a behaviour test that only exercises fn main() passes on the path that is already correct. The existing coverage does exactly that: tests/test_retry_until.py and the Gate 10b probes both run everything inside fn main(), which is why 83/83 probes and the full suite were green.

Affected versions

v5.7.1 and v5.8.0 confirmed. Likely older — the construct is not new.

Activity

  1. added 3 commits that reference this issue on Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingseverity:highMajor feature broken or significantly wrong

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions