Skip to content

emrg: a config.toml edit takes effect in the running daemon, no restart - #1334

Merged
argszero merged 4 commits into
masterfrom
feature/config-toml-hot-reload
Sep 17, 2026
Merged

argszero merged 4 commits into
masterfrom
feature/config-toml-hot-reload

Conversation

@argszero

Copy link
Copy Markdown
Owner

Why

Measured by the host (rant 2026-09-17T16:52:57): ~/.emrg/config.toml was read exactly onceemrg/server/__main__.py, at process start. The only path that made an edit take effect was the client noticing the file's mtime and SIGKILLing the daemon (daemon_manager.py:246, and only at client (re)connect), which kills in-flight scheduled handlers, drops every connected client and rebuilds every handler. The GUI has no freshness check at all, so its Settings page (api_key, base_url, model, [[llm.models]] including the vision flag) saved values that never took effect — with no indication.

Cost asymmetry the rant names: making one vision = true take effect required killing the whole daemon, while tasks.yml in the same repo has a real hot reload (Scheduler.apply_tasks, "no daemon restart").

What

New emrg/server/config_reload.py — the decision half.

  • POLL_INTERVAL_SECONDS = 2.0; an idle tick is one os.stat. The file is read and parsed only when the fingerprint (mtime_ns, size) moved.
  • The whole revision is type-checked before the first assignment, so a half-written or wrongly-typed file is rejected whole: the previous good configuration stays in force, one warning is logged, and the next save is a new fingerprint that is retried. A rejected revision is marked seen, so a broken file cannot spam the log on every tick.
  • The applied field set is derived from the dataclass (model excluded), so a field added later is reloadable by default instead of silently uncovered.
  • model is reported, never assigned — see below.

emrg/server/daemon.py — the act half.

  • _config_reload_loop() rides the existing background-loop pattern: started in _run, cancelled by name in _shutdown_all; _reload_config_once() drives exactly one revision so a test never has to sleep.
  • The state change behind /model is extracted into _apply_model_switch(); _handle_set_model and the reload path both call it. An edited [llm] model therefore invalidates the usage anchors (Dev.to 3dh3g), re-resolves context_window/vision from the matching [[llm.models]] entry, and then broadcasts model_set to every connected client. A bare self.llm.config.model = … would have skipped all three.
  • max_tool_rounds is mirrored into the construction-time snapshot, or the tool loop would keep running on the old limit while the file said otherwise. The value always comes from the host's file — the reload never invents one.
  • load_config(path=…) gained an optional parameter (all existing callers unchanged) so the reload path and its tests point at a file they own. A test must never read the host's config.

DEVELOPMENT.md documents the behaviour, the per-key semantics and the three exact log lines a host verifies it with in ~/.emrg/emrgd.log — "I saved the file and nothing happened" was indistinguishable from success before. The same section's [update] paragraph named two fields (check, ttl_hours) that UpdateConfig no longer has; corrected against upgrade.py (enabled, delay_minutes, fixed 300 s tick).

Guard

tests/test_config_reload.py — 11 tests, all on files under tmp_path, no test starts/stops/restarts a daemon (one in-process EmrgServer, driven by hand):

  1. the type table covers every reloadable field (it is a second source of truth — this is the step that keeps it honest);
  2. an unchanged file is not a revision; a missing file is not a crash;
  3. a valid edit is applied in place, reported by key, and does not disturb untouched fields;
  4. a broken revision (unparseable TOML) keeps the previous config, is not re-reported, and is retried on the next write;
  5. a wrongly-typed field rejects the whole revision — including its valid keys — and max_tokens = true is covered (bool is a subclass of int);
  6. a model change is reported, not assigned;
  7. the daemon mirrors the tool-round snapshot;
  8. a model change travels the switch path: entry's API model, context_window, vision, anchors invalidated;
  9. a rejected revision never raises out of the tick;
  10. the loop itself applies a revision and survives a bad one (every other test drives the body by hand, which would leave the timer untested);
  11. the applied set stays derived from the dataclass.

Mutation evidence (six arms, source-side)

arm result
validation removed entirely killed (1 failed)
strict-bool arm dropped killed (1 failed)
model assigned instead of reported killed (2 failed)
rejected revision not marked seen killed (3 failed)
daemon forgets the tool-rounds mirror killed (1 failed)
assignment skipped entirely killed (4 failed)

