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 upgradedoes what is the same for every brain: it moves the pin, installs and regenerates the files the engine owns. Then it writesupgrade/TASK.md.- The agent follows
upgrade/TASK.mdto 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)
- Refuses a brain whose
@omnislash-ai/*packages sit outsidepeerDependencies(contract.md § 1). - Resolves the target version and records
from, the version the brain is on:--from, else the current exact pin, else the engine installed innode_modules. - Rewrites every engine peer pin to the target and runs
pnpm install. First it drops frompnpm-lock.yamleach third-party version locked outside a peer range of the target engine packages, optional peers included (an auto-installedbullmqbelow a raised floor, or the 4.5.4 your own"zod": "^4.5.4"locked): pnpm keeps a locked version even when it failsstrict-peer-dependencies, and no pnpm flag re-resolves it. Each dropped entry is printed. A spec of your own inpackage.jsonthat 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.0against^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 (annpm:alias,catalog:, a dist-tag, apnpm.overridesentry) 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. - 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 rangefrom < version <= targetintoupgrade/and writesupgrade/TASK.md, then prints the task. A range with no entries writes nothing and printsno agent steps. It reads no brain code, so a brain the new engine cannot load yet still gets the entries that explain the fix. - Runs the new CLI’s
omnislash regen:Dockerfile,.dockerignore,.github/workflows/{deploy,ci}.yml,docs/engine/,docker/*.yml.example,skills/engine/,constitution/engine-capabilities.jsonand the engine block ofCLAUDE.md/AGENTS.md(brain-dev-guide.md § Updating Engine has the detail and the opt-outs). Regen loadsomnislash.config.ts; when that fails with a task written, the error points atupgrade/TASK.md: apply its entries, then runomnislash regen. - 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
- Commit the bump: everything
omnislash upgradeleft,upgrade/included, aschore: update engine to v<version>. Do not push. - Before deploy: each entry’s Detect and Verify, read-only against production (db tools,
doctor_check). A failedblockerstops the upgrade: report it to the owners and go no further. - In repo, in order: Detect → Apply → Verify, one commit per entry
(
upgrade(<version>): <summary>), its tick included. - Gates:
omnislash doctor,pnpm type-check, the brain’s build. - 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). - After deploy: confirm the deploy landed (
upgrade_statusreports the running engine as the task’sTarget), then Detect → Apply → Verify against production, ticks committed. - Tell the owners: one message with every entry’s “Tell the owner” section and every skipped entry with its reason.
- 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 upgradeon a brain that is already on the target and still hasupgrade/prints the unfinished task again. Resume at the first unticked entry.omnislash doctorwarns Upgrade in progress whileupgrade/exists and names the next unticked entry.omnislash doctor --check upgrade-in-progresspasses 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:--fromcannot 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)
| Field | Meaning |
|---|---|
phase: before-deploy | Read-only checks against production before anything ships: views or keys a migration would trip on, table sizes that decide the deploy window. |
phase: in-repo | Edits to the brain, committed with the bump. |
phase: after-deploy | Production data fixes through db tools, runtime checks (doctor_check), owner notices. |
severity: blocker | Without it the brain does not boot or the deploy fails. |
severity: required | Without it, behaviour breaks. |
severity: cleanup | Nothing breaks if it waits. |
sql | The 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_checkat 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 versionpackage.jsonpins on your default branch. It differs fromrunningwhile 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.mdon 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.
upgrade_startchecks 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.- In its own clone of your repository, on the branch
upgrade/engine, the upgrader runsomnislash upgrade <target>and worksupgrade/TASK.mdas above: the before-deploy checks against production, the in-repo entries one commit each, the gates. It runs every SQL write first insideBEGIN … ROLLBACKto see the row counts. - 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.
- 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.
- It confirms the deploy landed (
upgrade_statusreports the target running — a deploy whose last step, the proof turn, failed is a failed deploy), runsdoctor_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 deletesupgrade/in a final commit. After the merge only.mdchanges (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.continueAfterRestarttimes (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 (theengine: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.
- “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
- 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.keepDumpsis0— 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(0deploys 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 (themcpservice 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 byagent_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.