Relationships
↔ sibling #3092#3068 Add a wait_for_signal workflow step that pauses a run for a JSON message
Opened by hammz · 10/6/2026· Shipped 10/6/2026
Problem
A workflow can pause for one thing today: a person approving a manual_approval gate. The gate's answer is yes or no. A workflow cannot pause for a piece of information and then act on it.
That rules out a common shape of automation, where one operation needs an answer from outside before it can continue:
- A reviewer chooses between shipping, sending the change back, or abandoning it.
- A script or CI job finishes some work elsewhere and reports a result.
- An operator supplies a value that was not known when the run started.
The workarounds are poor. Several approval gates can stand in for a choice, but each gate is a separate yes or no and the run cannot tell which one was meant. Splitting the operation into two workflows loses the single run history, and the second workflow cannot read the first one's step outputs.
Proposed solution
Add a step task, wait_for_signal, that suspends the run until a small JSON message arrives or a deadline passes. The message becomes the step's output, so later steps can branch on it with ordinary guards.
This request covers a local, command-line version only. It is a complete loop that a person or a script can drive by hand.
Usage
jobs:
- name: release
steps:
- name: review
allowFailure: true
task:
type: wait_for_signal
timeout: 86400
schema:
type: object
additionalProperties: false
required: [verdict]
properties:
verdict:
type: string
enum: [ship, fix, abandon]
- name: ship
dependsOn:
- step: review
condition: { type: succeeded }
# A guard skips the step when it is truthy:
# skip unless the verdict is ship.
guard: ${{ steps.review.outputs.payload.verdict != "ship" }}
task:
type: model_method
modelIdOrName: release
methodName: deploy
- name: escalate
dependsOn:
- step: review
condition: { type: failed }
task:
type: model_method
modelIdOrName: release
methodName: escalateswamp workflow run release # runs to the wait, prints the wait ID, exits suspended
swamp workflow waits # lists open waits: ID, workflow, step, deadline, schema
swamp workflow signal <waitId> --payload '{"verdict":"ship"}'
swamp workflow resume release # continues the runBehaviour
Suspending. Reaching the step suspends the run the way an approval gate does: sibling steps in flight finish, the record is saved, and the command exits. The step gets a fresh random wait ID.
Signalling.
workflow signaltakes the run's claim, re-reads the run, checks that the step is still waiting and inside its deadline, validates the payload against the step's schema, records it, and marks the step succeeded. An invalid payload is refused and the wait stays open. A second signal to a settled wait is refused and shown the stored receipt.Resuming.
workflow resumerefuses while a wait is still open, as it does for an undecided gate. Otherwise it continues, with the payload restored intosteps.<name>.outputs.Timing out.
timeoutis required. A wait past its deadline refuses signals, and the next resume fails the step with reasonwait_timeout, sofailedhandlers run.allowFailuredecides whether that fails the job and the run.Output. The step's output is:
steps.review.outputs = { payload: { verdict: "ship" }, signal: { id, waitId, receivedAt, submittedBy } }signalis written by swamp, never by the sender.submittedByis the OS user, asdecidedByis for a local approve.Addressing. A signal names the wait ID and nothing else. Step names are unique only within a job, and
forEachexpands one step into many, so a name does not identify a wait.Cancel and reject settle a waiting step as they settle a waiting gate.
Supersede.
workflow runcancels suspended runs of the same workflow with matching inputs. It must leave alone a run with an open signal wait, and report it as skipped. Otherwise a workflow with no inputs would cancel its own waiting run each time it is started.
Design notes
- Schema dialect. The payload schema uses the same form as workflow
inputsand the existingInputValidationService, which already supportstype,properties,required,enumandadditionalProperties. No new validation library. - Captured schema. The schema is stored on the step when it starts waiting, so a later edit to the workflow file does not change what an open wait accepts.
- A new step status. The waiting step gets its own status and does not reuse
waiting_approval. An older swamp binary would otherwise list the wait as an approval gate, and approving it would succeed the step with no payload. A status the older binary does not know makes it refuse the run instead. A neutral name such aswaiting, with the kind of wait recorded beside it, leaves room for other kinds of wait later. - Where the rules live.
WorkflowRunstays the aggregate root and the wait is state onStepRun. The transitions (start waiting, accept a signal, time out) belong onStepRun, so "only an open, unexpired wait accepts a signal" is enforced in one place. Resetting a step for a retry clears the wait, so the retry gets a new wait ID and a signal for the old attempt finds nothing open. - Reuse.
workflow signalfollowsworkflow approve: same run claim, same load, change and save. No new storage, repository or port.
Limits of this version
- Only the CLI can signal. An outside system cannot call in by itself; something must run
swamp workflow signalon a machine with the repository and its datastore. - Resume is manual, including for runs that
swamp servestarted. The local command does no authorization, as localapprovedoes none. - Deadlines are noticed only when something looks. An expired wait stays suspended until the next
signal,waitsorresume. - Payloads are small and not secret. The payload is stored in the plaintext run record, so it is capped in size (proposed 16 KiB) and must not carry secrets.
- A signal that arrives while the run is still finishing sibling steps is refused as not yet suspended, and can be retried.
- Older binaries cannot read a run with an open wait, and treat a workflow file containing the task as broken.
Acceptance criteria
- A workflow with a
wait_for_signalstep suspends at it and reports the wait ID in log and JSON output. workflow waitslists open waits in log and JSON modes.workflow signalaccepts a valid payload, and refuses an invalid payload, an unknown wait ID, an expired wait and an already settled wait, each with a distinct message.- After a signal and a resume, a guard reading
steps.<name>.outputs.payloadselects the right branch. - After the deadline, a resume fails the step with
wait_timeoutand afailedhandler runs. workflow rundoes not supersede a run with an open signal wait.- Cancel and reject leave no step in the waiting status.
swamp servedoes not auto-resume a run that still has an open wait after its last gate is approved.
What this makes possible later
Each of these builds on the task, the wait ID, the receipt and the output shape defined here, without changing them.
- Delivery through
swamp serve. Signals over WebSocket and authenticated HTTP, with asignalpermission that allows delivering to a wait and nothing more. - Automatic continuation. Serve resumes a run once its waits are settled, and fails waits at their deadline without anyone looking.
- Callbacks from outside systems. Provider webhooks routed to a specific wait; reserving a wait before a request is sent, so a fast callback is not lost; one-time links for a person to answer from a browser.
- Waiting on a condition. A sibling task that checks something on an interval until it holds, reusing the same suspend and resume path.
- Larger and secret payloads.
Alternatives considered
- Several approval gates. Works for yes or no. It cannot carry a value, and a choice among three becomes three gates with no record of which answer was given.
- Two workflows joined by a webhook. Right for two distinct operations. For one operation it splits the run history and loses access to earlier step outputs.
- Resume-time inputs.
workflow resume --inputcan already carry values into a suspended run, but they change the run's inputs and are not a step's result. There is no deadline, a step cannot depend on the value arriving, and only the input names are recorded, not the values or who supplied them. - Polling inside a model method. Right for short waits. It holds a process and a lock for the whole wait and does not survive a restart.
Shipped
Click a lifecycle step above to view its details.