Both source files restored byte-identically (config_reload.py sha256[:16] 7ba4550eae05a12b, daemon.py 7f5b6b8f61af9a2a, each printed before and after by the harness, which refuses to continue on a failed restore).

Verification

  • uv run pytest tests/ -q2808 passed, 16 skipped
  • uv run python -c "from emrg.client.app import run_client" → OK; python -m emrg --help → OK
  • scripts/check-doc-count.pyOK: no tracked file states the Python test count
  • The three documented log lines were measured, not written from memory: a probe printed changed=max_tokens,temperature · model→gpt-4o (via the /model path) · rejected (previous config kept): TOMLDecodeError: …

Not in this PR (stage 2 of the same rant)

Removing the client's config-restart branch (daemon_manager.py:287, source mtime becomes the only restart reason), the GUI/TUI showing the effective value, and main.js:994's comment that relies on a GUI-side mechanism which does not exist.

The host's measurement (rant 2026-09-17T16:52:57): `config.toml` was read
exactly once, in `emrg/server/__main__.py`. The only path that made an edit
take effect was the client noticing the file's mtime and SIGKILLing the daemon
— which drops every connected client and rebuilds every scheduled handler, and
from the GUI, where no freshness check exists at all, not even that. Editing
`api_key`, `base_url`, `model`, `vision`, `max_tokens` or `temperature` did
nothing, silently.

This adds the daemon-side watcher and the apply path:

- `emrg/server/config_reload.py` — the decision half. One tick is an `os.stat`;
  the file is read and parsed only when the fingerprint (mtime_ns, size) moved.
  The whole revision is type-checked **before** the first assignment, so a
  half-written or wrongly-typed file is rejected whole: the previous good
  configuration stays in force, one warning is logged, and the host's next save
  is a new fingerprint that is retried. A rejected revision is marked seen, so
  a broken file cannot spam the log on every tick.
- `emrg/server/daemon.py` — the act half. `_config_reload_loop()` rides the
  existing background-loop pattern (started in `_run`, cancelled by name in
  `_shutdown_all`); `_reload_config_once()` drives exactly one revision.
  Applied fields are derived from the dataclass, so a field added later is
  reloadable by default; the value always comes from the host's file.
- `model` is the one field that is **reported, never assigned**: the state
  change behind `/model` is extracted into `_apply_model_switch()`, and a
  reload runs that path — usage-anchor invalidation (Dev.to 3dh3g),
  `context_window` / `vision` resolution from the matching entry — and then
  broadcasts `model_set` to every client. A bare assignment would have skipped
  all of it.
- `max_tool_rounds` is mirrored into the daemon's construction-time snapshot,
  or the tool loop would keep running on the old limit while the file said
  otherwise.
- `load_config(path=…)` gained an optional parameter so the reload path — and
  its tests — can point at a file they own. A test must never read the host's
  config.

`DEVELOPMENT.md` documents the behaviour, the per-key semantics and the three
exact log lines a host can verify it with (`emrgd.log`), because "I saved the
file and nothing happened" was indistinguishable from success before. The same
section's `[update]` paragraph named two fields (`check`, `ttl_hours`) that
`UpdateConfig` no longer has, and is corrected against `upgrade.py`.

Guarded by `tests/test_config_reload.py` (11 tests): the type table must cover
every reloadable field (it is a second source of truth, so this is the step
that keeps it honest); an unchanged file is not a revision; a valid edit is
applied in place and reported by key; a broken revision keeps the previous
config, is not re-reported, and is retried on the next write; a wrongly-typed
field rejects the whole revision including its valid keys (`max_tokens = true`
is covered too — `bool` is a subclass of `int`); a model change is reported and
not assigned; the daemon mirrors the tool-round snapshot and routes a model
change through the switch path (anchors invalidated); a rejected revision never
raises out of the tick; and the loop itself applies a revision and survives a
bad one. No test starts, stops or restarts a daemon — one in-process
`EmrgServer` is driven by hand.

Mutation-verified, six arms — validation removed / the strict-bool arm dropped /
`model` assigned instead of reported / a rejected revision not marked seen / the
tool-rounds mirror forgotten / the assignment skipped entirely — each fails
exactly the tests that own it, with both source files restored byte-identically
(`config_reload.py` sha256[:16] 7ba4550eae05a12b, `daemon.py` 7f5b6b8f61af9a2a).

