← Writing

OpenClaw

OpenClaw Says “Accepted.” When Is the Work Actually Done?

A successful request, an accepted task, and a finished execution are different milestones. Follow OpenClaw Gateway through acknowledgments, idempotent retries, wait deadlines, and event recovery.

You send a message to an agent. The API quickly reports success, and the loading indicator disappears. A few seconds later, the model starts producing output. A little later, tools are still running. Which part did the application misunderstand?

The problem often lies in the meaning of success. Delivering a message, accepting a task, and finishing its execution are three different moments. A short query can collapse them into one response. A long-running agent usually cannot.

The previous article examined how OpenClaw assigns messages to sessions. This time we follow the Gateway: after sending a request, how does a client recognize acceptance, observe progress, and establish completion? The analysis uses v2026.5.22; all source links point to the same pinned commit.

A connected WebSocket is not yet a chat connection

WebSocket provides a connection on which both sides can keep exchanging data. It does not define identity, permissions, request correlation, or task completion. Those remain the Gateway protocol's responsibilities.

The entry point is the connection callback registered by attachGatewayWsConnectionHandler. The server first sends connect.challenge. After obtaining the nonce, the client sends a connect request declaring its protocol range, client information, role, and scopes. Once authentication and the other checks pass, the server returns hello-ok, including capabilities, a state snapshot, and connection policies.

“The first request must be connect” refers to the client's first request, not the first frame traveling in either direction: the server's challenge arrives first. Sending a business method before completing the handshake causes the message handler to reject it. Transport connectivity and application readiness should not share one boolean.

A successful handshake does not grant every capability. Subsequent requests still pass method-level role and scope checks before reaching a handler. This separates “who are you, and what can this connection negotiate?” from “is this particular operation allowed?” This article establishes that boundary without attempting to cover device pairing or the complete authorization model.

Three frame types answer three questions

The protocol definitions distinguish req, res, and event at the top level.

Frame Key fields Question it answers
req id, method, params What operation does the client want?
res id, ok, payload or error What response did this request receive?
event event, payload, optional seq What changed in the system?

This allows one connection to carry multiple requests while the server emits events without a corresponding new request. Receiving any frame does not establish that the current request succeeded. Nor should the latest response finish whichever task happened to start most recently.

Three identifiers also need separate meanings. Request id correlates an RPC exchange. runId identifies an execution. sessionKey identifies shared conversation context. A session can host many executions over time, and multiple requests can query one execution. These identifiers may appear next to each other, but their responsibilities differ.

The Node client's request() generates a fresh UUID for each call and stores its waiting Promise in pending. Responses find their waiter by id; events take a different path. That correlation makes concurrent RPCs possible.

Why one request can receive two responses

The agent method contains the most interesting behavior. After parameter validation, session preparation, and related work, it registers the execution and cancellation state, stores an in-progress dedupe entry, and returns status: "accepted". Only then does it schedule execution.

The order matters. If the server acknowledged acceptance before registering the active task, an immediate cancellation request could fail to find its target. Registration precedes acknowledgment to avoid that window. The acceptance path also yields briefly after acknowledging, giving the frame and immediately following queries an opportunity to be processed. This is not a guarantee of network delivery.

Execution enters agentCommandFromIngress. When its Promise settles, dispatchAgentRunFromGateway stores the terminal state and sends a second res through the original respond callback. That closure is bound to the original request id, so both responses carry the same request ID.

The agent RPC acknowledges acceptance, then returns a terminal outcome with the same request ID
Terminal denotes a terminal outcome, not a literal status value. This diagram describes the agent RPC path.

This separates quick acknowledgment from slow completion while reusing response correlation. But a protocol that supports two responses does not imply that every client waits for the second.

In handleMessage, an ordinary call removes its pending entry and settles its Promise on the first response. A later response with the same ID finds no waiter and is ignored. Setting expectFinal: true changes the handling of accepted: the waiter remains, onAccepted is notified once, and the client continues waiting for the later result.

There is a subtle limitation: this branch special-cases only accepted. If a retry receives in_flight, the Promise still resolves even with expectFinal. A resolved Promise does not establish a completed execution. Integrations must inspect the business status as well.

Is a retry checking the task, or starting another one?

Suppose the server accepted the task but the client never saw the acknowledgment. Resending looks like recovery from a network fault. It can instead repeat model calls and tool side effects.

The agent handler uses a caller-provided idempotencyKey; on this path, runId equals that value. The dedupe key has an agent: prefix. A repeated submission with the same key returns in_flight if the original remains accepted, or the cached result if a terminal outcome is available.

Request IDs and idempotency keys therefore need separate roles. A retry is a new RPC and may have a new request ID. If it represents the same business intent, it should retain the idempotency key. A new key gives the server reason to treat it as another execution. Conversely, unrelated operations should not reuse a key merely for convenience.

