Automation service ยท event path

From Webhook to Conversation

How an event reaches an agent, and how a second event about the same thing reaches the same agent instead of a fresh one.

Repo OpenHands/automation Branch vasco/external-conversations Head 56740d6 PR #385
Overview

Three ways a run begins, one way it executes

The automation service turns triggers into agent runs. A run is a sandbox holding an agent server, into which the service uploads a tarball and starts an entrypoint. Everything upstream of that is about deciding whether to start one.

A run is created by the scheduler when a cron automation is due, by a person calling the API, or by an event arriving from a provider. The first two always create a run. The third is the interesting one, because an event is often about something that already has a conversation: a Slack thread somebody is still replying in, a pull request that keeps collecting comments.

The change described here gives a trigger a second destination. Instead of creating a run, a matched event can be delivered as another turn on the conversation that earlier events about the same subject already opened.

Transport Ingest Decision Outcome GitHub signed webhook Slack socket mode Custom per-org secret accept_event record + deduplicate match triggers derive subject key one transaction destination per trigger Turn to a live conversation POST /api/conversations/{id}/events Turn folded into a queued run parked on that run's event_payload New run, carrying the subject automation_runs.subject_key now on boot on boot One conversation, on an agent server in a sandbox uuid5(NS, "org / automation / source / subject")
Every matched automation takes exactly one of three paths, and all three address the same conversation id. Only the third costs a new run and a new sandbox.
Transport

Acquisition is separate from routing

A transport owns acquisition, authentication and interpretation. It then hands the service an AcceptedEvent: a source, an event key such as issue_comment.created, the raw payload, the provider's delivery id, and optionally the subject the event is about. Nothing downstream knows how the event arrived.

Two transports exist today. The HTTP endpoint at POST /v1/events/{org}/{source} verifies a signature before doing anything else, with the scheme resolved per source from a provider registry. Built-in sources use a hex HMAC over the raw body; a custom webhook picks its own scheme and signature header, and Slack's and Standard Webhooks' schemes sign a timestamp, so they reject deliveries older than five minutes.

The second is a supervised socket connection. One task per source is held open for the life of the process, with the supervisor catching every exception so a failing provider cannot take down the scheduler, the dispatcher or HTTP handling. A source that is configured wrongly raises a config error and stops being supervised, because retrying cannot fix it. The Slack socket transport is the one that names its own subject, since it reads the envelope directly.

Ingest

One transaction, from delivery to decision

Every transport funnels into a single function, which records the event and resolves every matched automation before committing once.

  1. Record first. The event is inserted inside a nested savepoint, before any matching work. A redelivery then costs one failed insert rather than a full match pass. The savepoint matters: without it the constraint violation would poison the transaction the caller still has to commit.
  2. Deduplicate on the provider's id. A unique violation with a delivery id present means a redelivery, and the event is dropped. With no delivery id the dedupe index cannot be what conflicted, so the error is re-raised rather than swallowed. Reading it as a duplicate would silently discard a genuine event.
  3. Match. Candidate automations for the org and source are loaded ordered by id, and each trigger is tested on the event key and its JMESPath filter. The count of matches is written back onto the event row, which is what makes an event that matched nothing visible afterwards.
  4. Derive the subject, once. Only if at least one matched trigger asks to continue a conversation. The provider's own extractor runs unless the trigger overrides it with an expression.
  5. Route each match. A matched automation either continues a conversation or gets a run. It never gets both.

Typed events keep their model shape when stored, so a GitHub run reads the parsed event; everything else stores the raw payload.

Identity

The subject, and the id derived from it

A subject is the external thing an event is about, named by a string. Slack's is the team, channel and thread timestamp. GitHub's is the repository and the issue or pull request number, with both reading to one key because GitHub numbers them from a single sequence. An event about nothing numbered, such as a push, has no subject, and that is a real answer rather than a gap.

The conversation is not looked up. It is computed:

conversation_id = uuid5(NAMESPACE, f"{org_id}/{automation_id}/{source}/{subject_key}")

The namespace is pinned permanently. Changing it re-keys every live thread at once, which readers experience as every conversation losing its memory on a deploy.

The automation id is in the key deliberately. A conversation belongs to the automation whose script created it, with its prompt, its tools and its tarball. Two automations in one organization watching the same channel would otherwise share a conversation, and the second would send turns into one that cannot answer them. It also means editing an automation re-keys its threads rather than pinning a thread to whichever agent saw the first event.

How a subject is named

Built-in extractors cover the two sources that have one. A trigger can supply its own expression instead, which is how a custom webhook, or Slack arriving over the older webhook bridge, opts in. The expression is compiled at creation time with the same check the filter uses, because a typo would otherwise look exactly like the feature not being switched on. A result that is not a scalar is refused: a dictionary is not an identity, and a boolean would collapse every event onto one subject.