Measured on this tree: full suite 2808 passed / 16 skipped; import check and
`python -m emrg --help` OK; `check-doc-count.py` OK. Stage 2 (the client's
config-restart branch removed, plus the GUI/TUI showing the effective value)
follows separately.
@argszero

Copy link
Copy Markdown
Owner Author

Reviewer note (measured, not inferred): this PR lands clean on master on its own — git merge-tree --write-tree 99c836d4 25f5a9eb → rc=0, tree 577952c86c25f0 — but it collides with #1332 in emrg/server/daemon.py: git merge-tree --write-tree <#1332 head> 25f5a9eb → rc=1, CONFLICT (content), tree dd48d52120ff (also auto-merging emrg/config.py and tests/test_daemon.py).

Both PRs edit the same model-switch state change: #1332 adds resolve_model_vision to it, this PR extracts that state change into a reusable path so the config reloader can ride it. Neither branch can see this on its own — "merges clean onto master" and "the queue is safe" are different questions. Whichever of the two lands second needs a one-line-level resolution, which is expected rather than a defect in either PR, and scripts/check-merge-order.py reports the dirty pair before a merge.

CI on this head: run 35208364712 green on both legs (test 3m07s, test-windows 7m14s); merge state MERGEABLE/CLEAN.

Conflict in `_handle_set_model`: #1334 extracted `_apply_model_switch`, master
taught the same code path to resolve `vision` through `resolve_model_vision`.
Resolution keeps the extraction and carries master's logic inside it — the
method returns the `model_set` frame (now including `vision_source`) and the
caller keeps sending/broadcasting it.

Semantic merge fix (clean merge, dirty tree): master made `vision` a *derived*
field, so the file's `[llm] vision` key now lands on `vision_default`. The
reload type table never learned about that field — `vision = "yes"` was
therefore applied instead of rejected, and the table no longer covered every
reloadable field. `vision_default` is now typed `bool`, which restores both
properties.

Verified: full suite 2891 passed, 16 skipped.
@argszero

Copy link
Copy Markdown
Owner Author

Maintainer push: conflict resolved (25f5a9eb..ef38270e)

The PR was CONFLICTING (a dirty PR gets no CI runs, so it could not advance),
and the head branch lives on argszero/emrg, so I resolved the conflict
directly and pushed the result. Two hunks in emrg/server/daemon.py:

  1. Imports — a union: this PR's emrg.server.config_reload imports and
    master's extended emrg.server.llm imports (CONTENT_RISK,
    CONTEXT_TOO_LONG, classify_llm_error, with_content_risk_hint).
  2. _handle_set_model — the interesting one. This PR extracted
    _apply_model_switch; master taught the same code path to resolve vision
    through resolve_model_vision (rant 2026-09-17T16:53:02). The resolution
    keeps the extraction and carries master's logic inside it: the method
    returns the model_set frame (now also carrying vision_source) and the
    caller keeps doing the send + broadcast. The hot-reload path
    (_reload_config_once) therefore inherits the vision resolution for free —
    which is exactly what the extraction was for.

One semantic merge fix was needed (the merge was clean, the tree was not):
master made vision a derived field, so the file's [llm] vision key now
lands on vision_default. The reload type table had never learned about that
field, so vision = "yes" was applied instead of rejected, and the table no
longer covered every reloadable field (both of this PR's own tests caught it:
test_the_type_table_covers_every_reloadable_field,
test_a_wrongly_typed_field_rejects_the_whole_revision).

Committing vision_default: (bool,) restores both properties: the
file-sourced key is type-checked, and the table covers reloadable_fields().

Verified on the merged tree: full suite 2891 passed, 16 skipped.

⚠️ This is a new head, so the previous votes are void (0/3). Please review the
resolution commit itself, not only the original diff.

…timestamp

CI on the merged head `ef38270e` failed the `windows-2025` leg, and it was not a
flake: `test_a_model_change_is_reported_not_assigned` stopped at `assert None is
not None`. Cause: `fingerprint` was `(st_mtime_ns, st_size)`, and on Windows two
writes milliseconds apart share a timestamp - `model-a` → `model-b` keeps the
size too, so the stat was *identical* and the edit was never applied. That is the
rant's own complaint ("I edited it and nothing happened") surviving on one
platform, silently, with no log line. The macOS/Linux legs could not see it:
there the two writes get different nanosecond timestamps.

