~/blog/dispatch-tickets-not-conversations

AI Workflow · part 10

[Dev Workflow] Delegating to an AI Coding Agent: The Unit of Work Is a Ticket File, Not a Conversation

cat --toc

TL;DR

Handing a task to an AI coding agent isn't a conversation — it's a work order. I dispatch engineering tasks to Codex as self-contained ticket files (goal / steps / machine-checkable acceptance / red lines), run them detached, and gate the report before trusting it. The rule I paid for: a background worker that hangs never tells you. One Codex run sat dead for 2 hours 17 minutes — hung about five seconds in — because I hadn't pinned three flags or attached a watchdog. Three scars became three flags (no nohup &, -c "mcp_servers={}", < /dev/null) plus a 5-minute liveness check. And a report is not a result: re-run the acceptance before you believe it.

I dispatched a ticket to Codex and went to do something else. Two hours and seventeen minutes later, it was still "running." It wasn't running. It had hung about five seconds in — CPU frozen near zero, its session log stuck at two lines: the prompt, and task_started. I only noticed because a passing "it can't be taking this long?" made me go look.

You notice a crash — it exits, prints a stack trace, something wakes you. A hang in a background worker is silent. It looks exactly like work. That afternoon is why I no longer talk tasks to an AI executor. I hand it a file.

This is Part 10 of the AI Workflow series. Part 9 externalized live state — what's done, what's mid-flight — so a fresh session could pick a task back up. This one externalizes the thing one step earlier: the task itself. Same through-line as the publish-gate hook — take the discipline out of my head and put it somewhere a machine enforces it.

The unit of delegation is a file, not a conversation

When I first started routing work to Codex, I did it the obvious way: I described the task in the chat, the way you'd explain it to a colleague leaning over your desk. It worked when I was sitting there to answer the follow-up questions. It fell apart the moment I wanted to walk away — which is the entire point of delegating.

A conversation is a bad unit of work for three reasons. It isn't self-contained: half the context lives in the scrollback the executor can't see. It has no acceptance criteria: "make it work" isn't something a process can check against itself. And it dies with the session: the moment the window closes, the task is unrecoverable.

So my first rule applies before I dispatch anything. Ask one question: is this an engineering execution ticket? — env setup, a data pipeline, a batch of mechanical edits, a build chore, anything whose acceptance a machine can verify. If yes, it becomes a ticket and goes to the executor. If it's judgment, taste, or a one-off call, it stays with me. (The exception that keeps me honest: a one-line config edit I can verify and roll back in seconds isn't worth a ticket — writing the ticket costs more than the change. The threshold is investigation or cross-file logic, not lines changed.)

The commander doesn't step onto the field. The main session routes, judges, and integrates; the execution happens somewhere else, against a file.

A ticket is four blocks: goal, steps, machine-checkable acceptance, red lines

The format is deliberately plain, because the reader is a fresh process with none of my context:

A dispatch ticket has four blocks. Goal: what 'done' means in one or two lines. Steps: the required changes, numbered. Acceptance: machine-checkable conditions — exit code 0, grep returns nothing, a file exists and contains X — highlighted as the block that makes the ticket verifiable without you. Red lines: what not to touch, including 'if the world looks different from this ticket, stop and report.'

Here's a real one, sanitized — a retirement task, the kind where the danger is doing too much:

# Goal
Retire the legacy mirror hook: nothing in the active config should still call it.

# Steps
1. Remove the mirror hook from ~/.config/<tool>/hooks.json
2. Drop the launchd job that triggers it
3. Leave the four archived copies untouched

# Acceptance (machine-checkable)
- `jq empty ~/.config/<tool>/hooks.json` exits 0
- `grep -rl mirror-memory.sh <active-configs>/` returns nothing
- all four archive dirs still exist

# Red lines
- do NOT delete the archives
- do NOT edit the runtime copies directly
- if the topology looks different from this ticket, STOP and report

The block that does the real work is Acceptance. "Do a good job" is not checkable; jq empty … exits 0 and grep … returns nothing are. If the acceptance can't be expressed as something a shell can decide, the ticket isn't ready — and that's a signal about my thinking, not the executor's ability.

I have one test for whether a ticket is done being written. Could a fresh AI, with zero memory of this conversation, get it right on the first try from this ticket and the files I attached — nothing else? If not, I'm the one who under-specified it. I learned that the expensive way: I once sent a task as a narrow, terse work order — just the steps, no context about where it sat in the project or which environment traps to expect — and the executor hit an ssh/tmux/detach edge it couldn't reason its way through. It hung twice and bounced back to me five times. That wasn't a weak executor. That was a bad ticket. The quality of the dispatch is the orchestrator's job, not the executor's.

Dispatch is a background job, not a foreground chat

A ticket runs detached. From inside the agent harness I use its own background primitive — the harness wakes me when the run finishes and hands me the report. In a plain terminal the shape is the same:

codex exec -C <repo> --dangerously-bypass-approvals-and-sandbox \
  -c "mcp_servers={}" \
  "$(cat ~/tickets/<ticket>.md)" > ~/logs/<name>.log 2>&1 < /dev/null

That command line looks over-decorated. Every flag on it is a scar. Here's how I earned each one.

The scars: three flags I paid for in dead time

The dispatch lifecycle: write ticket, fire it detached, a watchdog checks liveness at ~5 minutes, the run reports, then a go/no-go gate. Three failure branches are labelled off the fire step: nohup & detaches from the harness so the report drifts uncollected; a missing mcp_servers= lets an MCP transport hang the process after task_started; a non-TTY stdin without < /dev/null blocks. The watchdog branch catches a hang the background primitive never reports.