This is not a permanent exactly-once guarantee. The mechanism here is an in-memory dedupe cache. Its maintenance logic removes expired and excess entries while preserving active runs and certain pending registrations. A cache with a lifecycle is not a transaction guarantee across restarts and every possible failure.

Once the runId is known, agent.wait expresses the intention to check completion more directly than repeatedly submitting the task with new keys.

No result yet does not mean execution stopped

agent.wait first looks for a usable terminal snapshot. Otherwise it waits on two paths: agent lifecycle records and terminal Gateway dedupe records. When either produces a valid result, it cleans up the other waiting path.

The difficult part is deciding which result belongs to the current execution. One run ID can have records from different sources and times. A newer agent execution may still be active while an older chat record says it finished. Ending the wait on any successful record would mistake past state for present state.

readTerminalSnapshotFromGatewayDedupe implements precedence rules for this situation. accepted, started, and in_flight are not terminal. An older chat terminal record cannot finish the wait ahead of a newer active agent entry. Specific terminal records produced by RPC cancellation are protected too: a late success cannot simply overwrite them.

The key distinction is what a timeout measures. A local client RPC deadline, the observation deadline in agent.wait, and an agent's execution timeout belong to different layers. An expired waiter can stop observing without cancelling the execution. Similarly, ok: true can carry status: "timeout": successfully handling the request and successfully completing the task are separate dimensions.

The client-side waiting budget deserves its own configuration. Ordinary requests use the default RPC timeout. When expectFinal is enabled without an explicit timeout, this layer installs no waiting timer by default. That avoids cutting a long task short with an ordinary RPC budget, but does not provide a complete execution-deadline policy.

An integration should decide separately how long the user will wait in the current interface, how long the background execution may run, and which identifiers must survive a disconnection so observation can resume. Those limits can differ. A local abort signal cleans up the request waiter; changing the server-side execution still requires an explicit cancellation path.

A product can show “no result yet” as an unresolved observation and continue checking. If the user explicitly wants to stop, send cancellation. Combining these intentions can produce an interface that reports failure while work continues in the background, or triggers duplicate operations through retries.

Retry with the same idempotency key, observe with agent.wait, and cancel explicitly to stop

The chat interface follows a different completion path

The preceding discussion concerns the agent RPC. It should not be copied wholesale into a web chat integration. In this version, chat.send first returns status: "started". The web interface then processes chat events carrying delta, final, aborted, or error.

handleChatEvent checks the session and active run ID. A final message from another execution should not casually clear the current execution's loading state. The problem is now ownership of a state change, not merely whether a request returned.

An event stream is not automatically a reliable log. The broadcaster maintains an outer seq per connection. Permission filtering happens before sequence allocation, so filtering out events outside a client's scopes does not itself introduce gaps. Targeted events can omit this outer sequence altogether.

Slow consumers are different. A broadcast marked as droppable consumes a sequence number and skips sending when the client is too slow. A later message can therefore expose a gap. For a non-droppable message, exceeding the buffer limit closes the connection. This contains a slow client's resource pressure at the connection boundary, while leaving recovery work to the client.

onGap reports an observed gap; it does not automatically replay missing content. The outer connection sequence is also distinct from a run sequence inside an event payload. Clients need a recovery strategy involving authoritative state or history. An increasing number alone does not establish a complete reconnect-and-replay contract.

What I checked locally

I ran 26 focused source checks. They directly execute the original terminal-wait module and dependencies; extract the unchanged client message handler and exercise it with fixed frames and validator doubles; and execute the original broadcaster body with simulated sockets to inspect filtering, drops, and slow-consumer behavior.

The checks confirm several unintuitive outcomes: default requests resolve on accepted; expectFinal retains the waiter specifically for accepted; an older chat terminal entry cannot overtake a newer active agent; wait expiry does not modify the running entry; and dropped broadcasts leave observable sequence gaps. All passed. This is not the full upstream suite and does not exercise a live handshake, model, network reconnect, or complete authorization chain.

Define completion before building the integration

Starting a client from scratch, I would track connection state, pending RPC state, and execution state separately. The first version would preserve the relationship among request IDs, run IDs, and idempotency keys. Every retry would begin by asking whether the business intent changed. Acceptance, active execution, success, failure, cancellation, and an expired observation deadline would have explicit state transitions.

Three choices in OpenClaw are especially useful: separating fast acknowledgment from terminal outcome, deduplicating retries around business identity, and using events for observation without treating them as a substitute for authoritative state. Their costs are concrete too. Clients must understand multiple statuses. Distributed ownership of terminal state complicates consistency. Without a recovery strategy, a healthy connection can still leave the interface wrong.

My first improvement to an integration would be a per-method state table that identifies the initial response, subsequent completion signal, timeout meaning, and cancellation behavior. More elaborate retries can follow. For a long-running agent, the priority is not making the button clickable as quickly as possible. It is helping the user distinguish a delivered request, work still in progress, and an execution that has actually ended.

继续阅读