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
When a workflow step calls workflow_wait(event_type, deadline_ms=N), the step suspends and the runtime records the waiting state. But the event that resumes the workflow must be delivered by the host, via an explicit call to resume_workflow(graph_id, checkpoint, payload).
There is no built-in pub/sub, no event bus, and no mechanism for Nodus code to autonomously receive external events — the host is always the event dispatcher. This caps the Autonomy axis at 4/5 for use cases requiring reactive event-driven behavior.
Impact
A workflow that waits for a webhook callback requires the host to (a) receive the webhook, (b) look up the waiting graph_id, (c) call resume_workflow(). All three steps must be implemented in host code.
There is no Nodus-native equivalent of await event("order.paid") that handles its own subscription.
The HTTP runtime bridge exposes POST /workflow/replay which provides this from outside the runtime, but this is host-plumbing, not a Nodus primitive.
Fix direction (v5 candidate)
A built-in event channel that connects Nodus coroutines to the host's event bus would close this gap:
let evt = wait_event("order.paid", deadline_ms=30000i)
// resumes automatically when the host publishes "order.paid" to the runtime bus
Implementation would require a publish_event(event_type, payload) host API and a runtime event queue. The workflow workflow_wait mechanism already tracks waiters; generalizing it to a non-workflow context would be a natural extension.
Affected versions
v4.0.0 (current when filed). Noted in System Capability Audit 6/6/26, Autonomy axis.
Re-verified 2026-08-30 against 5.7.1 dev source — still valid, as part of a sweep over the 2026-06-07 audit batch.
No publish_event or wait_event exists. workflow_wait still records the waiting state and the host is still the only thing that can dispatch the resume.
Pair this with #176 (self-scheduling): they are the same shape from two directions — a workflow can enter a waiting state, and nothing inside Nodus can cause it to leave one. #176 is the time-triggered case, this is the event-triggered case, and a single mechanism ("something the runtime owns can wake a waiter") would answer both. Designing them separately risks two wake paths for one question.
One thing that has changed in the surrounding area: NODUS_RUN_STATE_ROOT now relocates both halves of a run's state together (#585), and #476 established that a run is two stores — .nodus/graphs/ plus the workflow store — with a resume that now says so rather than reporting "not found". Any event-delivery mechanism has to wake a run that is split across both.
Verified: no wait_event and no publish_event builtin. workflow_wait / resume_workflow / workflow_resume_payload are still the mechanism, and the host is still the dispatcher. The Autonomy cap this issue describes stands.
spawn() returns the coroutine handle now rather than nil, so a waiter has something to hold.
Worth reading those before designing wait_event: there is now a working example in-tree of "an outside decision reaches a suspended task", including what happens to finally versus catch on the way through. A second mechanism that does not match it would be two answers to one question.
One caution on the sketch in this issue. It proposes wait_event("order.paid", deadline_ms=30000i) resuming automatically. Anything that resumes on a timer has to decide what a resumed-but-unsatisfied wait returns, and #486 is the precedent for getting that wrong: a checkpoint turned out to be a re-entry label for its whole step rather than a position marker, so effects before it re-ran on every resume. Design the resume semantics before the subscription API.
Re-scoped against 5.9.0 — the gap is one of the three steps, not all three
Filed against v4.0.0. Re-read the tree at 5.9.0 and probed it; the issue's own framing turns out to name the answer. It says the host must (a) receive the webhook, (b) look up the waiting graph_id, (c) call resume_workflow().
(a) is irreducibly the host's. A guest has no listener, and giving it one is not on the table.
(b) is the only step the runtime is uniquely able to do, and it is the one it does not do.
So the proposed wait_event() — a Nodus-native call that subscribes and resumes itself — is the wrong shape. It would make the guest the subscriber, which is precisely what #176 declined, and it would need a second event bus to boot. The right shape keeps the host as dispatcher and closes (b).
Both ends exist and the middle is missing. claim_waiting_run_for_resume takes an event_typeonly to verify one against a run the caller already identified — so the store can already confirm "this run is waiting for this event", it just cannot answer "which run is". A host has to list_runs() (a full scan; wait_json is unindexed TEXT and doctor.py already flags the scan under #380) and reach into record.wait.event_type, which is not a published surface.
sweep() does not help: it is purely a clock. It expires deadlines, releases on_timeout: "resume" schedules, retries due runs and adopts orphans. It reads no event type anywhere.
The emit bus is not a candidate either — RuntimeEventBus is a per-VM bounded deque with fire-and-forget sinks, no subscribe-by-type and no persistence, and nothing in the tree imports both it and the workflow store.
Proposal — deliver by event type, host-invoked
One new question answered in one place: which parked runs are waiting for this event?
Store: list_runs_waiting_for(event_type, *, correlation_key=None). The missing query, next to the existing list_due_retry_runs / list_rehydratable_runs.
Runner: deliver_event(vm_factory, event_type, *, payload=None, correlation_key=None, all_matching=False) → resolve, then hand each match to the existing resume_workflow, which already validates the wait's schema (workflow_wait declares no payload schema, so resume_workflow delivers an unvalidated payload #472) and claims through claim_waiting_run_for_resume (so two dispatchers racing the same event is already handled — the loser gets None).
HTTP: POST /workflow/deliver, alongside the existing /workflow/resume and /workflow/replay.
Nothing new executes anything: delivery is an explicit host call, so the #176 boundary is untouched.
Three decisions worth stating, because each could go the other way
Ambiguity is refused, not resolved. If an event type matches more than one parked run and no correlation_key narrows it to one, the call fails and names the candidates; --all / all_matching=True opts into fan-out. Returning an arbitrary one of N is latest_graph_state() returns the lexicographically largest random graph id, not the latest — and the HTTP surface reports it as the caller's #584's exact failure mode — the copy that could not answer correctly returned something plausible, and the bug hid for as long as the substitute looked reasonable. correlation_key exists precisely to disambiguate, so the API should make the caller use it.
Zero matches is a reported outcome, not an error. A webhook dispatcher does not control ordering: the event can arrive before the run parks or after it completes. The call reports delivered: [] with the count rather than failing, so a host can decide whether to retry.
Delivery does not resurrect an expired wait.resume_workflow already expires timeouts first, so a run past an on_timeout: "fail" deadline dead-letters and delivery then finds nothing waiting. That is the correct order and it falls out of reuse rather than needing its own rule.
What this does not do
It does not index. list_runs_waiting_for is a filtered scan like every other list method here, so the #380 cost bound applies unchanged — this adds a caller, not a new performance problem, and pretending otherwise by adding an index for one query would be the wrong place to start. If delivery becomes hot, #380 is where the fix belongs, for all of them at once.
Proceeding on this basis unless the shape is wrong.
Activity
Masterplanner25 commentedon Aug 30, 2026
Re-verified 2026-08-30 against 5.7.1 dev source — still valid, as part of a sweep over the 2026-06-07 audit batch.
No
publish_eventorwait_eventexists.workflow_waitstill records the waiting state and the host is still the only thing that can dispatch the resume.Pair this with #176 (self-scheduling): they are the same shape from two directions — a workflow can enter a waiting state, and nothing inside Nodus can cause it to leave one. #176 is the time-triggered case, this is the event-triggered case, and a single mechanism ("something the runtime owns can wake a waiter") would answer both. Designing them separately risks two wake paths for one question.
One thing that has changed in the surrounding area:
NODUS_RUN_STATE_ROOTnow relocates both halves of a run's state together (#585), and #476 established that a run is two stores —.nodus/graphs/plus the workflow store — with a resume that now says so rather than reporting "not found". Any event-delivery mechanism has to wake a run that is split across both.Masterplanner25 commentedon Aug 31, 2026
Re-checked at 5.8.0: still true, unchanged
Verified: no
wait_eventand nopublish_eventbuiltin.workflow_wait/resume_workflow/workflow_resume_payloadare still the mechanism, and the host is still the dispatcher. The Autonomy cap this issue describes stands.What has changed nearby, none of which closes it:
cancel(t),wait(t),nodus workflow cancel. A cancelled task's waiters are released rather than orphaned. That is the runtime learning to deliver one kind of external signal to a waiting task, which is structurally the thing this issue asks for, for a different signal and through a host call rather than a bus.spawn()returns the coroutine handle now rather than nil, so a waiter has something to hold.Worth reading those before designing
wait_event: there is now a working example in-tree of "an outside decision reaches a suspended task", including what happens tofinallyversuscatchon the way through. A second mechanism that does not match it would be two answers to one question.One caution on the sketch in this issue. It proposes
wait_event("order.paid", deadline_ms=30000i)resuming automatically. Anything that resumes on a timer has to decide what a resumed-but-unsatisfied wait returns, and #486 is the precedent for getting that wrong: a checkpoint turned out to be a re-entry label for its whole step rather than a position marker, so effects before it re-ran on every resume. Design the resume semantics before the subscription API.Staying open.
Masterplanner25 commentedon Sep 4, 2026
Re-scoped against 5.9.0 — the gap is one of the three steps, not all three
Filed against v4.0.0. Re-read the tree at 5.9.0 and probed it; the issue's own framing turns out to name the answer. It says the host must (a) receive the webhook, (b) look up the waiting
graph_id, (c) callresume_workflow().nodus workflow sweepis an explicit call for exactly that reason. The precedent is a good one and this should follow it.So the proposed
wait_event()— a Nodus-native call that subscribes and resumes itself — is the wrong shape. It would make the guest the subscriber, which is precisely what #176 declined, and it would need a second event bus to boot. The right shape keeps the host as dispatcher and closes (b).What the tree actually holds
Probed, not read off the source:
Both ends exist and the middle is missing.
claim_waiting_run_for_resumetakes anevent_typeonly to verify one against a run the caller already identified — so the store can already confirm "this run is waiting for this event", it just cannot answer "which run is". A host has tolist_runs()(a full scan;wait_jsonis unindexed TEXT anddoctor.pyalready flags the scan under #380) and reach intorecord.wait.event_type, which is not a published surface.sweep()does not help: it is purely a clock. It expires deadlines, releaseson_timeout: "resume"schedules, retries due runs and adopts orphans. It reads no event type anywhere.The
emitbus is not a candidate either —RuntimeEventBusis a per-VM boundeddequewith fire-and-forget sinks, no subscribe-by-type and no persistence, and nothing in the tree imports both it and the workflow store.Proposal — deliver by event type, host-invoked
One new question answered in one place: which parked runs are waiting for this event?
list_runs_waiting_for(event_type, *, correlation_key=None). The missing query, next to the existinglist_due_retry_runs/list_rehydratable_runs.deliver_event(vm_factory, event_type, *, payload=None, correlation_key=None, all_matching=False)→ resolve, then hand each match to the existingresume_workflow, which already validates the wait'sschema(workflow_wait declares no payload schema, so resume_workflow delivers an unvalidated payload #472) and claims throughclaim_waiting_run_for_resume(so two dispatchers racing the same event is already handled — the loser getsNone).nodus workflow deliver <event_type> [--correlation-key K] [--payload JSON] [--all].POST /workflow/deliver, alongside the existing/workflow/resumeand/workflow/replay.Nothing new executes anything: delivery is an explicit host call, so the #176 boundary is untouched.
Three decisions worth stating, because each could go the other way
Ambiguity is refused, not resolved. If an event type matches more than one parked run and no
correlation_keynarrows it to one, the call fails and names the candidates;--all/all_matching=Trueopts into fan-out. Returning an arbitrary one of N is latest_graph_state() returns the lexicographically largest random graph id, not the latest — and the HTTP surface reports it as the caller's #584's exact failure mode — the copy that could not answer correctly returned something plausible, and the bug hid for as long as the substitute looked reasonable.correlation_keyexists precisely to disambiguate, so the API should make the caller use it.Zero matches is a reported outcome, not an error. A webhook dispatcher does not control ordering: the event can arrive before the run parks or after it completes. The call reports
delivered: []with the count rather than failing, so a host can decide whether to retry.Delivery does not resurrect an expired wait.
resume_workflowalready expires timeouts first, so a run past anon_timeout: "fail"deadline dead-letters and delivery then finds nothing waiting. That is the correct order and it falls out of reuse rather than needing its own rule.What this does not do
It does not index.
list_runs_waiting_foris a filtered scan like every other list method here, so the #380 cost bound applies unchanged — this adds a caller, not a new performance problem, and pretending otherwise by adding an index for one query would be the wrong place to start. If delivery becomes hot, #380 is where the fix belongs, for all of them at once.Proceeding on this basis unless the shape is wrong.
feat: deliver an external event by event type, without a run id (#181)
feat: deliver an external event by event type, without a run id (#181) (
release: 5.10.0 -- the boundary between a host and the code it runs (#…