Skip to content

OrderAgent

The per-order Durable Object that holds live state, owns waits and approvals, cancels, and answers the ops chat.

OrderAgent (apps/engine/src/agent.ts) is an Agents SDK Agent, one Durable Object per order, named by the order id (orderAgentName in @orderloop/core). It:

  • holds the order’s live state, synced to every browser on the order page through useAgent;
  • records what the Workflow reports;
  • owns the order’s waits and delivers approvals and events to the right instance;
  • cancels with compensation;
  • answers the ops chat with tools over the same state.

The synced state is OrderState from packages/core/src/api.ts, plus a private seq:

Field Meaning
orderId, order, flow The order and the pinned FlowVersion
status One of queued, running, waiting, completed, declined, cancelled, failed, rolled_back
nodes NodeRun per node: status, runs, summary, output, error, branches, startedAt, endedAt
edges EdgeState per edge, from the latest plan
waits Open PendingWaits: node, event type, kind, title, event, amount, assignee, deadline
vars Run variables
timeline TimelineEntrys, capped at the last 400 (TIMELINE_CAP)
chat Ops chat messages, capped at the last 100
instanceId, generation The Workflow instance driving the order and its generation
rollback { outcome: 'complete' | 'failed', error } once a failed run has settled
reason Why it was cancelled, declined or failed

validateStateChange throws for any change whose source is not the server: browsers may read state and call methods, never write state.

init(order, flow, instanceId) is called once by startOrder: status queued, every node pending, generation 1, and a first timeline entry naming the flow and version.

report(r) merges a report from the Workflow (see OrderLoop Workflow):

  • Sequenced reports (with seq) are fresh if they come from a newer generation, or the same generation with a higher seq. Only fresh ones update plan, order and vars. A plan never clears a node already rolled_back or failed; those come from their own reports.
  • Unsequenced detail reports always apply their node patch and log entry.
  • A status applies only if the order is not already terminal (completed, declined, cancelled, failed, rolled_back), so a late report cannot reopen a finished order. A status change adds a timeline entry keyed status:${generation}:${status}.
  • Timeline entries with a key replace the entry with the same key rather than appending.

After a status change or an order patch the agent writes the index row (syncIndex). When the status becomes failed it schedules checkInstance.

A node patch with startedAt starts a run, unless the node is already running (started, not ended); a retried attempt then keeps the run’s first start, so a duration includes its retries. Waits start in openWait. The first endedAt for a running node, at or after its start, writes a node_runs row (recordRun, onConflictDoNothing), with the run number from the plan and the generation. A replayed report reaches a node that has already ended, or a later run of it, so it never adds a row. Nodes without a start (an End node, a control.wait sleep) are not timed.

These rules are what make replays and retries safe: apps/engine/tests/agent.test.ts covers replayed snapshots, deduped keys, new generations and sticky terminal statuses.

syncIndex updates the order’s D1 orders row from state: status, total, customer_id, title, instance_id, generation, waiting_on (the titles of open waits joined with ·) and updated_at. The dashboard and GET /api/v1/orders read that row; GET /api/v1/orders/:id reads the agent.

The Workflow calls openWait from a node’s :open step and closeWait from its :close step.

  • openWait(w) is a no-op if a wait with the same eventType is already open (a retried step). For an approval it inserts an approvals row (status: 'pending', onConflictDoNothing), then adds the wait, starts the node’s run at the wait’s since, and adds an open:${eventType} timeline entry.
  • closeWait(eventType, outcome, payload) updates the approval row with the outcome, decided_at, decided_by (the payload’s by, timeout for an escalation) and note, removes the wait, and adds a close:${eventType} entry.

Methods marked @callable() can be called from the order page over the socket; EngineApi calls the same methods over RPC.

Method Behaviour
decide(nodeId, decision, by = 'operator', note?) Finds the approval wait for nodeId (throws if none) and sends its event type to the instance with { decision, by, note }
deliver(event, payload = {}) Finds the event wait for event. If none is open, returns { delivered: false }; otherwise sends { ...payload, event, at } and returns { delivered: true, nodeId }
cancel(reason = 'Cancelled by operator') See below. Returns { ok: false } for an order that has already ended
chat(text) See below

The web app proxies the socket with the signed-in principal in x-orderloop-principal (PRINCIPAL_HEADER, base64 JSON from encodePrincipal in packages/core/src/roles.ts), set by the web Worker after removing any copy the browser sent; the engine has no public route. onConnect stores it as the connection’s state. For a signed-in person the header also carries their session’s id (the SHA-256 that keys sessions), which never reaches the browser.

Each callable calls authorize(min). On a socket call it re-reads the session and its user from D1, so a page that is already open follows changes made since it connected: a lower role applies at once, and once the session has been signed out, reset or expired, or the user disabled, the call fails with Your sign-in has ended. Reload the page to sign in again. and the connection closes with code 4401 just after. A caller without at least min gets This needs the <Role> role.; a socket with no principal is refused. The shared password and open local dev have no session to re-check. Calls over RPC have no connection and are trusted, because the web app’s routes already checked the role.

Method Socket role
chat viewer
decide approver; by is replaced by the caller’s email (or name)
deliver, cancel operator

Readonly connections (shouldConnectionBeReadonly) aren’t used: they would also block the agent’s own state writes, such as a viewer’s chat history.

deliver returning delivered: false is what lets POST /api/v1/webhooks/events answer 409 until a node is waiting for the event.

cancel terminates the Workflow instance (logging, not failing, if that throws), runs compensateOrder (release held stock, void open authorizations, cancel booked shipments), marks every pending approval row cancelled, sets status cancelled with the reason and clears the waits. Terminating means no End node runs, so the agent announces the ending itself: it schedules emitWebhook for order.cancelled.

emitWebhook({ key, event, data, attempt }) calls the same emit as the Workflow’s emit steps, keyed by key. While deliveries are pending it reschedules itself after 10 * 2^attempt seconds, up to 5 retries (WEBHOOK_RETRIES), matching the Workflow’s backoff, then leaves the rows failed.

A failed instance runs its compensations inside Workflows, after the order is already failed. checkInstance polls the instance status every 10 seconds (up to 60 attempts) until it is errored, terminated or complete, then records rollback from the instance’s rollback field. Runtimes that omit that field (including local dev) fall back to the node reports: an errored instance with any rolled_back node counts as a complete rollback. A complete rollback sets status rolled_back; a failed one stays failed with the error.

continuedAs(instanceId, generation) ignores anything not newer than the current generation, then switches instanceId and generation, resets seq to 0 and logs Continued as run N.

chat(text) appends the user’s message (truncated to 4000 characters), then runs up to four model turns through runJson with a schema of { action: 'reply' | 'tool', tool?, args?, reply? }. The model sees a brief of the order state (status, order, waits, vars, non-pending nodes, the last 10 timeline messages), the tools called so far and the last eight messages.

Tool Effect
get_state Returns the brief
approve, reject Prepares a ChatProposal of kind decide for the waiting approval; does not decide
deliver_event Calls deliver directly
cancel Prepares a ChatProposal of kind cancel; does not cancel

Decisions and cancellations from chat are only ever proposals: the answer carries the proposal, and a person confirms it on the order page with the same undo window as any other decision. With AI_MODE=mock or a model error, chatHeuristic picks a tool from keywords or replies with the order’s status. See AI nodes and the copilot.