Trigger fieldDefaultEffect
destinationdispatch_runSet to continue_conversation to opt this trigger into threading. Per trigger, not per automation, because the trigger is where the event shape is already known.
subject_key_exprnullA JMESPath expression naming the subject, overriding the provider's extractor. Must evaluate to a scalar.
turn_text_exprnullA JMESPath expression producing the text of the delivered turn.
wake_agenttrueWhether the delivered turn also starts the agent. False leaves the message in history for the current turn to pick up.
Routing

What happens to a matched event

For each matched automation whose trigger asks to continue, the service takes the subject's lock, finds the most recent run holding that subject, and then picks one of three outcomes.

A live conversation

If the run has started, the turn is posted to the agent server holding it. The composed text is the trigger's expression if it has one, then the message the payload obviously carries, and only a shape nothing recognises falls back to the verbatim JSON. Dumping the payload unconditionally buried the one line a human wrote under kilobytes of webhook metadata, every single turn.

The request appends a user message and starts the agent loop only if one is not already running, so a message arriving mid-turn is queued into the live loop rather than starting a second one.

A run that has not started

If the run exists but has no start timestamp, there is no agent server to talk to yet, but that run will open the same derived conversation and has not read its payload. So the turn is parked on the run's payload under a private key, capped at twenty. The dispatcher lifts those back out at boot and the script replays them as extra messages. This is what stops a burst of messages on a brand-new subject from becoming a burst of runs.

Nothing holding the subject

No run, or a run whose sandbox is gone, means a run gets created and the subject is written onto it. That column is not a mapping to a conversation. The conversation id is derived and never stored. The column exists because the sandbox holding a conversation can only be named by the server.

Concurrency

Two events, one thread, several pods

Row locks alone cannot order two events that both find nothing, and that is exactly the burst that leaves one subject with two runs. So every event for a subject first takes a Postgres advisory lock keyed on a hash of the automation and the subject. It is transaction-scoped, so the caller's commit releases it, which means it also covers the run that event may go on to create. Because the lock lives in the database it orders events across pods, not just across tasks in one process.

The subject's run row is then selected for update, so a second event sees the first event's decision instead of racing it. Locks are taken in automation id order, which is why candidate automations are loaded ordered by id: without that, two events on one thread can lock the same rows in opposite orders and deadlock.

SQLite skips both, on the assumption of a single process, exactly as the dispatcher and scheduler do.

The key is hashed locally rather than by a database function, so it does not depend on a server-side hash staying stable across Postgres versions.
Execution

From a pending row to a running agent

Run creation and run execution are separate. A poller claims pending runs with a skip-locked select, marks each one running, and commits. Each run is then dispatched as its own background task so a long automation cannot block the loop.

  1. A provisioning deadline is set. The timeout is padded with the sandbox-ready budget and a margin, so the watchdog only reaps runs that die while provisioning. Once the entrypoint's shell command actually starts, the deadline is reset to that moment plus the run budget.
  2. The backend produces an execution context. In cloud mode that creates a sandbox and waits for it to be running, then reads the agent server URL out of its exposed URLs. In local mode it returns a pre-configured URL. Everything above this line is mode-agnostic.
  3. Environment is built. The callback and phase URLs, run and org identity, the event payload, the model, the sandbox session key, a scoped key-value token, and, for a run that owns a subject, the derived conversation id.
  4. The tarball is delivered and started. Either uploaded as bytes or downloaded inside the sandbox, then extracted, then a setup script if present, then the entrypoint. The call returns without waiting for it to finish.
  5. Identifiers are persisted. The shell command id, so the verifier can filter output belonging to this run and not to concurrent activity on a shared agent server, and the sandbox id, so a later event can find this conversation.

The service does not watch the run to completion. The script inside reports back.

Sandbox

The one place the derived id has to be honoured

Routing addresses a conversation the service never creates. The first run must therefore open its conversation under that same id, or every follow-up would address a conversation that does not exist and silently start a fresh thread instead.

Both shipped presets read the environment variable and pass it as the conversation id. The SDK's remote conversation then fetches that id and, on a 404, posts a create request carrying the id in the body. The agent server uses a client-supplied id when it is given one and generates a random one only otherwise. A conversation that exists but was started with a different agent kind raises rather than attaching, which is why re-keying on edit is the wanted behaviour rather than a compromise.

A custom tarball that ignores the variable gets a fresh conversation per event. That is documented, and it is the one part of the contract the service cannot enforce.

On completion the script posts back the conversation id, the accumulated cost and any run metadata.

Lifecycle

Who keeps the sandbox alive

Ordinarily the completion callback deletes the run's sandbox. Deleting the sandbox of a threaded run would destroy the conversation the next event is meant to continue, which would make threading a no-op in cloud mode. Rather than have every deletion site re-derive that, the question is settled once at creation: an automation whose trigger continues a conversation is required to keep its sandbox alive, and asking for the opposite is rejected outright. Every cleanup path then reads that one flag. The runtime's own reaper still collects the sandbox eventually, and that reaper is the real expiry policy. There is no mapping to go stale.

