Files
open-design/apps/daemon
0944e176ce feat(daemon): make OD Next opt-in through an app-config switch (#7362)
* feat(daemon): make OD Next opt-in through an app-config switch

OD Next shipped switched on: `readOdNextRolloutPolicy` read `active` when
`OD_NEXT_STRATEGY_ROLLOUT` was unset, so merging the strategy changed how
every design run behaved on every install. The only way back to the ordinary
route was an environment variable, which `PACKAGED_CHILD_ENV_ALLOWLIST`
strips before the packaged daemon ever sees it — a packaged user had no way
to choose either direction.

Turn the switch around. An installation that configured nothing runs `off`
and takes the ordinary route, and `odNextStrategyMode` in app config is how
it opts in. The run route reads that field per request, so configuring it
takes effect on the next run rather than the next daemon restart, and it
persists across restarts and upgrades because it lives in app-config.json
rather than in one process's environment.

`OD_NEXT_STRATEGY_ROLLOUT` still outranks the saved mode, so a pinned
process stays pinned: an operator debugging one daemon, a packaged smoke
run, and a test each set the mode for one process without overwriting what
a user saved, and without a user's saved choice overriding theirs.

`od strategy rollout status` now reports which authority decided
(env / app_config / default), so whoever just configured the mode can
confirm theirs is the one in effect instead of inferring it from the
resulting mode.

* test(daemon): opt the failure-telemetry smoke into OD Next

Its `task_hierarchy` cases assert how an ADMITTED Run reports — one
`strategy-task:` trace per task execution with a `strategy-stage:` span per
Run inside it. That representation only exists for a Run OD Next owns, so
with the strategy now opt-in these three cases waited 3s for an observation
the daemon had no reason to emit and timed out.

Opt in through the same `putConfig` the cases already use. The `single_run`
case keeps its representation on top of the same opt-in, because deepseek
sits outside the capability gate — which is the contrast the table exists to
draw, and it now draws it without depending on an install-wide default.

* fix(daemon): refuse an odNextStrategyMode write that is not a mode

Review catch (#7362): `doWrite` copies the existing config before applying a
partial, so the sanitizer's `delete` did not "leave the previous choice
alone" — it removed an already-saved `active`. `od config set
odNextStrategyMode acive` therefore switched the installation off, printed
success, and left the person who typed it no way to tell that from an
opt-out they never asked for.

Dropping a bad value is the right trade for a preference, where the cost is
one setting falling back to its default. It is the wrong trade for a switch
that decides whether OD Next runs at all, because there "dropped" and
"deliberately off" are the same state. So a write that names the key with
anything other than a mode (or `null`, which stays the deliberate way to opt
back out) is refused whole: 400 from `PUT /api/app-config`, non-zero exit
from `od config set`, nothing written — neighbouring keys in the same body
included.

The read path stays fail-soft: a hand-edited or truncated file drops the
value and reads as unconfigured, which is `off`, rather than taking the
daemon down.

The 400 carries the nested error envelope. `od` only finds a code in that
shape; from a flat body it fell back to `daemon-not-running` and exited 64,
telling a caller to go start a daemon that had just answered.

* fix(daemon): stop reading an unreadable app config as an unconfigured one

Review catch (#7362), and the same defect class as the write-side fix one
commit earlier: an error was being made indistinguishable from a deliberate
choice.

`readAppConfig` already answers `{}` for both states that legitimately mean
"nothing configured" — no file, unparseable file — and throws only when the
daemon genuinely cannot read its own config. The `.catch(() => ({}))` on the
rollout reads flattened that throw into the same answer, so an installation
whose saved `active` became unreadable silently took the ordinary route, and
`od strategy rollout status` reported `requestedMode: off` /
`requestedModeSource: default` — a claim about the operator's choice made
from a fact about the disk.

The run route and the status route now let the read fail. Express 5 forwards
the rejection, so the operator sees an error instead of a confident wrong
answer, and the run fails rather than quietly running unconfigured (it would
have had no `agentCliEnv` either).

The safety latch is the one place that must not depend on the read, so it
takes a thunk now: the latch lands first, and a failed read skips the
control-change event rather than reporting a mode the installation never
chose.

Also gives the opt-in acceptance case an explicit 60s timeout. It is the
only case in that file driving two full runs against a real server, and the
suite default of 20s leaves it no headroom on a slow runner.

---------

Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
2026-08-25 08:04:46 +00:00
..