You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
BUG: a workflow step body calling an imported module's function truncates silently and the run reports success #691
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:
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.
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.
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:
failed: [], run reports successStack underflowwhilethat calls the callbackCannot call non-function: nilfnrather than a literalnodus-retry is required for @retry(spurious)std:retryReproduction
1. Silent truncation, reported as success — the worst case
only_noloop.nd:repro.nd:got \(v)never runs. No error is raised. The run reports no failures.Control — identical file with
let v = 7iinstead of the module call:So the truncation is caused by the module call, not by the step body or the
print.2. Stack underflow
mymod4.nd:A module containing only
no_loopworks. A module containing onlyin_loopworks. A module containing both fails whenno_loopis called — which points at the callee being resolved against the wrong entry, not at either function's own code.3. Named function value
A literal closure or a
let-bound closure in the same position works; only a named top-levelfnproducesnil.4.
retry.until, the 5.8.0 feature this blocksThis is the documented literal-closure form from
docs/guide/ai-primitives.md. It works infn 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
failed: []failed: []Stack underflowStack underflowCannot call non-function: nilCannot call non-function: nilBoth 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:
no_loopworks 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.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.pyand the Gate 10b probes both run everything insidefn 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.