The fingerprint is now a sha256 of the bytes. The read-free property was the
stated reason for the stat pair, and it has to go: equal `(mtime_ns, size)` is
*ambiguous* - the tick that finds no change is the tick that cannot rule one out -
so the only sound answer reads the file. Paid on every platform rather than
behind a Windows branch, because coarse timestamps are a property of the mount,
not of the OS. The cost is a small TOML read plus a hash per 2 s tick; the
docstrings in `config_reload.py` and `daemon.py` that claimed a stat-only tick
now say what the tick does.

Regression test replays the Windows shape *as a file system fact* rather than a
patched `stat`: write the edit, then restore the first write's mtime with
`os.utime`, so equal `st_mtime_ns` + equal size + different bytes holds on any
platform. Measured both ways by mutating the source back to the stat pair: the
new test reds with exactly CI's `assert None is not None`, while
`test_a_model_change_is_reported_not_assigned` still *passes* on POSIX - which is
why the shape needs the replay rather than the original test.

Verified: full suite 2892 passed, 16 skipped; import + CLI checks green.
@argszero

Copy link
Copy Markdown
Owner Author

CI red on the windows-2025 leg — the cause was a product defect, now fixed (21648d7e)

The merge push ran CI on this branch for the first time in a while (CONFLICTING =>
no runs at all). Ubuntu passed; Windows failed:

FAILED tests/test_config_reload.py::test_a_model_change_is_reported_not_assigned
E       assert None is not None

Not a flake. fingerprint was (st_mtime_ns, st_size), and on Windows two writes
milliseconds apart share a timestamp; model-amodel-b keeps the file's size
too, so the stat was identical and the revision was never applied. That is the
rant's own complaint — "I edited it and nothing happened" — surviving on one
platform, silently, with no log line. The POSIX legs cannot see it: there the two
writes get different nanosecond timestamps, which is why it took the Windows leg to
find it.

Fix: the fingerprint is now a sha256 of the file's bytes. The read-free property
was the stated reason for the stat pair and it has to go — equal (mtime_ns, size) is
ambiguous, so the tick that finds no change is exactly the tick that cannot rule one
out. The cost is a small TOML read plus a hash per 2 s tick, paid on every platform
rather than behind a Windows branch, since coarse timestamps are a property of the
mount and not of the OS. The two docstrings that claimed a stat-only tick now say what
the tick does.

Regression test: the Windows shape replayed as a file system fact, not a patched
stat — write the edit, then restore the first write's mtime with os.utime, so
equal st_mtime_ns + equal size + different bytes holds on any platform. Proven in
both directions by mutating the source back to the stat pair: the new test reds with
CI's exact assert None is not None, while test_a_model_change_is_reported_not_assigned
still passes on POSIX — which is the reason the replay is needed instead of the
original test.

Full suite: 2892 passed, 16 skipped; import + CLI checks green. ⚠️ New head again,
so the votes are void (0/3).

@argszero

Copy link
Copy Markdown
Owner Author

Maintainer review — one defect found, fixed and pushed (21648d7e → 74d008d6). The code reviewed sound; the defect is in the host-facing manual.

What I verified in the code (no issue found)

  • The reload really lands on subsequent requests. LlmClient.__init__ keeps the LlmConfig by reference (emrg/server/llm.py:210) and every request reads self.config.{model,max_tokens,temperature,stream_options,api_key,base_url} at call time (:229-262, :393), so an in-place assignment reaches the next request and cannot rewrite a stream already in flight. The daemon's other reads (auto_compact_threshold, context_window, models, vision, vision_default) are all attribute reads at use time too; max_tool_rounds is the one the daemon snapshots, and _reload_config_once mirrors it.
  • Atomicity is real, not asserted. validate() runs over the whole revision before the first setattr, and _TYPES is kept honest by test_the_type_table_covers_every_reloadable_field (set(_TYPES) == set(reloadable_fields())), which closes the one hole the continue in validate would otherwise leave open.
  • Ordering across the two paths is right: _apply assigns models / vision_default before the daemon calls _apply_model_switch, so a file that changes both a [[llm.models]] entry and [llm] model resolves against the new entry.
  • The Windows defect is genuinely fixed — the fingerprint reads bytes, and test_a_same_size_edit_inside_the_timestamp_granule_is_still_a_revision replays the failing shape with os.utime rather than stubbing stat.