nohup … & — no. When I dispatch from inside the harness, nohup detaches the process from it. The run finishes, but nothing wakes me: the report just sits in a log file, uncollected, while I assume it's still working. I did this three times before the rule stuck. Use the harness's own background mechanism so completion actually notifies you. (From a bare terminal with a human watching, nohup is fine — the rule is specifically about running inside something that's supposed to wake you.)

-c "mcp_servers={}" — always. An execution ticket needs a shell and nothing else. Loading MCP servers into it is pure downside: if a transport fails to start — an OAuth token that isn't there, an AuthRequired — the whole process can hang permanently, right after task_started. That was the root cause of the 2-hour-17-minute corpse. Pass an empty MCP config and this whole class of hang goes away — an exec ticket has no reason to pull in MCP servers in the first place. (It clears the servers you configured; it won't disable an MCP source some host or plugin injects, so it's a strong default, not an absolute guarantee.)

< /dev/null — always. codex exec with a non-TTY stdin that isn't wired to a pipe intermittently blocks waiting on input that will never come. Redirect stdin from /dev/null and it can't.

And the flag that isn't a flag: a watchdog. A hung run never exits, so the harness's "wake me when it's done" never fires — the one failure mode the background primitive can't catch is the one where nothing ever completes. So every ticket gets a ~5-minute log-freshness check running alongside it. The hang has a clean fingerprint: CPU near zero despite tens of minutes of elapsed time, a session log frozen at one or two events, a log file that stops growing. Silence is not progress. A background worker with no liveness signal is a bet, and the 2h17m run is what losing that bet costs.

A report is not a result — gate it before you trust it

The executor is both the worker and the narrator of its own work, which violates the oldest rule in the book: the producer shouldn't certify their own high-risk output. So a completion report doesn't close a ticket. It opens a gate:

  1. Spot-check one red line. The report says it didn't touch the archives — I ls them myself. Claimed ≠ true.
  2. Re-run the acceptance, line by line. Not the summary sentence "all done." The actual jq, the actual grep, against the actual state.
  3. Scan for conflicts with what I already know. If the report contradicts my notes, that's a high-value signal, not noise — maybe my notes are stale, maybe the report is confabulating. I find out before I decide, not after.
  4. Check one artifact for real. The file exists, the test actually ran, the change is live in the running system — not just green in the repo.

Only then does it resolve, and only into one of three verdicts: go, re-issue the ticket with the failure trace attached, or escalate to a human. This is the same instinct as Part 9's handoff rule — never inherit "it works" on someone's word; re-verify the last step before building on it. A backgrounded agent that hits its session limit mid-task and returns a report is a special hazard here: I've opened a "finished" module to find a function returning three values while its one caller still unpacked two — a ValueError waiting on any success path. The report said done. The acceptance check said otherwise. That gap is the whole reason the gate exists.

Takeaways

Where the time went. Not the tickets — those are quick once the format is muscle memory. It was the hangs: 2h17m on a single dead run before the watchdog existed, and a separate under-specified ticket that hung twice and bounced back five times. Both were costs of silence — a background worker that fails quietly burns wall-clock you never notice you're spending.

Reusable diagnostics. A liveness signal and a completion signal are different things — build the first explicitly, because the second lies by omission when a process hangs. Machine-checkable acceptance is the load-bearing part of any delegation: if you can't write the check, you don't yet understand the task well enough to hand it off. And a report is a claim; the acceptance re-run is the evidence.

The general principle. The commander doesn't step onto the field. Externalize the task into a file that can be verified without you, dispatch it, and gate the report — the same move as externalizing live state, one step earlier in the pipeline.

The dispatch checklist

  • Is it an engineering execution ticket with machine-checkable acceptance? If not, keep it.
  • Four blocks: goal, steps, acceptance (a shell can decide it), red lines.
  • Fresh-AI test: could a context-free agent do it right first try from this file alone?
  • Fire it detached, with -c "mcp_servers={}" and < /dev/null; never nohup & inside the harness.
  • Attach a ~5-minute liveness watchdog — a hang won't report itself.
  • Report received ≠ done: re-run the acceptance, spot-check a red line, then rule go / re-issue / escalate.

FAQ

How do you delegate a coding task to an AI executor like Codex?
Not as a conversation — as a self-contained ticket file. Four blocks: the goal (what 'done' means), the steps, machine-checkable acceptance (exit codes, grep-returns-nothing, a file exists and contains X), and red lines (what not to touch). If a fresh AI with none of your chat history couldn't do it right from the ticket alone, the ticket is underspecified — that's your job to fix, not the executor's.
Why does a backgrounded Codex run sometimes hang forever?
Two common causes. One: an MCP transport fails to start (a missing OAuth token, an AuthRequired) and hangs the whole process right after task_started — pass `-c "mcp_servers={}"` so an exec ticket loads no MCP at all. Two: a non-TTY stdin that isn't wired to a pipe blocks — always redirect `< /dev/null`. A hung run never exits, so it never notifies you; attach a ~5-minute log-freshness watchdog.
Is receiving a completion report from an AI agent the same as the task being done?
No. The producer shouldn't be the one who certifies its own high-risk work. Treat the report as a claim, not a result: re-run the ticket's acceptance checks yourself, spot-check one red line it claimed not to cross, and only then call it done. A report says 'I think it works'; the acceptance checks say whether it does.
When should work go to a background executor versus staying in the main session?
Route by a single question: is this an engineering execution ticket with machine-verifiable acceptance — env setup, a data pipeline, a batch edit, a build chore? If yes, it goes to the executor as a ticket. Judgment, taste, and one-off decisions stay with the orchestrator. A one-line config edit you can verify and roll back in seconds isn't worth a ticket — just do it and check it.

Read next

Don't miss the next one

Subscribe, and you won't.

One-click unsubscribe anytime.