Skip to main content
← Back to list
01Issue
FeatureShippedSwamp CLIPublic
Assigneeshammz

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: escalate
swamp 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 run

Behaviour

  • 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 signal takes 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 resume refuses while a wait is still open, as it does for an undecided gate. Otherwise it continues, with the payload restored into steps.<name>.outputs.

  • Timing out. timeout is required. A wait past its deadline refuses signals, and the next resume fails the step with reason wait_timeout, so failed handlers run. allowFailure decides 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 }
    }

    signal is written by swamp, never by the sender. submittedBy is the OS user, as decidedBy is for a local approve.

  • Addressing. A signal names the wait ID and nothing else. Step names are unique only within a job, and forEach expands 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 run cancels 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 inputs and the existing InputValidationService, which already supports type, properties, required, enum and additionalProperties. 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 as waiting, with the kind of wait recorded beside it, leaves room for other kinds of wait later.
  • Where the rules live. WorkflowRun stays the aggregate root and the wait is state on StepRun. The transitions (start waiting, accept a signal, time out) belong on StepRun, 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 signal follows workflow 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 signal on a machine with the repository and its datastore.
  • Resume is manual, including for runs that swamp serve started. The local command does no authorization, as local approve does none.
  • Deadlines are noticed only when something looks. An expired wait stays suspended until the next signal, waits or resume.
  • 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_signal step suspends at it and reports the wait ID in log and JSON output.
  • workflow waits lists open waits in log and JSON modes.
  • workflow signal accepts 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.payload selects the right branch.
  • After the deadline, a resume fails the step with wait_timeout and a failed handler runs.
  • workflow run does not supersede a run with an open signal wait.
  • Cancel and reject leave no step in the waiting status.
  • swamp serve does 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 a signal permission 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 --input can 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.
02Bog Flow
✓OPEN✓TRIAGED✓IN PROGRESS✓SHIPPED+ 1 MOREASSIGNED+ 11 MOREREVIEW+ 29 MOREPR_MERGED+ 2 MORESESSION_SUMMARIZED

Shipped

10/6/2026, 5:54:48 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
hammz assigned hammz10/6/2026, 2:17:30 PM
hammz linked sibling of #309210/6/2026, 5:40:36 PM

Sign in to post a ripple.