OpenWork engine v2 switches back by itself: the Den policy forces the migration, and the real ceiling is 0.18.58
The chat sits on engine v1, automatic updates are off, and v2 comes back anyway. Reverting in the selector holds for a few minutes; restarting the app returns v2; and sometimes the switch happens mid-turn, with no click and no warning.
The scene breaks a premise that looked solid: that pinning the app version and disabling updates makes the engine choice local. It does — up to one specific version. From there on, the desktop polls a remote policy every 60 seconds, and that policy takes precedence over the selector, over the state file, and over the environment variable that is supposed to disable the migration.
Correction to the previous article. The rollback analysis for 0.18.59 + engine v1 recommended 0.18.59 as the ceiling. For the free tier that remains true — free models serve up to 0.18.59. But 0.18.59 is exactly the first version that obeys the engine policy, and that is why it does not hold the engine. The ceiling that satisfies both requirements is 0.18.58.
1. What broke
The symptom has three faces, all pointing to the same origin:
- Reverting in the selector does not hold. The chat goes back to v1, and minutes later v2 is serving again — with no action in the interface.
- Restarting returns v2. The state looks like it was never written, but it was.
- The switch happens on its own, without confirmation. The history copy runs with it, with a backup, asking for nothing.
The trail lives in %APPDATA%\openwork\engine-v2-preview.json:
{
"enabled": true,
"chatRouting": true,
"engineOrigin": "forced",
"engineForced": true,
"engineChangedAt": "2026-10-29T10:32:37.009Z",
"historyCopyResult": { "imported": 12, "skipped": 89, "total": 101 }
}
engineOrigin: "forced" is not a value the selector writes. It shows up when nobody chose — and it is the single field that separates "I switched engines" from "someone switched it for me".
In the app's localStorage, the key openwork.engine-migration.analytics.v1 stores the time of each switch (migration_started and migration_completed). That is what dated the two migrations on the same night, at 00:27 and 07:25 local time, in an app that was pinned and had not updated itself.
2. The core trap: there are three levers, not two
The earlier diagnosis separated two: the app version decides the free tier, the engine decides archiving. The third was missing — the one that decides the engine without going through the user.
| Symptom | What actually controls it | Why the obvious attempt does not hold |
|---|---|---|
Free models answer 403 FreeTierError |
App version (from 0.18.60 on) | Reverting the engine does not touch the version |
The archive button disappears and writes return 501 |
Engine (v2 does not archive) | Changing the version does not change the engine that serves |
| v2 comes back by itself, with updates off | Remote policy, polled every 60 s by the desktop — and the app version decides whether it obeys | Disabling auto-update is another channel; this order arrives at runtime |
The practical consequence: "hold the version" and "choose the engine" are no longer the same decision. A version pin protects against the free tier breaking; it does not protect against the engine migration, because the migration is not an update.
3. How the mechanism works inside
The desktop's automation-runner polls the Den every 60 s (RUNNER_WORK_POLL_MS = 60_000) and, in the work response, receives an enginePolicy: { upgrade: boolean } field. The schema description is explicit:
"Live org policy is returned even when inventory has not changed."
When it comes back true, the desktop calls POST /experimental/engine-v2-preview/force on the local server with the host token. The server then:
- schedules the upgrade in the background;
- waits for the engine to go idle;
- copies the history into the v2 database, with a backup;
- writes
chatRouting: trueandengineOrigin: "forced".
A manual revert is only protected against the automatic revert path. The next poll, at most 60 seconds later, re-arms the upgrade — which is what creates the "it switches back while I work" impression.
The cut is the app version, and it is narrow
Comparing the source of the different-ai/openwork repository by tag (v0.18.54 through v0.18.63):
| Version | enginePolicy in the runner · setForcePolicy on the server |
Engine migration is a local default? |
|---|---|---|
| ≤ 0.18.58 | absent | no |
| 0.18.59 · 0.18.60 | present | no (OPENWORK_ENGINE_AUTO_UPGRADE === "1") |
| ≥ 0.18.61 | present | yes (!== "0") — and the Den policy overrides the opt-out |
The file apps/desktop/electron/engine-force-policy.mjs and the setForcePolicy function simply do not exist in 0.18.58. There the app ignores the policy because it has nothing to apply it with. It is the same reason why, from 0.18.61 on, OPENWORK_ENGINE_AUTO_UPGRADE=0 holds nothing: the policy arrives afterwards.
The 0.18.60 release note (Oct 22) names the change outright: "Older installs move to OpenCode v2 in the background."
Why editing the state file does not fix it
With the app open, the server keeps chatRouting in memory and rewrites the file on any persistState. Writing false while it runs is lost in the next cycle. The edit only counts with the app closed, and before changing versions.
4. Verifiable step by step
The goal is the only combination where the free tier serves and the engine is a local choice: app ≤ 0.18.58, updates off, chat on v1.
Step 1 — Confirm who is in charge
Get-Content "$env:APPDATA\openwork\engine-v2-preview.json" -Raw
chatRouting: true with engineOrigin: "forced" means a remote policy is acting, not a choice of yours. The live check sits on the workspace's local server — the port is in %APPDATA%\openwork\openwork-server-state.json, and the client token in %APPDATA%\com.differentai.openwork\openwork-server-tokens.json (both are session secrets: use them in memory, never write them to a project file):
GET http://127.0.0.1:<port>/experimental/engine-v2-preview/status
The response carries running, version and chatRouting with the same forced origin when the policy is applied.
Step 2 — Close the app and pin the state
Only with OpenWork closed:
{
"enabled": true,
"chatRouting": false,
"engineOrigin": "manual"
}
enabled: true on its own does not switch the engine; chatRouting is what decides.
Step 3 — Install a version ≤ 0.18.58
Before hunting for an installer off the machine, check the updater cache: the installer.exe at the root of %LOCALAPPDATA%\@openworkdesktop-updater\ is the last version it installed, and it is what app-recovery uses as a rollback.
(Get-Item "$env:LOCALAPPDATA\@openworkdesktop-updater\installer.exe").VersionInfo.FileVersion
Install 0.18.58 and keep automatic updates off.
Step 4 — Check that it worked
(Get-Item "$env:LOCALAPPDATA\Programs\@openworkdesktop\OpenWork.exe").VersionInfo.FileVersion
# expected: 0.18.58
%APPDATA%\openwork\engine-instances.json→ theprimaryinstance'sbinfield ends inopencode-x86_64-pc-windows-msvc.exe(v1). If it ends inopencode2-…, the chat is on v2.GET http://127.0.0.1:<port>/api/modelwith the same instance'sauthProbe→ 46 models, allproviderID: opencode, 45 free. On v2 it was 25, noneopencode.- Connect a provider lists OpenCode again, with a key field (
OPENCODE_API_KEY). %APPDATA%\OpenWork\runtime-opencode-config.jsonwithdisabled_providers: [],providers: {}and noprovider-filtersin thepluginlist.
Step 5 — Barrier against the accidental upgrade
The update is downloaded into %LOCALAPPDATA%\@openworkdesktop-updater\pending\ (openwork-win-x64-<version>.exe + update-info.json) and does not apply itself. Keeping that folder empty is what stops the version from climbing unintentionally.
5. Post-downgrade traps
engineOrigin: "forced" may be residue. In 0.18.58 there is no policy client to reapply it; the field left in the file is inherited from the previous version. Before treating it as an active policy, check the installed version and the endpoint's running — residue is not fought with edits.
POST .../force with {"upgrade": false} is not a fix. It holds until the next poll, at most 60 seconds. Without the version that does not know the policy, the order comes back.
The Connect warning is a false positive. OpenWork Connect: Needs attention · The selected provider/model was not found in OpenCode provider catalog. persists with engine v1 even while chat and tools work: the Cloud sync writes the default to an AI Gateway model (ipr_<id>/gwm_<id>), which does not exist in the v1 catalog, and the interface has no control to change that field. It disappears during checking and returns right after.
A session that crossed both engines may refuse every write. Archive, rename and delete return Unexpected server error. Check server logs for details. with a ref=err_…, and nothing changes. It is not the downgrade: it is the v1 database's event counter lagging behind the session's last event. The signature is in %USERPROFILE%\.local\share\opencode\log\opencode.log, on the ref line — UNIQUE constraint failed: event.aggregate_id, event.seq inside Session.setArchived. Measure before fixing: select max(seq), count(*) from event where aggregate_id='<ses_id>' against select seq from event_sequence where aggregate_id='<ses_id>'. A counter lower than the last seq is this case.
Conversations created on v2 do not show up on v1. The v2 database stays at %APPDATA%\openwork\opencode-v2\state\opencode.db and nothing is deleted; the text is what has to be copied before switching engines.
The automatic rollback brings back 0.18.59. If 0.18.58 fails at boot, app-recovery uses the installer.exe at the cache root as a fallback — and 59 is precisely the first version that obeys the policy. After a recovery, check the version before concluding the downgrade did not work.
Turning the runner off cuts more than the policy. OPENWORK_AUTOMATION_RUNNER=off is the contingency for staying on 0.18.59: it cuts the policy channel, but the desktop stops accepting Automations and remote session commands. It trades functionality for control.
6. Trade-off and the exit trigger
What staying on 0.18.58 costs: the 0.18.59 fixes are lost, and the app drifts further from the product with every release. Engine v1 is being discontinued (#5962, #6121) — a position with an expiry, not a destination.
The exit trigger is objective: the free tier serving again on a version ≥ 0.18.60 (tracked in issues #6466 and #6467). On that day, updating stops being an isolated move:
- from 0.18.61 on, the engine migration is an app default;
- the Den policy overrides
OPENWORK_ENGINE_AUTO_UPGRADE=0; - so staying on v1 would also require turning the runner off or signing out.
The practical consequence: the version stopped being only the lever of what breaks — it also decides whether a remote policy can swap the engine underneath. Anyone holding a version as control has to answer two questions, not one: which floor brings the feature back, and which ceiling is the last one where the app still obeys only you. In this case both answers land on the same number: 0.18.58.
Bruno Torres
Solo digital entrepreneur. I build digital businesses from scratch with AI agents — and document the path, including what didn't work.
Comments
Share: X · Facebook · LinkedIn · WhatsApp · Telegram · E-mail ·