What I found — the note describes the detector this PR replaced

DEVELOPMENT.md still said:

The daemon watches ~/.emrg/config.toml (a stat every couple of seconds; the file is read only when it changed)

That is the falsified design, stated to the host in the same change whose module docstring exists to explain why a stat cannot answer the question — and this PR's own fingerprint() reads the bytes on every tick. Two consequences for a reader: the tick is not the cheap thing the note implies (material if ~/.emrg is on a network filesystem, which this repo has measured before), and the sentence contradicts the reason the detector was rewritten.

Fixed in 74d008d6: the note now says the tick reads and hashes the file and parses it only when the hash moved, plus one bullet on why the (mtime, size) pair was falsified on windows-2025 and what the read costs. No code touched.

Full suite on this head: 2892 passed, 16 skipped. The push replaces the head, so the votes it needs must come from cycles after it.

Noted, not blocking

  • emrg/server/daemon.py carries top-level [llm] vision] default — a stray ] in a comment (inherited from emrg: the model's vision flag resolves in one place, and never inherits #1332's text). Cosmetic; left alone rather than widening this push.
  • After a config-driven switch, live.model holds the entry's API id while [llm] model holds the display name, so a later unrelated edit re-reports model and re-runs the switch path (idempotent: same api model, so no anchor invalidation) and re-broadcasts model_set. Harmless, and arguably the file being authoritative; worth knowing if a host is surprised by a model_set after editing only temperature.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260917-212650 (Committer review, own measurement).

Reviewed at head 74d008d6 (fresh: git merge-base HEAD 46f180fc = 46f180fc, the master tip).

What I measured, on this head, in its own checkout:

  1. The documentation claim is true of the code. The added paragraph says the tick "reads the file and hashes it". emrg/server/config_reload.py::fingerprint (:93) is path.read_bytes()sha256, POLL_INTERVAL_SECONDS = 2.0 (:56), and the daemon's loop calls it once a tick (daemon.py::_config_reload_loop:625). So the prose now names the mechanism that exists, which is the defect that was fixed here: the previous wording ("a stat every couple of seconds") described the design 21648d7e had already replaced.
  2. The suite: pytest tests/ -q -k reload16 passed.
  3. The load-bearing guard is killable (my arm, not a re-run of the one that produced the file): with one entry deleted from _TYPES (max_tool_rounds), tests/test_config_reload.py::test_the_type_table_covers_every_reloadable_field is the only failure — 1 failed, 11 passed. So "every reloadable field has a declared type" is a property the suite enforces, not a comment. emrg/server/config_reload.py restored byte-identically (sha256[:16] 55f2f7048463edc0, 9867 bytes, before and after).
  4. I also asked the question the arm cannot answer — is any field unvalidated today — as a measurement rather than an inference: reloadable_fields() has 12 entries, _TYPES covers all 12, and _TYPES has no stale name. The expected is None: continue branch in validate() is therefore dead code today and a future-addition hole that the test above closes at the moment it is opened. No objection.

No test in this PR starts, stops or restarts a daemon; nothing here writes ~/.emrg/config.toml (the module only reads it).

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260917-214246 (Committer review, own measurement).

Reviewed at head 74d008d6 (fresh: git merge-base HEAD origin/master = 46f180fc, the
master tip).

Measured on this head

  1. pytest tests/test_config_reload.py -q12 passed; pytest tests/test_daemon.py -q
    156 passed.
  2. Arm 1 — the whole-revision rule has a job. Removed the validate() gate from
    ConfigReloader.poll()test_a_wrongly_typed_field_rejects_the_whole_revision red,
    and the failure shows why it matters: ReloadOutcome(applied=['max_tokens', 'vision', 'vision_default'], error=None) — a half-applied revision, which reads exactly like a
    successful reload. That is the state the module docstring says would be worse than the
    bug it fixes.
  3. Arm 2 — model really is only reported. Added self.live.model = cfg.llm.model
    to _apply()test_a_model_change_is_reported_not_assigned red.
  4. Both restored byte-identically; emrg/server/config_reload.py sha256[:16]
    55f2f7048463edc0 asserted back to its start value.
  5. CI, both legs on this head: test pass (3m15s), test-windows pass (6m57s).

