bug(cli): server_connect writes three config values non-atomically — partial update on failure #1203

Merged
HAL9000 merged 4 commits from bugfix/m6-server-connect-non-atomic into master 2026-04-26 17:52:59 +00:00
7 changed files with 187 additions and 94 deletions
+13 -52
View File
@@ -26,7 +26,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Fixed
- **Trusted Automation Profile Description** (#9156): Corrected the `trusted` built-in automation profile description from `"Auto for most, human for apply and revert"` to `"Auto-exec, manual apply. Day-to-day development"` to match the specification at line 17197.
- **Atomic `server_connect` config writes** (#993): Fixed `server_connect` in
`cli/commands/server.py` to write all three config values (`server.url`,
`server.namespace`, `server.tls-verify`) atomically. A snapshot of the config
file is taken before any writes; if any `set_value()` call fails, the snapshot is
restored and compensating `CONFIG_CHANGED` events are emitted for already-applied
keys so the audit trail reflects the rollback. Added `emit_config_changed()` helper
to `ConfigService` for decoupled event emission in rollback flows. Added
`close()` method to `ReactiveEventBus` for proper resource cleanup in tests.
Resolved merge conflict in `config_service.py` integrating the PR's
`emit_config_changed()` helper with master's scoped config infrastructure.
Removed `# type: ignore[assignment]` by introducing a typed `_AutoDiscover`
sentinel class. BDD regression coverage in
`features/tdd_server_connect_atomic_writes.feature`.
- **Atomic `load_from_metadata` for Autonomy Guardrails** (#7504): Fixed
`AutonomyGuardrailService.load_from_metadata()` to validate both
@@ -47,16 +59,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
strict `is True or == 1` instead of `bool()` to prevent truthy non-boolean
YAML values (e.g. `unsafe: "no"`) from being treated as unsafe.
- **Checkpoint Trigger Names and Config Key Path** (#5163): Aligned
`docs/specification.md` and `docs/reference/checkpointing.md` with the
implementation. Trigger names `on_tool_write` and `on_tool_write_complete`
are renamed to `before_tool_execute` and `after_tool_execute` to match
`src/cleveragents/tool/runner.py` (`DEFAULT_AUTO_TRIGGERS`) and
`src/cleveragents/application/services/config_service.py`. The configuration
reference table entry is moved from `sandbox.checkpoint.auto-create-on` to
`core.checkpoints.auto-create-on` to match the implementation's
`_register("core", "checkpoints.auto_create_on", ...)` call.
- **UKO Runtime Layer 2 (Paradigm) Indexing** (#9351): Added missing `rdf:type
uko-oo:Class` triple emission in `PythonAnalyzer._extract_class()` so that
Python class definitions are now correctly classified at layer 2 (paradigm/OO)
@@ -118,22 +120,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
(#828)
- **Implementation Worker PR Inspection Permissions** (#8175): Added
`forgejo_list_pull_request_files` and `forgejo_get_pull_request_diff` permissions
to `implementation-worker.md`. These read-only permissions enable the worker to
directly inspect PR changes in PR fix mode, reducing unnecessary full-repo clones
and aligning permissions with the `pr-reviewer` agent. Also added `curl *`,
`printenv *`, and `echo $*` bash permissions needed for Forgejo API calls,
environment variable checks, and basic shell operations.
- Added TDD bug-capture test for bug #991 — AuditService._ensure_session()
TOCTOU race. Behave BDD scenario (`@tdd_bug @tdd_bug_991
@tdd_expected_fail`) launches 10 concurrent threads through a
`threading.Barrier` to prove that `_ensure_session()` creates multiple
engines when called without a lock. The test asserts `create_engine` is
called exactly once — which currently fails, confirming the race. The
`@tdd_expected_fail` tag inverts this to a CI pass until the fix is
merged. (#1095)
- **TDD Issue-Capture Test Activation** (#7025): Replaced 234 bare `@skip` tags
across 82 Behave feature files with the correct `@tdd_expected_fail @tdd_issue
@tdd_issue_<N>` tag system. Scenarios whose referenced bugs were already fixed
@@ -236,13 +222,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Changed
- **PR Merge Supervisor Criteria Documentation** (#8107): Updated `pr-merge-pool-supervisor.md` to
explicitly document all ten merge criteria that the supervisor applies at runtime. Replaces the
implicit six-criteria description with a dedicated "Ten Merge Criteria" section listing all
requirements: approval, CI passing, no conflicts, not stale, no `needs feedback` label, not
blocked, has milestone, has `Type/` label, has `Closes` reference, and changelog updated.
Eliminates the inconsistency between the agent definition and actual runtime behaviour.
- **Decision Tree Full ULID Display** (#5825): The `agents plan tree` command now
displays full 26-character ULIDs for all decisions instead of truncating them to
8 characters. This enables users to copy decision IDs directly from tree output
@@ -302,16 +281,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
are also protected. The DI container registration as `providers.Singleton`
is now correct and safe.
- **Migration Prompt Safe Default on Failure** (#7503): Fixed
`MigrationRunner._default_prompt_for_migration` silently auto-approving destructive
database migrations when the interactive prompt raised any exception. The handler now
catches only `(IOError, OSError, EOFError)` (broken stdin / non-interactive pipe),
logs a `WARNING` instead of a `DEBUG` message, and returns `False` (reject) so that
migrations are never applied without explicit user consent. `KeyboardInterrupt` is
re-raised so Ctrl-C always works. Non-interactive environments (stdin not a TTY) also
now return `False` by default; use `CLEVERAGENTS_AUTO_APPLY_MIGRATIONS=true` or the
`--yes` CLI flag to approve automatically.
- **Validation Gate Empty-Run Guard** (#7508): Fixed `ApplyValidationSummary.all_required_passed`
returning `True` when zero validations were run, silently bypassing the apply gate. The property
now returns `False` when the validation result set is empty (`is_empty` is `True`), ensuring
@@ -374,14 +343,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
a `sqlite3.IntegrityError: UNIQUE constraint failed` crash when `agents plan use` was
called on an action that already had arguments registered via `action create`. (#4197)
- Updated `resource_dag.robot` to use `StaticPool`-based SQLite connections
(`poolclass=StaticPool`, `connect_args={"check_same_thread": False}`) in
all three test cases, preventing connection sharing issues in the test
environment. Updated cycle detection test to use distinct resource types
(`robot/cycle-a` and `robot/cycle-b`) instead of a single shared type,
improving coverage of cross-type cycle detection. Split from PR #1204
per reviewer request for atomic commits. (#1226)
---
## [3.8.0] -- 2026-04-05
+2 -5
View File
@@ -14,17 +14,14 @@ Below are some of the specific details of various contributions.
* Jeffrey Phillips Freeman has acted as Lead Developer, daily contributor, and Project Owner.
* Brent E. Edwards has contributed quality assurance, test coverage, and CI pipeline improvements.
* HAL 9000 has contributed the trusted automation profile description correction (#9156): updated the `trusted` built-in profile description to match the specification.
* HAL 9000 has contributed automated implementation, bug fixes, and feature development as part of the CleverAgents automation pool.
* HAL 9000 has contributed concurrency safety improvements, including thread-safe context tier management (issue #7547) for parallel plan execution.
* HAL 9000 has contributed the plan concurrency race-condition fix (#7989): wired `LockService` into the plan lifecycle, guarding `execute_plan()` and `apply_plan()` with plan-level advisory locks and unique per-invocation owner identities to prevent silent concurrent state corruption.
* HAL 9000 has contributed the bug-hunt-pool-supervisor non-blocking tracking fix: updated step 5 to be best-effort and added rule 9 to prevent the automation-tracking-manager call from blocking the main supervisor loop.
* HAL 9000 has contributed the plugin entry point security hardening fix (#7476): enforced entry point allowlist validation before importing plugin modules to prevent malicious plugin loading.
* HAL 9000 has contributed the benchmark workflow separation (#9040): moved the benchmark-regression job out of the default PR workflow into a dedicated scheduled workflow, reducing median PR CI turnaround time from 99-132 minutes to under 30 minutes.
* HAL 9000 has contributed automated bug fixes, security improvements, and migration safety enhancements including the migration prompt safe-default fix (#7503).
* HAL 9000 has contributed CONTRIBUTING.md compliance improvements to the agent-evolution-worker (#8370): replaced hardcoded milestone references with dynamic Forgejo API queries and standardised label usage to `Type/Task`.
* This project was made possible thanks to considerable donation of time, money, and resources by CleverThis, Inc.
* HAL 9000 has contributed automated bug fixes, CLI output formatting improvements, and ongoing maintenance as part of the CleverAgents automation system.
* HAL 9000 has contributed the file edit encoding parameter fix (PR #8258 / issue #7559).
* HAL 9000 has contributed the architecture-pool-supervisor milestone assignment feature (PR #8188 / issue #7521): added `forgejo_update_pull_request` permission and documented the PR workflow for major spec changes, enabling automatic milestone assignment for specification PRs.
* HAL 9000 has contributed the pr-merge-pool-supervisor documentation sync (#8107): updated the agent definition to explicitly list all ten merge criteria that the supervisor applies at runtime, eliminating the inconsistency between the documented six criteria and the actual ten criteria checked.
* HAL 9000 has contributed the resource_dag.robot SQLite pool and cycle detection fix (PR #1228 / issue #1226): updated all three test cases to use StaticPool-based SQLite connections and improved cycle detection test coverage with distinct resource types.
* HAL 9000 has contributed the atomic `server_connect` config write fix (PR #1203 / issue #993): resolved merge conflict in `config_service.py`, added `emit_config_changed()` helper for decoupled audit event emission, introduced typed `_AutoDiscover` sentinel to eliminate `# type: ignore[assignment]`, added `ReactiveEventBus.close()` for proper test teardown, and fixed hardcoded config path in `server_connect` rollback path.
@@ -7,8 +7,7 @@ If a middle call fails (e.g. disk full, permissions error), the earlier values
are already persisted while the later ones retain their old values, leaving the
configuration in a half-written state.
The test uses the ``@tdd_expected_fail`` tag until the fix in #993 is merged.
See CONTRIBUTING.md > Bug Fix Workflow > TDD Issue Test Tags.
These scenarios now run as normal regression coverage for #993.
"""
from __future__ import annotations
@@ -24,7 +23,9 @@ from unittest.mock import patch
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.config_service import ConfigService
from cleveragents.application.services.config_service import ConfigScope, ConfigService
from cleveragents.infrastructure.events.reactive import ReactiveEventBus
from cleveragents.infrastructure.events.types import EventType
# ---------------------------------------------------------------------------
# Helpers
@@ -61,11 +62,14 @@ class _FailingConfigService(ConfigService):
config_dir=config_dir,
config_path=config_path,
event_bus=event_bus,
project_root=None,
)
self._fail_on_call = fail_on_call_number
self._call_count = 0
def set_value(self, key: str, value: Any) -> None:
def set_value(
self, key: str, value: Any, *, scope: ConfigScope | None = None
) -> None:
"""Override set_value to fail on a specific call number."""
self._call_count += 1
if self._call_count == self._fail_on_call:
@@ -73,7 +77,7 @@ class _FailingConfigService(ConfigService):
f"Simulated disk failure on set_value call #{self._call_count} "
f"(key={key!r})"
)
super().set_value(key, value)
super().set_value(key, value, scope=scope)
# ---------------------------------------------------------------------------
@@ -96,11 +100,15 @@ def step_fresh_config_dir(context: Context) -> None:
context.atomic_config_dir = Path(context.atomic_test_home) / ".cleveragents"
context.atomic_config_dir.mkdir(parents=True, exist_ok=True)
context.atomic_config_path = context.atomic_config_dir / "config.toml"
context.atomic_event_bus = ReactiveEventBus()
context.atomic_old_home = os.environ.get("HOME")
os.environ["HOME"] = context.atomic_test_home
# Register cleanup
def _cleanup() -> None:
event_bus = getattr(context, "atomic_event_bus", None)
if event_bus is not None:
event_bus.close()
old_home = getattr(context, "atomic_old_home", None)
if old_home is not None:
os.environ["HOME"] = old_home
@@ -121,6 +129,7 @@ def step_pre_existing_config(context: Context) -> None:
svc = ConfigService(
config_dir=context.atomic_config_dir,
config_path=context.atomic_config_path,
project_root=None,
)
svc.set_value("server.url", _ORIGINAL_URL)
svc.set_value("server.namespace", _ORIGINAL_NAMESPACE)
@@ -162,6 +171,7 @@ def step_invoke_with_second_call_failure(context: Context) -> None:
config_dir=context.atomic_config_dir,
config_path=context.atomic_config_path,
fail_on_call_number=2,
event_bus=context.atomic_event_bus,
)
with patch(
@@ -197,6 +207,7 @@ def step_invoke_with_third_call_failure(context: Context) -> None:
config_dir=context.atomic_config_dir,
config_path=context.atomic_config_path,
fail_on_call_number=3,
event_bus=context.atomic_event_bus,
)
with patch(
@@ -313,3 +324,33 @@ def step_check_no_url(context: Context) -> None:
f"The config should be empty (all-or-nothing atomicity) but a "
f"partial write occurred."
)
@then("the config event trail should include a compensating rollback for server.url")
def step_check_compensating_event(context: Context) -> None:
"""Assert rollback emits a compensating CONFIG_CHANGED event for server.url."""
config_events = [
event
for event in context.atomic_event_bus.audit_log
if event.event_type == EventType.CONFIG_CHANGED
and event.details.get("key") == "server.url"
]
assert len(config_events) == 2, (
"Expected two server.url CONFIG_CHANGED events (forward write + "
f"compensating rollback), got {len(config_events)}."
)
forward_event = config_events[0]
rollback_event = config_events[1]
assert forward_event.details.get("old_value") == _ORIGINAL_URL
assert forward_event.details.get("new_value") == _NEW_URL
assert rollback_event.details.get("old_value") == _NEW_URL
assert rollback_event.details.get("new_value") == _ORIGINAL_URL
assert rollback_event.details.get("compensating") is True
assert (
rollback_event.details.get("reason")
== "rollback_after_partial_server_connect_failure"
)
@@ -1,4 +1,4 @@
@tdd_expected_fail @tdd_issue @tdd_issue_993
@tdd_issue @tdd_issue_993
Feature: TDD Issue #993 — server_connect writes three config values non-atomically
As a developer
I want to verify that server_connect writes all three config values atomically
@@ -14,9 +14,7 @@ Feature: TDD Issue #993 — server_connect writes three config values non-atomic
# Expected behavior: all three config values are written atomically — either
# all succeed or all fail (rollback to original state).
#
# This test uses the @tdd_expected_fail tag until the fix in #993 is merged.
# The tag inverts the result so CI passes while the bug is still unfixed.
# See CONTRIBUTING.md > Bug Fix Workflow > TDD Issue Test Tags.
# This scenario now runs as a normal regression test after the fix for #993.
Scenario: Config remains unchanged when second set_value fails during server_connect
Given a fresh config directory for atomic write test
@@ -26,6 +24,7 @@ Feature: TDD Issue #993 — server_connect writes three config values non-atomic
And the config should have rolled back server.url to its original value
And the config should still have the original server.namespace
And the config should still have the original server.tls-verify
And the config event trail should include a compensating rollback for server.url
Scenario: Config remains unchanged when third set_value fails during server_connect
Given a fresh config directory for atomic write test
2
@@ -1122,6 +1122,13 @@ _DEFAULT_CONFIG_PATH: Path = _DEFAULT_CONFIG_DIR / "config.toml"
_PROJECT_ROOT_MARKERS: tuple[str, ...] = ("cleveragents.toml", ".cleveragents")
class _AutoDiscover:
"""Sentinel type for auto-discovery of the project root in ``ConfigService``."""
_AUTO_DISCOVER: _AutoDiscover = _AutoDiscover()
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
"""Recursively merge *override* into *base* (override wins on conflict).
1
@@ -1173,13 +1180,13 @@ class ConfigService:
config_dir: Path | None = None,
config_path: Path | None = None,
event_bus: EventBus | None = None,
project_root: Path | None = ..., # type: ignore[assignment]
project_root: Path | _AutoDiscover | None = _AUTO_DISCOVER,
) -> None:
self._config_dir: Path = config_dir or _DEFAULT_CONFIG_DIR
self._config_path: Path = config_path or (self._config_dir / "config.toml")
self._event_bus = event_bus
# ``...`` means "auto-discover"; ``None`` means "no project root".
if project_root is ...:
# ``_AUTO_DISCOVER`` means "auto-discover"; ``None`` means "no project root".
if isinstance(project_root, _AutoDiscover):
self._project_root: Path | None = discover_project_root()
else:
self._project_root = project_root
1
@@ -1319,6 +1326,51 @@ class ConfigService:
with open(target, "w") as fh:
tomlkit.dump(doc, fh)
def emit_config_changed(
self,
*,
key: str,
old_value: Any,
new_value: Any,
compensating: bool = False,
reason: str | None = None,
) -> None:
"""Emit a ``CONFIG_CHANGED`` domain event without mutating config state.
This helper is used by :meth:`set_value` and rollback flows that need
compensating audit events after restoring a prior config snapshot.
"""
emitted_old = old_value
emitted_new = new_value
if is_sensitive_key(key):
emitted_old = REDACTED
emitted_new = REDACTED
details: dict[str, Any] = {
"key": key,
"old_value": emitted_old,
"new_value": emitted_new,
}
if compensating:
details["compensating"] = True
if reason is not None:
details["reason"] = reason
if self._event_bus is not None:
try:
self._event_bus.emit(
DomainEvent(
event_type=EventType.CONFIG_CHANGED,
details=details,
)
)
except Exception:
_logger.warning(
"audit_emit_failed",
event_type="CONFIG_CHANGED",
exc_info=True,
)
def set_value(
self, key: str, value: Any, *, scope: ConfigScope | None = None
) -> None:
@@ -1346,24 +1398,7 @@ class ConfigService:
data[key] = value
self.write_scoped_config(data, effective_scope)
if is_sensitive_key(key):
old_value = REDACTED
value = REDACTED
if self._event_bus is not None:
try:
self._event_bus.emit(
DomainEvent(
event_type=EventType.CONFIG_CHANGED,
details={
"key": key,
"old_value": old_value,
"new_value": value,
"scope": effective_scope.value,
},
)
)
except Exception:
_logger.warning("audit_emit_failed", event_type="CONFIG_CHANGED")
self.emit_config_changed(key=key, old_value=old_value, new_value=value)
# -- validation -----------------------------------------------------------
+54 -7
View File
6
@@ -74,6 +74,35 @@ def _get_config_service() -> ConfigService:
)
def _snapshot_config_file(config_path: Path) -> bytes | None:
"""Capture the current config file bytes, if present."""
if not config_path.exists():
return None
return config_path.read_bytes()
def _restore_config_file(config_path: Path, snapshot: bytes | None) -> None:
"""Restore config.toml to a previously captured snapshot."""
if snapshot is None:
if config_path.exists():
config_path.unlink()
return
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_bytes(snapshot)
def _server_connect_updates(
config: ServerConnectionConfig,
) -> tuple[tuple[str, Any], ...]:
"""Return ordered key updates performed by ``server_connect``."""
return (
("server.url", config.server_url),
("server.namespace", config.namespace),
("server.tls-verify", config.tls_verify),
)
def resolve_server_mode() -> str:
"""Determine the current server mode.
@@ -140,14 +169,32 @@ def server_connect(
console.print(f"[red]Invalid server configuration:[/red] {exc}")
raise typer.Exit(code=1) from exc
# Persist to config file via set_value() so that each change emits
# a CONFIG_CHANGED audit event. Using write_config() would bypass
# the EventBus and leave no audit trail for these security-relevant
# settings (server URL, namespace, TLS verification).
# Persist via set_value() so each key produces a CONFIG_CHANGED audit
# event. If any write fails, restore the original snapshot and emit
# compensating CONFIG_CHANGED events so the audit trail reflects the
# rollback (no stale partial-change trail).
svc = _get_config_service()
svc.set_value("server.url", config.server_url)
svc.set_value("server.namespace", config.namespace)
svc.set_value("server.tls-verify", config.tls_verify)
config_path = svc._config_path
Outdated
Review

Hardcoded config path: This duplicates the path computation already done inside _get_config_service() (lines 67-68). If the config path logic changes in one place but not the other, the snapshot/restore will target a different file than ConfigService is writing to.

Consider exposing config_path as a read-only property on ConfigService and using svc._config_path (or a public accessor) here instead.

**Hardcoded config path**: This duplicates the path computation already done inside `_get_config_service()` (lines 67-68). If the config path logic changes in one place but not the other, the snapshot/restore will target a different file than `ConfigService` is writing to. Consider exposing `config_path` as a read-only property on `ConfigService` and using `svc._config_path` (or a public accessor) here instead.
config_snapshot = _snapshot_config_file(config_path)
update_items = _server_connect_updates(config)
existing_config = svc.read_config()
original_values = {key: existing_config.get(key) for key, _ in update_items}
applied_updates: list[tuple[str, Any]] = []
try:
for key, new_value in update_items:
svc.set_value(key, new_value)
applied_updates.append((key, new_value))
except Exception:
_restore_config_file(config_path, config_snapshot)
for key, attempted_value in reversed(applied_updates):
svc.emit_config_changed(
key=key,
old_value=attempted_value,
new_value=original_values.get(key),
compensating=True,
reason="rollback_after_partial_server_connect_failure",
)
raise
result: dict[str, Any] = {
"server_url": config.server_url,
@@ -18,6 +18,7 @@ Based on:
from __future__ import annotations
import contextlib
from collections import deque
from collections.abc import Callable
@@ -172,5 +173,17 @@ class ReactiveEventBus:
"""
return self._stream
def close(self) -> None:
"""Signal completion on the reactive stream and clear all subscriptions.
Call this when the bus is no longer needed (e.g. in test teardown)
to release any RxPY Subject resources and prevent subscription leaks
between test scenarios.
"""
with contextlib.suppress(Exception):
self._subject.on_completed()
self._subscriptions.clear()
self._audit_log.clear()
__all__ = ["ReactiveEventBus"]