Inbox & Dispatch

What this is for

Multi-step automation often splits work across several workflows: one stage produces a result, another stage should run only after that result exists. You could glue stages together with temporary files and shell glue, but that is easy to get wrong (races, stale paths, unclear ownership).

Jaiph instead offers a first-class inbox: an in-process channel between workflows. One workflow sends a message (<-); another is dispatched when that message is processed (->). The runtime owns routing and ordering; there are no file watchers, no polling, and no external brokers.

At a glance

One workflow produces output with the send operator (<-), another reacts to it via a route declaration (->). The runtime handles dispatch — no file watchers, no polling, no external message brokers.

Send (<-), routes (->), and related parsing rules are specified in Grammar — Parse and runtime semantics (items 11–12). Restrictions on $(...) and bare shell calls also apply to the RHS of a send; see Grammar — Managed calls vs command substitution.

channel findings

workflow researcher {
  findings <- echo '## analysis results'
}

workflow analyst {
  echo "Received: $1"
}

workflow default {
  run researcher
  findings -> analyst
}

In this example, researcher sends data to the findings channel. The default workflow routes findings messages to analyst, which receives $1=message, $2=channel, $3=sender (see Trigger contract).

Design principles

Syntax

Channel declarations: channel <name>

Declare channels at top level, one per line:

channel findings
channel report

workflow default { ... }

Every channel used by send (<-) or route declarations (->) must be defined in the current module or imported from another module (e.g. shared.findings). Undefined channels fail validation with:

Send operator: <channel_ref> <- <command>

The channel reference is always on the left side of the <- operator. Valid forms:

The send operator captures the command’s stdout, writes it to the next inbox slot, and signals the runtime to dispatch.

channel findings

workflow researcher {
  findings <- echo '## findings'
}

If no command follows <-, the workflow’s $1 argument is forwarded:

channel findings

workflow forwarder {
  findings <-
}

The <- operator is only recognized when it appears outside of quoted strings. The parser tracks quote state to avoid false matches inside shell commands.

Transpilation:

Jaiph Bash (generated in the workflow ::impl)
ch <- echo "foo" jaiph::send 'ch' "$(echo "foo")" '<workflow>'
ch <- jaiph::send 'ch' "$1" '<workflow>'

(<workflow> is the name of the workflow that contains the send step.)

Route declaration: <channel_ref> -> <workflow>

Tells the runtime: when a message arrives on that channel, call each listed workflow with positional args $1=message, $2=channel, $3=sender.

Targets must be workflows (local or imported as alias.name). Rules and functions are not valid route targets — the compiler uses workflow-only reference checks, so a bad target is E_VALIDATE with messages such as unknown local workflow reference "…", imported workflow "…" does not exist, rule "…" must be called with ensure, or function "…" cannot be called with run. A name that is not a valid alias.name / name pattern fails at parse time as E_PARSE invalid workflow reference in route: "…".

channel findings
channel summary

workflow default {
  run researcher
  findings -> analyst
  summary -> reviewer
}

Multiple targets are supported (comma-separated). Sequential dispatch (default): each target runs in list order for that message. Parallel dispatch (run.inbox_parallel / JAIPH_INBOX_PARALLEL): all targets for the messages being drained in the current loop iteration may run concurrently; see Parallel dispatch.

findings -> analyst, reviewer

If you declare the same channel more than once (several findings -> … lines), jaiph::register_route merges targets onto that channel in source order.

Route declarations are static routing rules, not executable statements. They are stored in routes on the workflow definition, not in steps. The compiler validates that all target workflow references exist (see above).

Capture + send is a parse error

# E_PARSE: capture and send cannot be combined; use separate steps
name = channel <- cmd

Use two steps instead:

name = cmd
channel <- echo "$name"

Inbox layout

.jaiph/runs/<YYYY-MM-DD>/<HH-MM-SS>-<source-file>/inbox/
  001-findings.txt
  002-summary.txt
  003-findings.txt
  ...

Each message is a file named NNN-<channel>.txt where NNN is a zero-padded monotonic counter scoped to the run.

Runtime dispatch loop

