Part 15 built the state machine a mission runs on. Part 16 covered how a unit gets checked before it’s trusted and how a worker’s shell is boxed in. Both describe one mission’s life from creation to done. This one is about everything around that: missions that fire themselves on a schedule, notifications that reach me only when something needs a decision, spend attributed to a specific mission, a way to give a stuck mission a stronger model without making that the default, and a regression test that runs the whole stack for real.
Why cron and not a background job scheduler
Timothy already had a periodic pattern: internal/platform/migrate.go runs schema migrations under a Postgres advisory lock so multiple instances can boot without racing. The mission scheduler in internal/brain/missions/scheduler.go reuses that idiom instead of reaching for a job-queue library. A ticker fires every minute, takes pg_try_advisory_xact_lock on a scheduler-specific key, and only the instance holding the lock does anything that tick: no lock, no work, silent skip.
A schedules row is a cron expression plus a MissionTemplate: goal, kind, agent, route, budget, iteration cap. When due, the scheduler inserts a fresh missions row from the template, tagged with the schedule’s id. It doesn’t provision a workspace or session yet; the mission is lazily provisioned the first time the driver touches it, same as any mission created from the UI.
Firing without duplicating
Three things have to hold for a cron-fired mission system to be trustworthy: a schedule fires exactly once per due boundary even with multiple brain instances running, a schedule still running its last mission doesn’t pile a second one on top of it, and downtime doesn’t create a burst of backfilled runs.
The advisory lock covers the first. The second is a live-queue check inside the same transaction: before inserting a new mission, fireOne counts missions with that schedule_id whose phase isn’t done or failed. If one is still active, the schedule skips firing but still advances last_run, so the next boundary computes from now rather than a stale anchor. Without that, a schedule stuck behind one slow mission would fire on every tick until the mission finished, then treat the whole gap as backlog.
The third is misfireGrace, one hour. dueDecision is the pure part of this logic: given a cron expression, the last anchor time, now, and the grace window, it returns fire, skip, or backfill-skip, with no I/O at all. If the brain was down for six hours, a schedule due three hours ago doesn’t fire three hours late; it’s past grace, so the tick advances last_run without creating a mission. At most one legitimately due run gets picked up on restart, never a queue of catch-up runs.
One more deliberate detail: a schedule’s template can leave route, review route, or budget empty, in which case the fired mission borrows the referenced agent’s current defaults, resolved at the moment the schedule fires, not when it was saved. Edit an agent’s prompt overlay next month and every schedule pointing at it picks up the change on its next fire, instead of staying frozen at whatever the agent looked like when the schedule was created.
Notifications that fire once, on the way in
The mission event log records everything, but I don’t want to be paged for everything. internal/brain/missions/notify.go only reacts to a transition landing on one of four states: waiting_for_input, paused, done, error. Everything else in a mission’s life is silent.
The stricter rule is that it fires on the transition into one of those states, not on being in that state: isActionableTransition takes the before and after status, and if they’re equal, nothing fires. Re-driving an already-paused mission (which the stale-working sweep does, more below) reports the same before and after and stays quiet, so anything that re-touches a parked mission doesn’t re-notify me for something I already know about.
Delivery is two-tier. A notifications row is always written, durably, inside the same insert that dedupes against an existing unread row of the same kind for that mission (a NOT EXISTS subquery inside the insert, not a separate pre-check, so two concurrent transitions can’t both slip past a check-then-insert race). Fan-out to an external webhook is best-effort on top: if NOTIFY_WEBHOOK_URL is set, a generic JSON payload posts out for ntfy- or Slack-style receivers, and a failed POST only logs. The durable row exists either way.
Why SSE instead of polling
Before this, the mission board and detail pages polled every five seconds, dropping under two seconds while a permission was pending: a lot of empty requests for a system where most ticks change nothing. The fix is a small in-process hub: ApplyTransition, AppendEvent, and the notification insert each publish a {kind, id} signal, and GET /v1/events relays those over SSE. The web client refetches the real REST endpoints when a signal arrives instead of on a timer. The signal carries no state, just a hint that something changed and which id to re-fetch, so it can’t become a second source of truth that drifts from the database.
Spend, attributed honestly
Every mission turn had been writing mission_id into cost_ledger since the mission engine’s first migration, but nothing ever summed it. Aggregator.Mission sums cost, tokens, and request count filtered to one mission id, plus a breakdown of which provider/model pairs actually served the mission’s turns (a route is a fallback chain, not one model, so “which model ran this” can legitimately be more than one answer if the chain fell over mid-mission).
The more interesting fix in the same change was about what happens when a price is unknown. The never-guess-cost rule from the gateway (Part 3) means a row with no configured price gets cost_usd = NULL, not zero. Every dashboard aggregate before this had been COALESCEing those NULLs to zero, quietly hiding real usage behind a number that looked like “no cost” instead of “unknown cost.” The fix doesn’t hide the gap: MissionUsage carries an explicit unpriced_requests count alongside the summed cost, and the UI shows unpriced calls with an advisory estimate pulled from the model catalog, always labeled as an estimate, never folded into recorded spend. Same principle as the ledger itself: an unknown price stays visibly unknown.
The same change also closed a smaller leak: every mission gets a hidden bookkeeping session that the session list was supposed to filter out, per a comment never backed by an actual join filter, so it showed up as an untitled row in the ordinary chat session list. One join clause fixed it.
An escalation route, opt-in only
A mission pinned to a small, cheap model can burn its whole iteration budget on a unit that model is too weak for. escalation_route, when set, switches worker turns to a stronger route the moment there’s evidence the current one isn’t working: a worker failure or a review rework already recorded against this mission. workerRoute is a small pure function: escalation route set and either ConsecutiveFailures or StallCount above zero, use it; otherwise the mission’s normal route.
It’s opt-in, deliberately: escalating silently would mean a mission’s cost profile could change without anyone choosing that. Leaving the field empty, the default for every mission that predates this, means the ladder is off entirely and the mission’s own route wins forever, however many times it fails. When it does fire, the escalated route rides along on the turn event, so the ladder is auditable after the fact rather than something inferred from cost alone.
The same change tightened the gateway’s own retry behavior: a failing provider used to get the full retry budget even when the next chain entry could serve the request immediately. Now only the final chain entry gets the full budget; every earlier entry gets one quick retry before failing over. The chain is the retry mechanism, and backing off against a rate-limited provider when the next one in line is healthy right now is wasted latency.
The canary: a golden mission, run for real
Unit tests cover the state machine’s transitions in isolation, but the harness the missions engine depends on (worker sandboxing, artifact verification, the review path) only really gets exercised by running an actual mission against the running stack. make canary (scripts/canary-mission.sh) does that: creates a research mission with a real goal, polls to completion, asserts on the event log afterward.
The goal isn’t fixed. The script draws from a pool of ten short HTTP/networking topics (“explain the Retry-After header,” “compare HTTP HEAD and GET”) at random each run. A fixed goal would let provider prompt caches, plain model memorization, or leftover artifacts from a prior run quietly flatter a broken harness into looking like it passed. A different goal every time means a pass only means the harness worked on something it hasn’t seen before.
The pass conditions are specific, not just “the mission reached done”: zero permission parks (a canary has to run fully unattended), at least one mission.unit_verified event with passed = true (proof the harness actually checked something, not just that the model claimed success), and a turn count under a small ceiling, since a trivial mission taking more than a handful of turns means the loop isn’t right-sized. A second script, canary-coding.sh, runs the coding path against a seeded fixture git repository, with its own checks: a worktree and branch exist, review genuinely ran rather than being skipped (coding missions are never allowed to skip review), and the declared artifact is present on disk, checked independently of the harness’s own verification.
Cleanup only runs after every check passes; a failing mission is left in place on purpose, because that’s exactly the run I want to inspect. Before this existed, canary runs quietly left mission rows, hidden sessions, and workspace directories behind after every pass, piling up until a manual purge was needed. DELETE /v1/missions/{id} now removes a terminal mission’s whole aggregate: the row (events and notifications cascade), its hidden session, its workspace directory. It refuses with a 409 on anything not terminal, since deleting a live mission’s data mid-run is not the same operation as deleting a finished one. The web got the same button later, next to cancel on a mission’s detail page, gated to appear only once a mission reaches done or failed, behind a confirm dialog.
The sweep that catches a dead process
A mission can be mid-turn when the brain process dies. At boot, RecoverAndSweep re-drives every mission the store finds still marked working, which only happens if the previous process crashed mid-Advance without reaching a terminal or waiting state. That path existed already.
The newer piece is a periodic check for a subtler failure: a mission whose Drive loop stopped advancing without the process crashing at all. Every thirty seconds, alongside the sandbox-orphan sweep and the idle-over-cap work-slot retry, the sweep asks the store for any mission working for longer than fifteen minutes, well above the slowest single-turn duration ever observed against a remote model, and re-drives it. It’s safe to call even on a mission genuinely still mid-turn: the driver’s own claim-driving guard makes a second concurrent Drive on the same mission id a no-op, so this can only rescue a loop that has actually stopped.
The UI bug that made a pass look like a failure
One fix corrected something the UI had been saying wrong. The review-approval transition emits a mission.unit_verified event, but its payload was empty, carrying no unit and no passed field. The web timeline’s renderer defaulted both to their fallback values when absent, which for passed meant false. So every mission that passed review rendered “Unit ? verification: failed” in its own timeline, on the one transition that only fires because the unit was approved. The fix emits passed: true explicitly, and the renderer now shows a plain “Unit verification” label, no bogus index, when the payload carries no unit number. A one-line state machine change and a one-line renderer change, each guarded by a test, for a bug that had been quietly telling me the opposite of the truth.
What’s still manual
The escalation ladder has exactly one rung: on or off, one stronger route, not a multi-step chain of increasingly strong models the way the gateway’s own provider chains work. That’s a deliberate follow-up, not something the current code pretends to already do.
Every schedule fires the same template every time; there’s no way yet to vary the goal per run the way a human running a recurring task naturally would. The briefing-style recurring mission works by putting the topic in the goal text itself and re-firing that same text on cron: honest, but not adaptive.
Costs are honest about what’s unpriced, but the estimate shown is exactly that: pulled from the model catalog, not a measurement. Add a provider without a price row and spend on it shows as an advisory number, not one I can reconcile against a bill.
Next: some conversations never leave the house, pinning sensitive sessions to local models only.