The watchdog reads the same flag when it terminalises a stuck run, and it is also the service's only periodic janitor, pruning recorded events on the same loop.

Cancellation is the exception. It is explicit, so the sandbox goes even when the run owns a subject, and the subject is released with it. Otherwise the next event would pick that run and pay a lookup for a sandbox that was just deleted. Released is not erased: the key stays on the row as the record of what the run was about, and a second timestamp column marks the moment it stopped routing.

Failure

How delivery degrades

Every failure to deliver a turn degrades to creating a run. Nothing raises, because a reaped sandbox is ordinary rather than exceptional.

Before giving up, two kinds of not-yet are waited out. A sandbox that is merely paused is resumed and polled for up to twenty seconds, since the conversation survives a pause and only the sandbox has to come back. A sandbox that answers but returns a 404 on the append is taken to mean the script has not opened the conversation yet, and the append is retried for up to ten seconds. That second wait applies only while the run is unfinished; once it has completed, a 404 means the conversation is gone for good and waiting would buy nothing.

If the run is finished and still unreachable, its subject is released, which stops every later event paying the timeout to rediscover the same reaped sandbox. A run that has not finished keeps its subject, because it may not have recorded its sandbox yet and releasing it would orphan the subject.

That last distinction is also where the remaining gap lives.

run created PENDING marked running started_at set, committed sandbox recorded sandbox_id written conversation opened script creates the derived id completed sandbox kept turn folds into the run no agent server yet a second run is created append is retried turn appends A follow-up event arriving in each window is handled differently. The amber window is bounded by sandbox provisioning alone.
Coalescing keys on the start timestamp alone, so it stops the moment the run is marked running. Delivery cannot begin until the sandbox id is recorded. Only what falls between those two points still forks.

Known gap: the provisioning window

The poller marks a run running and commits before the sandbox exists, and the sandbox id is written only after the entrypoint launches. In that window there is no agent server to resolve, so the turn is not delivered and a second run is created. The shorter window that used to follow it, where the sandbox is reachable but the script has not opened the conversation, is now waited out by the retry described above.

The subject is not released, because the run has not finished, so this is a fork rather than an orphan. Follow-ups after the conversation exists all land correctly on one thread.

The obvious fix does not work. The continuation path holds the run's row lock for the length of its attempt, and the dispatcher needs that same lock to write the sandbox id, so waiting inside the lock prevents the very write it waits for. Closing this properly means recording the sandbox id as soon as the sandbox exists rather than after the entrypoint starts, or parking the event in the queue this design does not adopt.

The dead heat that remains regardless

Two events arriving on a brand-new subject before either has a run at all will produce two runs. Dropping the loser would silently discard somebody's message, and holding it would need the pending-event queue this design deliberately does not adopt. Every follow-up after that lands on one conversation.

Reference

State and constants

The only schema change threading adds is two nullable columns on the run. Their shared index is partial, covering only the runs still routing for a subject.

Run columnWritten byRead for
subject_keyIngest, at run creationFinding the sandbox that holds a subject's conversation. Never erased, so it doubles as the record of what a run was about
subject_released_atCancellation, or a failed turn to a finished runExcluding a run from the lookup once it stops routing, without losing its key
sandbox_idDispatcher, after the entrypoint startsResolving the agent server, verification, cleanup
conversation_idCompletion callbackDisplay. Routing never reads it, since the id is derived
started_atPoller, at the transition to runningDeciding between folding a turn and delivering one
timeout_atPoller, then reset at shell startThe watchdog's indexed staleness scan
event_payloadIngest, plus folded turnsThe script's view of what triggered it
ConstantValueGoverns
TURN_TIMEOUT_SECONDS15The whole attempt to deliver one turn, including resolving the agent server
RESUME_WAIT_SECONDS20How long a paused sandbox is given to come back before falling back to a run
RESUME_POLL_SECONDS2Polling interval while waiting for that sandbox
CONVERSATION_WAIT_SECONDS10How long an append is retried against a 404 while the run is still opening its conversation
CONVERSATION_POLL_SECONDS2Polling interval for that retry
MAX_COALESCED_TURNS20Turns parked on one queued run. A burst is a handful of messages, not a mailbox
MAX_SUBJECT_KEY_LENGTH500Matches the column. Truncating would merge two subjects
Boundaries

What this deliberately is not

No session object

There is no lifecycle state owned by the service, no idle timeout, and no session table. A subject is a routing key, not a managed entity.

No event queue

Nothing buffers events that arrive before a sandbox is ready or while one is gone, beyond the turns folded onto a queued run. There is no acknowledgement protocol.

No script-side polling

The script does not pull pending events or acknowledge them. The service formats the turn and delivers it, which costs the script some control.

No sandbox-death policy

A dead sandbox always degrades to a new run. There is no configurable choice between queueing, restarting and dropping.

Each of those is additive. Nothing here forecloses building them, and the derived id survives all four, because it never depended on stored state in the first place.