Skip to content

CAPAB-003: External event delivery to waiting workflows requires explicit host call #181

Closed

Description

@Masterplanner25

Summary

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.

Activity

  1. added this to the v5.0 milestone on Jun 8, 2026
  2. removed this from the v5.0 milestone on Aug 25, 2026
  3. Masterplanner25 commented on Aug 30, 2026

    @Masterplanner25
    OwnerAuthor

    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.

  4. Masterplanner25 commented on Aug 31, 2026

    @Masterplanner25
    OwnerAuthor

    Re-checked at 5.8.0: still true, unchanged

    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.

    What has changed nearby, none of which closes it:

    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.

    Staying open.

  5. Masterplanner25 commented on Sep 4, 2026

    @Masterplanner25
    OwnerAuthor

    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().

    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:

    wait.event_type='order.paid'  wait.correlation_key='cust-42'  (durably stored)
    
    store methods mentioning event_type:
      claim_waiting_run_for_resume(run_id, *, owner, event_type=None, correlation_key=None)
      register_wait(run_id, *, event_type, ...)
    

    Both ends exist and the middle is missing. claim_waiting_run_for_resume takes an event_type only 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).
    • CLI: nodus workflow deliver <event_type> [--correlation-key K] [--payload JSON] [--all].
    • 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

    1. 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.

    2. 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.

    3. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions