Every part so far has been about a chat turn: a request comes in, the model streams a reply, the turn ends. That shape works for a conversation. It does not work for “migrate this schema” or “research these five vendors and write me a comparison.” Those take hours, span dozens of tool calls, and need to survive the brain restarting in the middle.
Missions are Timothy’s answer: long-running agent work driven by a deterministic state machine, not by the model’s own sense of when it’s done.
Why chat turns are the wrong shape for this
A chat session is one continuous conversation. Context grows every turn, compaction keeps it bounded (see Part 6), and the loop that lets the model call tools (see Part 8) runs until the model stops asking for tools.
That loop has a bounded step ceiling for a reason: unattended tool use needs a hard stop. A multi-hour build task needs the opposite property: it needs to keep going across many separate turns, potentially many separate model sessions, potentially across a restart of the brain process itself. Bolting “keep going for hours” onto a chat session means either an unbounded context (breaking prompt caching, eventually blowing the context window) or an ever-growing transcript the model has to re-read every turn just to remember what it already tried.
The other problem is trust. A chat turn ends when the model stops calling tools, and for a five-minute conversation that’s fine: if the model says “done,” the user is right there to check. For an unattended, multi-hour task there’s nobody watching, so “the model said it’s done” cannot be the definition of done. Something outside the model has to decide.
Missions solve both problems by moving the loop, the memory, and the definition of “done” out of the model and into ordinary Go code.
The pipeline: a phase, not a conversation
A mission has a fixed pipeline: research → plan → execute → review, ending in done or failed. Phase is one dimension; status is the other (idle, working, waiting_for_input, paused, done, error), so a mission’s overall situation is always the pair, never a single flag:
// internal/brain/missions/statemachine.go
const (
PhaseResearch Phase = "research"
PhasePlan Phase = "plan"
PhaseExecute Phase = "execute"
PhaseReview Phase = "review"
PhaseDone Phase = "done"
PhaseFailed Phase = "failed"
)
The plan phase produces a spec: an ordered list of units, each with a title, the artifact paths it must produce, and a verify command. Execute then works through units one at a time. Review checks a unit before the mission moves to the next one or finishes.
Pure Step(), one writer
Why is the transition logic a pure function instead of a method that touches the database?
Step(state, input, config) → Transition takes a StepState (phase, status, iteration counters, budget, stall count) and a StepInput (what happened: a worker retried, a reviewer approved, a budget was hit) and returns the next state plus a list of events to record. No I/O, no side effects, no randomness.
func Step(s StepState, in StepInput, cfg Config) Transition {
if in.Input == InputCancel { ... }
if s.BudgetUSD != nil && s.SpentUSD >= *s.BudgetUSD && !s.Phase.Terminal() {
return Transition{Next: withPause(s, PauseBudget), ...}
}
switch in.Input {
case InputPhaseComplete: return stepPhaseComplete(s)
case InputWorkerFailed: return stepWorkerFailed(s, in, cfg)
case InputReviewApprove: return stepReviewApprove(s)
case InputReviewRework: return stepReviewRework(s, in, cfg)
// ...
}
}
That purity is what makes the entire transition table exhaustively unit-testable without a database, a model, or a clock. Every brake (backoff, stall, budget), every phase advance, every pause reason is a table test against Step directly.
The only thing allowed to write mission state after creation is Store.ApplyTransition: it updates the mission row and appends every event Step decided on, in one transaction. The driver never issues a bare UPDATE. This mirrors the same discipline sessions already use (Part 6): one function decides what happened, one function is allowed to make it durable.
Append-only events, same ethic as sessions
mission_events(mission_id, seq, kind, payload, provenance, fingerprint) is append-only, exactly like session_events. Sequence numbers are assigned under a row lock so they stay gapless per mission. Nothing is ever updated or deleted while a mission is live.
The reason is the same reason sessions are event-sourced: the mission’s timeline in the UI is not a separate thing that has to stay in sync with the mission’s state, it’s a pure replay of the same events that drove the state machine. A mission with an unroutable category, driven live, produces created → provisioned → worker_finished/retry (x3) → paused(backoff) → failed(cancelled), and every one of those words is readable straight back out of GET /v1/missions/{id}/events with no separate audit log to keep honest.
Terminal events (mission.done, mission.failed) carry a SHA-256 fingerprint of their kind and payload. If two terminal outcomes ever get proposed for the same mission (a worker reports failure a moment after a done was already persisted, a genuine race under crash recovery), the second one doesn’t get to end the mission twice: it either matches the recorded fingerprint (a harmless duplicate, no-op) or it doesn’t, in which case a mission.reconciled event names the canonical first outcome and the contradictory one is logged, not acted on.
Fresh workers, no transcript replay
Why does each worker turn start a brand-new session instead of continuing one long conversation?
Because the mission’s memory doesn’t live in a conversation, it lives in the database. Each phase runs one or more fresh worker sessions, and every fresh session is seeded from a WorkPacket: the goal, the current plan with each unit’s pass/fail state, the progress log so far, and (for coding missions) a capped git log of what’s already been committed in the worktree.
// internal/brain/missions/packet.go
type WorkPacket struct {
Goal, Kind string
Spec Spec
Progress []ProgressNote
GitLog string
Iteration int
PromptOverlay string
}
This is the same shape as the resume story in Part 6: recovery comes from artifacts, not from replaying every message a session ever produced. If the brain process is killed mid-execute and restarted, the mission resumes at whatever phase it was in, reading the same persisted spec and progress log any fresh worker would read anyway. There’s no scrollback to lose. A test proves this directly: it swaps to a brand-new, empty Driver instance mid-mission, between plan and execute, and the mission finishes correctly from the stored state alone.
The packet also carries an important honesty detail: ExecEnvironmentNote, which tells the worker what its shell commands are actually running against (a sandboxed container versus the brain’s own minimal process), so a worker doesn’t confidently report “done” on a step whose verify_cmd fails later for want of a runtime that was never there in the first place.
The sentinel protocol: catching a worker that wants to quit
A worker’s turn has to end somehow, and the harness needs to know why it stopped. Every worker turn is required to end with exactly one mission_status tool call: done (with evidence), retry (with an analysis of what went wrong), or blocked (with a specific question).
The critical framing, right there in the tool’s own description: “DONE… is a request for verification, not a claim that the work is accepted.” Nothing the model says flips a unit to passed. It only asks the harness to check.
Models don’t always cooperate. Left alone, a model that’s spent its patience on a hard problem tends to write something like “This should work. Ready for review.” and simply stop calling tools, no sentinel, nothing for the harness to act on. The enforcement ladder for that case:
- One forced re-run of the turn with an explicit prompt to end with
mission_status. - If that still produces nothing, a scan of the final paragraph against a fixed panel of bail-pattern regexes: “ready for review,” “I’ll check back later,” “unable to proceed,” “I believe this is complete,” and six more, each pinned to a source location by its own regression test so nobody can quietly delete one during a refactor.
Either way the outcome is the same: treated as retry. The pattern match doesn’t decide whether to trust the turn (nothing does that except the harness’s own checks); it only affects what gets logged as the reason.
Verification the model cannot fake
A unit’s passes flag can only flip because the harness itself checked something, never because a model said so. When a worker calls mission_status{done}, the driver checks that every artifact path the plan declared actually exists on disk, non-empty and inside the workspace, then runs the unit’s verify_cmd and records the exit code and an output digest as a mission.unit_verified event. Everything downstream, including review, sits behind that gate. How the gate is built, why the checks run in that order, and what stops a planner from writing a verify command that lies are the subject of the next post; here it is enough that the state machine treats “the model said done” as a request for verification, never as evidence.
Four brakes, each independent
Any one of these pauses or fails a mission, and each is checked without reference to the others:
max_iterations(default 8): the mission has tried enough times in a phase; fails outright.- Backoff: three consecutive worker failures pause the mission as
paused/backoff, well short of the iteration cap, on the theory that three failures in a row is a different problem than “still making slow progress.” - Stall: reviewer rejections get normalized into a gap fingerprint. Two consecutive identical fingerprints (the reviewer rejecting for the exact same reason twice in a row) pause the mission as
paused/no_progress, because grinding through the rest of the iteration budget on a fix that isn’t landing is wasted spend. - Budget: mission spend, summed from
cost_ledgerrows keyed by mission id, checked againstbudget_usdbefore any input-specific handling runs at all.
All four pause states are human-resumable, not dead ends. Resume clears the pause reason and hands the mission back to the idle work-slot queue, preserving the iteration and failure counters, because resuming is not restarting.
Corrupt state loads as paused, never crashes
One rule that’s easy to skip and expensive to skip wrong: a mission row whose phase or status column holds a value the current binary doesn’t recognize, a stale row from a rolled-back migration, hand-edited during an incident, forward-written by a newer version, degrades to paused on load, with the bad value named in the pause message. It never becomes an unreadable row, and it never resurrects as a self-driving mission under some default the loader guessed at. A row that says status = 'self_driving_v9' (an invented value a test injects directly into Postgres to prove this) comes back out of Get as paused/infra, quoting the unrecognized value verbatim.
That asymmetry is deliberate: failing to load a mission is annoying, an operator has to look at the row manually. Silently resuming a mission from an unrecognized state is worse, because it means running code against assumptions nobody actually verified hold for that row.
Per-mission git worktrees
Coding missions get a real, isolated git workspace: git worktree add -b mission/<slug> outside the main repo directory, with base_commit captured at creation under a tight one-second timeout that degrades to a sentinel (unavailable) value rather than ever blocking mission creation on a slow or hung git process. Every verified unit gets its own commit on the mission branch; a retry verdict rolls the worktree back to clean state (git checkout -- . plus git clean -fd) before the next attempt, so a half-finished failed attempt never bleeds into the next worker’s fresh session.
Two missions can never collide on the same repo and branch: provisioning takes a row lock across every currently-active mission and refuses at creation time if another mission already holds the same workspace and branch pair.
What this milestone does not include yet
This post is the engine. It does not cover what makes a worker’s claimed work actually trustworthy beyond the harness’s own artifact and verify_cmd checks, that’s an antagonistic reviewer session judging the diff against the goal, on a different model than the one that did the work, which is genuinely a separate design problem. It also doesn’t cover the cron scheduler that fires missions on a timer, or the notification channels that tell me when one needs my attention; those are their own layer on top of the engine described here.
Next up: trusting the work, harness-owned verification, an antagonistic reviewer, and a sandbox.