The safety property I checked first, because this is the one file the host owns. No
test resolves config_path(): every case builds its own config.toml under tmp_path
and hands it in, which is exactly what the new load_config(path=...) parameter exists
for; the daemon's own ConfigReloader keeps config_path() as its default, which is right
for the daemon and would be wrong for a test. Nothing in the module writes to the file —
fingerprint() reads bytes, load_config() reads, _apply() assigns onto the live
in-memory LlmConfig.

The documentation claim, checked against the code rather than believed. describe()
produces the three forms the new DEVELOPMENT.md paragraph prints
(changed=…, model→… (via the /model path), rejected (previous config kept): …), and
the paragraph also corrects a stale one: [update] is read as enabled (default true) and
delay_minutes (default 1440) — emrg/config.py:180-181 — with the tick fixed at
TICK_INTERVAL = 300 in emrg/server/upgrade.py:45, so "that interval is not
configurable" is true.

One note, not a blocker. max_tool_rounds is on the reloadable list, so an edit to it
now takes effect without a restart. The constraint that keeps that value the host's —
cycles must not write ~/.emrg/config.toml — is unchanged, but until now a restart was a
second, mechanical speed bump in front of it. Worth knowing; I would not change it, since
"the value always comes from the host's file, never from a default we invent" is the right
rule and a special case here would be the inconsistency.

No defect found.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260917-221117 (Committer review, own measurement).

Voted on the landing tree, not the head. The head 74d008d6 is STALE (2 behind master, base 46f180fc), so refreshing would void the 2 standing votes for a verdict about a tree that can no longer be merged. I measured what this merge would land, in the order it would be merged:

scripts/check-merge-plan-suite.py --steps 1341 1334 1342
  step 2 (#1334) tree 425971e565cc  suite OK: 2913 passed, 17 skipped in 126.19s

The one property this PR rests on, read from the landing tree's own source — that an in-place assignment by the reloader is what a request later reads:

  • emrg/server/llm.py:210self.config = config (the LlmConfig is held by reference, not copied);
  • emrg/server/daemon.py:253 / :258self.llm = LlmClient(llm_config) and self._config_reloader = ConfigReloader(llm_config) — the same object is handed to the client and to the reloader, so setattr(self.live, name, new) reaches every subsequent request and never touches a stream already in flight.

The loop is wired into the lifecycle, not left running loose: created at daemon.py:462, listed in the shutdown walk at :522 ((self._config_reload_task, "config-reload loop")), and gated by while self._running: at :636.

The host-facing enumeration checked against the dataclass. DEVELOPMENT.md lists the reloadable keys; LlmConfig's fields are base_url, api_key, model, max_tokens, temperature, max_tool_rounds, context_window, auto_compact_threshold, models, vision, vision_default, stream_options, context_refresh_interval_ms. Every file-facing key is named — vision_default is the one omission, and it is correct to omit it: it is not a key a host writes, it is where the file's [llm] vision lands (config.py: vision_default=llm_data.get("vision", False), with vision derived through resolve_model_vision). So the enumeration is complete as a list of what a reader would type, and the lead sentence ("every key in [llm] moves") is true of it.

Also checked (this cycle, on this tree): tests/test_config_reload.py 12 passed, tests/test_daemon.py 156 passed, no test resolves config_path() (every case builds its own file under tmp_path, which is exactly what the new load_config(path=…) parameter is for), and nothing in the module writes to the host's file. Both CI legs green on the head (run 35223169444). The earlier mutation arm on the whole-revision validation still stands: removing it reds the test with the half-applied outcome visible (applied=['max_tokens','vision','vision_default']), which is the state the module docstring calls worse than the bug.

No defect found. One residual for a later cycle, not a defect here: with max_tool_rounds on the reloadable list, an edit to it now applies without a restart — the constraint that keeps that value the host's is the rule that cycles never write ~/.emrg/config.toml, not the restart that used to sit in front of it.

@argszero
argszero merged commit 2056448 into master Sep 17, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant