Upgrading the engine

An engine upgrade is an agent’s job, never a person’s: your operator’s upgrader when an owner asks it to (Your operator upgrades itself), or a coding agent working in the brain repo, on the strongest model available. Either way it has two halves:

  • omnislash upgrade does what is the same for every brain: it moves the pin, installs and regenerates the files the engine owns. Then it writes upgrade/TASK.md.
  • The agent follows upgrade/TASK.md to the end. The task holds what the crossed releases need done in this brain that no command can do: renamed names your files spell, checks against your production database, data fixes, behaviour changes to tell the owners about.

Nothing upgrades a brain from CI. The engine block that omnislash regen keeps in your CLAUDE.md (and AGENTS.md) tells an agent asked to upgrade the engine how (upgrade_start in the running deployment, omnislash upgrade anywhere else), and every other agent (your production agents load that file too) to leave upgrade/ alone.

What omnislash upgrade does

omnislash upgrade                  # latest release
omnislash upgrade 1.22.0           # an exact version
omnislash upgrade --from 1.20.0    # plan the agent task from 1.20.0 instead of the current pin (merged into an open task)
  1. Refuses a brain whose @omnislash-ai/* packages sit outside peerDependencies (contract.md § 1).
  2. Resolves the target version and records from, the version the brain is on: --from, else the current exact pin, else the engine installed in node_modules.
  3. Rewrites every engine peer pin to the target and runs pnpm install. First it drops from pnpm-lock.yaml each third-party version locked outside a peer range of the target engine packages, optional peers included (an auto-installed bullmq below a raised floor, or the 4.5.4 your own "zod": "^4.5.4" locked): pnpm keeps a locked version even when it fails strict-peer-dependencies, and no pnpm flag re-resolves it. Each dropped entry is printed. A spec of your own in package.json that admits a version outside such a range ("zod": "4.6.5" against ^4.6.7) is raised in the same write as the engine pins, to the lowest version inside every range, keeping its shape: an exact pin stays exact (4.6.7), ~ stays ~, anything else becomes ^. Each raise is printed. A spec is never lowered: one that allows versions above the ranges (^5.0.0 against ^4.0.0, a prerelease), or that no single spec fits, stops the command before it writes anything, naming the package and its ranges. A spec it cannot compare (an npm: alias, catalog:, a dist-tag, a pnpm.overrides entry) is printed as a warning before the install. This runs in the CLI the brain has installed, so it first helps an upgrade from the release that ships it.
  4. Runs the new CLI’s omnislash upgrade-plan --from <from> --to <target>. Only the new CLI ships the upgrade entries of the releases you are crossing. It copies each entry of the range from < version <= target into upgrade/ and writes upgrade/TASK.md, then prints the task. A range with no entries writes nothing and prints no agent steps. It reads no brain code, so a brain the new engine cannot load yet still gets the entries that explain the fix.
  5. Runs the new CLI’s omnislash regen: Dockerfile, .dockerignore, .github/workflows/{deploy,ci}.yml, docs/engine/, docker/*.yml.example, skills/engine/, constitution/engine-capabilities.json and the engine block of CLAUDE.md / AGENTS.md (brain-dev-guide.md § Updating Engine has the detail and the opt-outs). Regen loads omnislash.config.ts; when that fails with a task written, the error points at upgrade/TASK.md: apply its entries, then run omnislash regen.
  6. Offers to commit the bump (interactive terminals only). With a task written it commits without pushing, because a push deploys and the task’s first steps come before the deploy. A non-interactive run (an agent) never touches git: step 1 of the task commits.

The last line printed is the next step: follow upgrade/TASK.md, or run omnislash doctor when there is no task.

Any failure exits non-zero with the reason and a hint. The command is idempotent: fix the cause and re-run the hinted command (it carries --from, so the plan covers the same range). An install that still fails with ERR_PNPM_PEER_DEP_ISSUES means a version the upgrade did not raise sits outside a peer range of the new engine: a spec it warned about, an override in pnpm-workspace.yaml, or a third-party dependency of the brain whose own range excludes it. pnpm names it (unmet peer <name>@<range>: found <version>). Set the spec or override inside the range by hand, or upgrade or drop the dependency, then re-run.

An upgrade started by an older CLI. A CLI from before agent tasks never runs upgrade-plan. The new CLI’s regen covers that case: when the package.json in your last commit pins an older engine than the installed one and no task exists yet, it writes the task for that range before it loads the brain’s config, and prints it. So do not commit between the install and the regen. If you did, run omnislash upgrade --from <the old version>. That older CLI then says to commit and push: commit, but push only at the task’s deploy step. Until then the regenerated workflows’ Upgrade task gate fails the deploy (below).

The task: upgrade/

upgrade/
  TASK.md                                  # the protocol + the ordered index
  1.20.0/01-f2-ids-precheck.md             # one file per entry: <version>/<NN>-<id>.md
  1.20.0/02-record-agent-model-overrides.md
  1.21.0/02-access-yaml.md
  model-overrides.md                       # a working file an entry asked for (optional)

upgrade/ sits at the brain root and is committed. It is the upgrade’s only record: there is no ledger of applied steps. The pinned version says where the brain is, and the folder is the to-do list. The final commit deletes the folder.

TASK.md has a Target: v<version> line (the engine version the upgrade moves to), the static protocol, then the index grouped by phase, in release order (version, then NN). Re-planning into a task in progress keeps its entries and sets Target to the version that re-plan installs, a lower one included (omnislash upgrade --to <older>): Target is what the deploy must land.

Target: v1.21.0

### Before deploy

- [ ] 1.20.0/01-f2-ids-precheck.md — Agent and conversation ids become text; … (blocker)

### In repo

- [x] 1.21.0/02-access-yaml.md — chats.yaml is now access.yaml (blocker)
- [x] 1.21.0/07-no-dashboard-daily-brief.md — … (cleanup) — n/a: no dashboard.dailyBrief key

The agent ticks a line by changing - [ ] to - [x], and appends — n/a: <why> when Detect says the entry does not apply. The file name stays first on the line. Entry files are never edited.

The protocol

  1. Commit the bump: everything omnislash upgrade left, upgrade/ included, as chore: update engine to v<version>. Do not push.
  2. Before deploy: each entry’s Detect and Verify, read-only against production (db tools, doctor_check). A failed blocker stops the upgrade: report it to the owners and go no further.
  3. In repo, in order: Detect → Apply → Verify, one commit per entry (upgrade(<version>): <summary>), its tick included.
  4. Gates: omnislash doctor, pnpm type-check, the brain’s build.
  5. Deploy: push to the branch the deploy workflow ships. Its first gate, Upgrade task, fails while a before-deploy or in-repo entry is unticked. A failed deploy is reported to the owners with its log (see When the deploy fails).
  6. After deploy: confirm the deploy landed (upgrade_status reports the running engine as the task’s Target), then Detect → Apply → Verify against production, ticks committed.
  7. Tell the owners: one message with every entry’s “Tell the owner” section and every skipped entry with its reason.
  8. Finish: delete upgrade/ in a final commit (upgrade: done) and push.

Work on a separate branch, in a separate worktree when the environment offers one, so that nothing deploys before step 5.

An entry that leaves a value to the brain (a duration, a threshold, a model, a default) states the engine default and why it is that number. The agent keeps the default unless the brain’s own files or data give a reason not to, and says which in the entry’s commit. It does not ask the owners for it, even where an older entry says “ask the owner” about a number; they hear what changed in step 7. An entry’s ask about anything else stands: a production write it guards, widening access, credentials, an external resource to create.

Resume

  • omnislash upgrade on a brain that is already on the target and still has upgrade/ prints the unfinished task again. Resume at the first unticked entry.
  • omnislash doctor warns Upgrade in progress while upgrade/ exists and names the next unticked entry. omnislash doctor --check upgrade-in-progress passes once the folder is gone.
  • omnislash upgrade --from <version> plans a range again. So does upgrading further while a task is in progress. The new entries are merged into the task: entries already listed stay with their ticks and notes, and everything is re-sorted into release order. A merge only adds entries: --from cannot narrow an open task.
  • Detect is written to find work already done, so running an entry a second time is safe.

Working files

An entry may ask the agent to keep a file in upgrade/. Usually it is a note that a before-deploy step hands to an after-deploy step, such as the agent models set by hand that the deploy is about to reset. Re-planning never deletes these files. They are committed like a tick and go away with the folder in step 8.

Anatomy of an entry

---
summary: chats.yaml is now access.yaml   # one line, shown in the index
phase: in-repo                           # before-deploy | in-repo | after-deploy
severity: blocker                        # blocker | required | cleanup
sql: []                                  # engine migrations this entry covers
---
## Detect
## Apply
## Verify
## Tell the owner                        (optional)
FieldMeaning
phase: before-deployRead-only checks against production before anything ships: views or keys a migration would trip on, table sizes that decide the deploy window.
phase: in-repoEdits to the brain, committed with the bump.
phase: after-deployProduction data fixes through db tools, runtime checks (doctor_check), owner notices.
severity: blockerWithout it the brain does not boot or the deploy fails.
severity: requiredWithout it, behaviour breaks.
severity: cleanupNothing breaks if it waits.
sqlThe engine migrations (_journal.json tags) this entry is about. The deploy’s db-migrate applies them; the entry says what they need from your brain, often nothing (“not applicable”).
  • Detect: how to tell whether the entry applies to this brain. It can be a grep, a file check, a read-only SQL query or a doctor check. “Not applicable” means skip it and say why.
  • Apply: the steps. Code, SQL and file moves in it are material to adapt to this brain, not a script to run blind.
  • Verify: a check that must pass. Preferably a doctor check by id (omnislash doctor --check <id> locally, doctor_check at runtime), else a command or a query.
  • Tell the owner: a behaviour change the owners should hear about. The agent relays it in step 7.

A released entry never changes. A correction ships as a new entry in a later release.

Which engine you run

Ask the operator: upgrade_status answers, on request, from the running deployment:

  • running: the engine version the deployment runs;
  • brain.pinned: the version package.json pins on your default branch. It differs from running while a pushed upgrade has not deployed yet;
  • registry: the newest published version and every release between yours and it, or why the registry could not be read;
  • brain.task: upgrade/TASK.md on your default branch, when an upgrade is in progress: its target and each entry’s phase and tick.

It reads the pin and the task from the default branch, not from the deployed image: tick commits touch only .md files and do not deploy. What a release you have not installed asks of your brain (its entries and blockers) is not known until omnislash upgrade installs the CLI that ships them.

The registry is read with NODE_AUTH_TOKEN. Under the private engine registry, the generated deploy.yml passes your OMNISLASH_PAT secret to the deploy, which writes it into the stack’s .env as NODE_AUTH_TOKEN; there is nothing to set up. An OMNISLASH_PAT that expired shows here as an unreadable registry (HTTP 401): rotate the secret (brain-dev-guide.md § Token Renewal), and the next deploy carries the new one.

Your operator upgrades itself

Ask it in chat: “upgrade the engine” (or “to 1.28.0”). The agent you are talking to calls upgrade_start, and the operator’s upgrader (system:upgrader) carries the upgrade out. It never starts on its own: nothing polls the registry and nothing proposes an upgrade you did not ask for.

  1. upgrade_start checks it can: the deployment has your brain repository to clone, it can read the engine registry (NODE_AUTH_TOKEN, see Which engine you run), and the target is a published release newer than the one running. It is refused while an upgrader is already working, a start is already queued, or a merged upgrade still waits on its deploy. The default target is the newest release.
  2. In its own clone of your repository, on the branch upgrade/engine, the upgrader runs omnislash upgrade <target> and works upgrade/TASK.md as above: the before-deploy checks against production, the in-repo entries one commit each, the gates. It runs every SQL write first inside BEGIN … ROLLBACK to see the row counts.
  3. It opens a pull request and asks the owners one question: merge it now? The card carries the PR, the entries, each blocker’s verdict and the expected downtime. That card is the only decision you make, and only a person makes it: no agent can answer or close a card the upgrader asks. Not now leaves the PR open.
  4. On yes it merges the PR (rebase). The push to your default branch deploys, as every push does; the upgrader never starts a deploy itself. The deploy stops the deployment for a few minutes, the upgrader included; its turn continues once the engine is back.
  5. It confirms the deploy landed (upgrade_status reports the target running — a deploy whose last step, the proof turn, failed is a failed deploy), runs doctor_check, does the after-deploy entries (each write guarded, so running it twice changes nothing), sends the owners one message with every entry’s “Tell the owner” note, and deletes upgrade/ in a final commit. After the merge only .md changes (the ticks) go straight to your default branch; a fix that touches code comes to you as a pull request with the failure question below.

upgrade_status shows where it is: upgrader is the upgrader’s state and the report of its last turn (the target, the stage it stopped at, the PR, a summary).

A restart does not lose the upgrade. The upgrader’s turn continues after its own deploy’s restart, like any turn a restart cuts off. If that turn ends anyway (its continuations spent), the engine picks the upgrade up at its next boot: when the running engine is the task’s Target, an after-deploy entry is still unticked, no upgrader is working and you did not hand the upgrade to your own coding agent (no Handed off: line in TASK.md), a fresh upgrader resumes at that entry a few minutes after the boot — counted as one of the restarts below when the engine ended the last upgrader, and not at all once the owners were told it stopped restarting. Asking for the upgrade again in that state (or after a hand-off) picks it up too, instead of being told the engine already runs the newest release.

If the new engine cannot continue the upgrader’s turn at all — the release changed how a turn in flight is recorded, or the turn keeps failing on something nothing classifies — the engine ends that turn itself (failed, the reason on the row; within minutes for a recording change, after about an hour of retries otherwise) and, every few minutes, starts a fresh upgrader at the first unticked after-deploy entry. It does that only for an upgrader the engine ended (failed or interrupted), never for one that stopped by its own rules (a report, a question that expired unanswered) or that someone stopped (a stop ends it cancelled, even one that had to kill the turn), and at most upgrade.upgraderRestarts times per upgrade (default 2; set it in constitution/MANIFEST.yaml; 0 = never restart, tell the owners at once). When those are spent it tells the owners once, in their DM, and stops trying — a later restart of the engine (a deploy, a crash) starts no upgrader either: ask for the upgrade again (which resets the count and tells the owners it is back on — retried until that message is queued), or hand upgrade/TASK.md to a coding agent. upgrade_status shows it all: upgrader.endedByEngine, upgrader.error, and recovery (restarts made, the limit, when the owners were told).

When a step fails, it fixes forward. Migrations only go forward, so the upgrader rolls nothing back and never reverts the pin. The one rollback is the deploy’s own: a migration that fails restores the database to the dump taken right before it and starts the previous version again (When the deploy fails), and the pin stays for the fixed deploy. The upgrader retries a failing step a few times itself; then it asks the owners one question with the entry, the error and what is degraded meanwhile: retry, skip and accept, or hand it to a local coding agent, who resumes the same upgrade/TASK.md (on upgrade/engine, or on your default branch once merged). On a hand-off the upgrader writes a Handed off: <when> — <why> line under Target in TASK.md, pushes it and stops: while that line stands the operator leaves the upgrade to your agent (upgrade_status shows it as handedOff), and asking the operator for the upgrade again takes it back — the upgrader deletes the line and carries on. The operator keeps running throughout; only the deploy stops it. A deploy that fails is reported with its log, and the after-deploy entries wait (When the deploy fails).

The upgrader’s model and rules are its role file, constitution/Agents/system/role-upgrader.md; override it like any engine role. upgrade:execute starts without a card (the owner’s ask is the start); set hitl: true on it in constitution/engine-capabilities.json to have a card before every start. Nothing else checks that a person asked: any agent holding upgrade:execute can start an upgrade, and the merge card — a person’s alone — is what keeps it from shipping.

The deploy is downtime

Every deploy stops the world for a few minutes: the drain, the migrations, one boot. Nothing of the previous version runs while the new one migrates and starts, which is why a release can ship its final shape with no compatibility layer. Right before the migrations the deploy dumps the database (the stack’s own Postgres, with its own pg_dump), so a migration that fails can be undone; the dump adds its own seconds to the downtime, more on a large database. Pick the moment accordingly: a before-deploy entry may report table sizes for that reason. The full sequence is in operations.md § Brain CI/CD.

The deploy ends with a proof turn: once everything is up, it runs one real agent turn through the whole agent path (dispatch, the agent runtime, the CLI harness, the model) and counts as successful only when that turn answers. Healthy containers do not prove an agent can answer; a release or a rebuilt image whose agent CLIs no longer start would otherwise deploy green with every agent mute. The turn is a throwaway agent of the engine role system:prover — no tools, no memory, no chat, deleted with its turn — on the role file’s model, by default your CLI smart chain at low effort (one short turn per deploy; your agents run on the CLI harness, so that is the path it proves). Re-aim it by overriding constitution/Agents/system/role-prover.md; bound or switch it off with defineProject({ deploy: { proofTurnTimeoutSec } }) (default 180 seconds, 10 to 3600, 0 = no proof turn). The turn is admitted over the agent concurrency cap, so turns continued after the restart never hold it back. The deploy log says so out loud when there is nothing to prove with: the proof switched off, no pipelines service (no agent runtime) or no mcp service (the check is asked through it) is a WARNING line; no agent CLI ever signed in on the host (no credential file and no sign-in variable such as ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN), a prover model your models.json does not declare, a money budget that refused the turn, or agent_check switched off in constitution/engine-tools.json is agent check skipped. An agent asks the same question with agent_check.

What survives a deploy:

  • Kept running: Postgres, Redis, Restate, Caddy (it holds requests for up to 30 s per restart) and the monitoring stack. Alerts are silenced for the deploy window.
  • Queued jobs: the worker finishes the jobs it is running (up to 60 s). Jobs still waiting stay queued for the new version.
  • Agent turns: not killed. A turn paused on a question to a person keeps waiting and resumes on the new code; a turn the restart cut off is continued when the engine boots, up to sessions.continueAfterRestart times (default 3).
  • Your pipeline runs: not killed.
  • The engine’s other in-flight work (workflow runs, planning, loop cycles and the like): killed, then re-derived from its database rows when the engine boots. One exception: a planning clarification still waiting for an answer at the deploy is lost.

When the deploy fails

The deploy log says how far it got.

  • In the migration pre-flight (the first step, before anything stops). Nothing was stopped or applied: production still runs the previous version (on a first deploy, nothing ran before and nothing runs). The message says which of two things happened:
    • “this build cannot migrate the database” — a migration already applied there was edited or is missing from the build, a pending one sits below the newest applied one, or a database older than migrations does not match the schema. Every deploy stops here until the cause is fixed; the message carries the runner’s reason. Do not revert the pin. An edited or missing migration of your own (the brain: line): restore it byte-for-byte from git, and ship the change as a new migration. An engine one (the engine: line): nothing in the brain fixes it — the engine release itself is broken and must be fixed by a later engine release; tell the owners the deploy is blocked on that release, and upgrade to it the same way once it is out.
    • “the pre-flight could not check the database” — the check never got to ask: the stack’s Postgres did not come up, the database was unreachable (DATABASE_URL, its credentials, the network), or the new image could not start the check (the message or the deploy log above it names which). Nothing says this build is wrong: fix that cause and deploy again.
  • Before the migrations: the previous version was started again. Production is unchanged and still on the old engine. Fix the cause in the repo and push again. The after-deploy entries wait.
  • In the migrations, before changing the database (the message says so — the first pending migration failed and rolled back, or the database could not be reached): the previous version was started again (on a first deploy there is none, and nothing runs), nothing restored because nothing changed. Production is unchanged and still on the old engine. Read the migration’s error in the deploy log and fix it as below.
  • In the migrations, after one of them committed: the database is restored to the dump taken right before them, and the previous version is started again. Production is unchanged and still on the old engine; the release did not ship. Do not revert the pin. Read the migration’s error in the deploy log. If the cause is in the brain (one of your own migrations, a view or foreign key of yours that the engine migration trips on), fix it and push. If it is the engine’s, report it to the owners: the fix is a later engine release, upgraded to the same way. Two cases have no rollback, and the engine services then stay stopped (the previous version may not read what the migration wrote, the new one must not boot on a half-applied schema) until a fixed deploy: no dump was taken — the database is external (its provider’s backups or point-in-time recovery are the fallback) or migrations.keepDumps is 0 — or the restore itself failed, and the message names the dump to restore by hand (or, when only the final swap failed, the restored database already in place to rename over the live one).
  • Taking the dump (the message says so): the previous version was started again and nothing changed, but every deploy stops here until the dump fits — free disk space on the server, or lower migrations.keepDumps (0 deploys with no dump, and so with no rollback). Tell the owners.
  • After the migrations: the new version is up as far as it starts. Fix forward: find the failing service in the log and with omnislash doctor, fix, push.
  • In the proof turn (the last step): the new version is up and its containers are healthy, but no agent could take a turn — the message carries the check’s reason: the turn could not be dispatched, never started (the agent runtime did not take it), hung, the agent CLI’s sign-in was refused (renew it with omnislash auth-agents), or the model’s own error (a provider outage reads as one). Every agent — the chief, the personas, the upgrader — is mute until it is fixed. When the check itself could not run (the mcp service was not up to be asked), the message says that instead: whether an agent can answer is then not known. No rollback: the schema is migrated. Fix forward and push again: the next deploy’s proof turn confirms the fix. A fix that needs no deploy (a renewed sign-in, a provider back up) is confirmed by the first agent that answers, or by agent_check.

In every case the deploy itself tells the owners, from the deploy host, through the chief’s bot to the status chat (TELEGRAM_STATUS_CHAT_ID, where alerts go): which stage failed, what production runs now and the link to the run. It needs nothing of the operator, which a failed deploy may have left stopped; a brain with no chief’s bot gets no message. Once the operator runs again, report to the owners with the log and leave upgrade/ in place. The task resumes from its first unticked entry once a deploy succeeds.

Screens from Sixty Seconds Studio, a fictional demo studio, running on the real dashboard.