The SDK inside the sandbox POSTs /v1/runs/{id}/complete on exit with {status, conversation_id, error}. Coarse, terminal, fire-and-forget. A watchdog covers a missed callback. No per-message delivery, no ret/ack of user-facing messages.
Two outboxes, compared
Your read weeks ago: in the automation repo the message queue's inbox was carefully designed, but the outbox felt thin. This is the KISS comparison — the automation repo's result-return vs our coordinator's delivery queue — and where Rohit's TaskOutcome (PR #4465) lands.
1The one idea
They solve different halves of "outbox," and that's the whole comparison. The automation repo's outbox is one terminal result callback per run — "the run finished, here's the status." Our coordinator's outbox is a durable per-message delivery queue — "these N messages must reach this channel, exactly-ish once, in order, and I must know if a send's fate is unknown." PR #4465 makes the automation repo's one callback richer (a structured TaskOutcome); it does not give it delivery semantics.
Each outbound (a send_message the agent emits) becomes a durable delivery work row: claim → send → ack, with head-of-line order, fenced claims, backoff, and a first-class delivery_unknown for "the send's effect is ambiguous — never blind-retry."
2The two outboxes, side by side
3Automation repo — one completion callback
The automation service's result-return is a single endpoint. The SDK inside the execution sandbox calls it on context-manager exit:
# automation/schemas.py:614 — the entire outbound payload
class RunCompleteRequest(BaseModel):
status: Literal["COMPLETED", "FAILED"]
run_id: str | None = None
conversation_id: str | None = None
error: str | None = None # free-text, only when FAILED
# automation/router.py:325 — RUNNING → COMPLETED|FAILED, optimistic lock
@router.post("/runs/{run_id}/complete")
- Binary status — COMPLETED or FAILED, plus a free-text
error. No "blocked", no "needs user action", no confidence, no structured blockers. - Terminal, once per run — it reports the run ending, not individual user-facing messages. Fire-and-forget; a watchdog marks FAILED if the callback is missed (
watchdog.py:198). - No message delivery at all — there is no notion of "deliver these replies to a channel, retry, ack." That was never the automation outbox's job. OpenHands/automation
This is the "unfinished outbox" feel: the inbox side (dispatcher: SELECT … FOR UPDATE SKIP LOCKED atomic claim, PENDING queue, per-run dedup) is a real queue; the outbox side is a single status ping. Fine for "did the background run pass or fail," thin for "reliably return work products to a human."
4Coordinator — a real delivery queue
Our coordinator treats delivery as first-class durable work. Each outbound message is a delivery row in the unified work table with its own lifecycle:
# coordinator/types.ts — the delivery vocabulary
WorkState = 'ready' | 'claimed' | 'done' | 'retry_wait' | 'failed' | 'delivery_unknown'
SettleOutcome = {done, externalMessageId} | {retry} | {delivery_unknown} | {fail}
markSending(claim) # durably set send_attempted=1 BEFORE the network send
settle(claim, outcome)
- Per-message — the projector turns durable
send_messageaction events into delivery rows (destination = the conversation's bound lane). DESIGN.md §8 D1 - Head-of-line order — a later message never overtakes an unresolved earlier one in the same lane.
- Fenced claims + backoff — single-winner claim under concurrent workers; expiry → exponential backoff →
failed. delivery_unknownis the headline —send_attempted=1is set durably before the send; if the claim then expires with the response lost, the row goes todelivery_unknownand never auto-retries (an ambiguous send is never blind-replayed). This is the exact gap the old runner's outbox had ("nodelivery_unknown: an ambiguous send is either dropped or blindly re-sent").
So the coordinator's outbox answers the questions the automation callback can't: which message, in what order, delivered or not, and what to do when we can't tell.
5Feature-by-feature
| Outbox concern | Automation repo | Coordinator |
|---|---|---|
| Unit | whole run | each message / outbound |
| Result vocabulary | COMPLETED / FAILED + free-text error | ready/claimed/done/retry_wait/failed/delivery_unknown |
| User-facing message delivery | — (not its job) | projected from send_message, delivered to a lane |
| Ordering | n/a | per-lane head-of-line, monotonic sequence |
| Retry / claim | watchdog re-check on missed callback | fenced compare-and-set claim, expiry, backoff |
| Ambiguous send | — (can double-send or drop) | delivery_unknown, never blind-retried |
| Idempotency | optimistic lock on run row | unique (kind, source_key); deterministic event id on intake |
| Semantic outcome (success/blocked/…) | binary — #4465 enriches this | not modeled here (delivery ≠ semantics) |
6Where PR #4465 fits
#4465 (TaskOutcome) is an enrichment of the automation repo's completion callback, not a delivery-queue feature. It upgrades the coarse {status: COMPLETED|FAILED, error} into a structured {status: success|partial_success|blocked|failed|unknown, blockers[], needs_user_action, confidence, source} and forwards it as agent_outcome on that same completion callback (opt-in via AUTOMATION_CALLBACK_SUPPORTS_AGENT_OUTCOME). details
The clean way to see all three: the automation callback is a run-level result channel; #4465 makes that result semantically rich; the coordinator is a message-level delivery channel. #4465 and the coordinator are complementary, not overlapping — one answers "how did the task go," the other "did each reply actually get out."
The one place they touch: a "blocked, needs user action" TaskOutcome is exactly the kind of thing that should then be delivered to the user — i.e. #4465 produces content that a coordinator-style outbox would deliver. Task outcome = what to say; delivery queue = getting it there reliably.
7So what
- Your instinct holds: the automation repo's outbox is genuinely the thin side — a single terminal status callback, no per-message delivery, no ambiguous-send handling. The inbox (dispatcher/queue) is the finished part.
- #4465 improves the thin side without changing its shape — richer result, still one callback per run. It does not add delivery semantics, and it doesn't need to.
- The coordinator is the missing delivery half — and it already models the hard case (
delivery_unknown) that neither the automation callback nor the old SmolPaws runner handled. - Open question for us: do we want the coordinator to also carry a semantic outcome per lane (mirroring #4465's
TaskOutcome) so a "blocked" result can be delivered as a message, or keep semantics strictly upstream and let the coordinator stay a dumb, reliable pipe? KISS says: keep it a pipe; letTaskOutcomebe payload.
~/repos/automation main · coordinator @ smolpaws/src/coordinator (Aug 5) · ADR smolpaws-message-work-adr🐾 smolpaws · for Engel · code read, not a live run · KISS by request