Only the workflow that declares route rules gets the inbox infrastructure. The compiler emits jaiph::inbox_init and jaiph::register_route calls at the top of that workflow’s implementation, and jaiph::drain_queue at the end. Any child workflow called via run (or via dispatch) inherits the inbox environment and can call jaiph::send.

1. jaiph::inbox_init creates the inbox directory and resets state.
2. jaiph::register_route populates the routing table.
3. Execute the workflow steps top-to-bottom.
4. When <- is executed: write message to inbox dir, append to queue file.
5. After all steps complete, jaiph::drain_queue processes the queue in a loop:
   a. Read all lines from the current cursor through the end of `.queue` into memory (a snapshot); if there are none, stop.
   b. Walk each line (and bump the shared cursor / depth counter per line). Resolve the channel, load the message body from `NNN-<channel>.txt`, look up the route.
   c. If there is no route, skip that line (message file remains on disk).
   d. If there is a route, invoke each target with $1=message, $2=channel, $3=sender — **sequentially** in target-list order by default, or **all targets for all lines in the snapshot as background jobs, then one shared wait** when `JAIPH_INBOX_PARALLEL=true` (see [Ordering guarantees](#ordering-guarantees)).
   e. Targets may call jaiph::send, appending new lines after the cursor; the next outer-loop iteration reads them.
   f. Repeat from (a) until a read finds no new lines, or until the dispatch depth limit is exceeded (`E_DISPATCH_DEPTH`).
6. Run ends.

Implementation notes

Routes are stored as a newline-delimited list (channel<TAB>targets) instead of bash associative arrays, avoiding known bugs in bash 3.2 where reading a non-existent key can return the last inserted value.

The dispatch queue (inbox/.queue) uses channel:NNN:sender entries (e.g. findings:001:researcher). The sequence counter (inbox/.seq) is also file-backed. Both are files rather than shell variables so that increments and enqueues performed inside subshells (e.g. run_step pipelines) survive back into the parent process.

Parallel dispatch

When run.inbox_parallel = true is set in a config block (module or workflow scope) or the environment sets JAIPH_INBOX_PARALLEL=true, route targets are launched as concurrent background jobs instead of one-at-a-time calls.

Precedence matches the rest of Jaiph agent/run settings: an explicit environment value wins over in-file config. See Configuration — Defaults and precedence.

config {
  run.inbox_parallel = true
}

workflow default {
  run producer
  findings -> analyst, reviewer   # analyst and reviewer run in parallel
}

Ordering guarantees

jaiph::drain_queue runs in a loop. Each iteration reads every queue line from the current cursor through the end of the file (one snapshot), drains that snapshot, then repeats if new lines were appended while dispatch ran.

Lock behavior

Parallel dispatch introduces synchronous file locks around three shared-state files:

Lock target Protects When held
inbox/.seq.lock Inbox sequence counter + queue append During jaiph::send
.seq.lock (run dir) Step sequence counter During jaiph::next_step_id
run_summary.jsonl.lock Any append to run_summary.jsonl During each summary line write (all event types)

Locks use mkdir (atomic on POSIX). They are only acquired when JAIPH_INBOX_PARALLEL=true; sequential mode has zero lock overhead.

Failure propagation

If any background target exits non-zero, drain_queue waits for all other background jobs from the same parallel snapshot to finish, then exits with status 1. The owning workflow still fails when any dispatched target fails; only the moment of exit differs from strict sequential fail-fast.

Rollback

To revert to sequential dispatch, remove run.inbox_parallel = true from config or set JAIPH_INBOX_PARALLEL=false in the environment. Sequential mode is the default and requires no locks.

Error semantics

Trigger contract

Routed receivers get three positional arguments:

Arg Value
$1 Message payload (content sent to the channel)
$2 Channel name (e.g. findings)
$3 Sender name (workflow that produced the message)

Progress tree integration

Example output

workflow default
  ▸ workflow scanner
  ✓ workflow scanner (0s)
  ▸ workflow analyst (1="Found 3 issues in auth module", 2="findings", 3="scanner")
  ✓ workflow analyst (0s)
  ▸ workflow reviewer (1="Summary: Found 3 issues in auth ...", 2="report", 3="analyst")
  ✓ workflow reviewer (0s)
✓ PASS workflow default (0.1s)