Skip to content

feat: improve local service reliability and test isolation#349

Merged
yyiilluu merged 5 commits into
ReflexioAI:mainfrom
wenchanghan:fix/bounded-service-respawn
Jul 16, 2026
Merged

feat: improve local service reliability and test isolation#349
yyiilluu merged 5 commits into
ReflexioAI:mainfrom
wenchanghan:fix/bounded-service-respawn

Conversation

@wenchanghan

@wenchanghan wenchanghan commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Improve local service reliability and keep the test runtime isolated from developer data.

Before

  • An unexpected child-service exit could leave the local stack degraded while the supervisor stayed alive.
  • Tests that exercised default SQLite-backed API storage could write generated learning data and debug logs into the developer's default ~/.reflexio directory.

After

  • The supervisor respawns unexpected child exits with bounded crash-loop handling, preserves intentional shutdown semantics, rolls back failed startup, and keeps readiness state and the pidfile accurate.
  • Tests load the existing provider environment while routing storage, config discovery, and debug logs to a temporary session directory. Existing E2E fixtures retain their per-test SQLite databases.

Behavior

  • Clean exits, supervisor shutdown, and explicit service-stop requests are not respawned.
  • A 30-second healthy window, 5 rapid failures, and a 2-second respawn delay bound recovery attempts; services that exceed the limit become degraded while sibling supervision continues.
  • Readiness waiting stops when a child exits, and the readiness callback runs at most once.
  • Respawn failures keep the supervisor alive as degraded child failures.
  • Supervision remains process-liveness-only; it does not add health probes or a durable daemon.
  • Policy tuning stays internal with no new public environment variables.

Validation

  • uv run ruff check reflexio/cli/utils.py tests/cli/test_utils.py
  • uv run pyright reflexio/cli/utils.py tests/cli/test_utils.py
  • uv run pytest tests/cli tests/server/api_endpoints/test_learning_status.py tests/server/services/storage/test_storage_defaults.py -o 'addopts=' — 347 passed
  • DEBUG_LOG_TO_CONSOLE=true uv run pytest tests/server/api_endpoints/test_learning_status.py -o 'addopts=' — 9 passed; production log mtime unchanged
  • uv run pytest -o 'addopts=' — 4,835 passed, 124 skipped, 10 failures because the local embedding daemon was unavailable at 127.0.0.1:8072; 2 warnings

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@wenchanghan, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 9 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 77d889e8-11fb-4408-b25a-eb4641059d57

📥 Commits

Reviewing files that changed from the base of the PR and between 7554cec and 599fa63.

📒 Files selected for processing (2)
  • tests/cli/test_utils.py
  • tests/server/services/storage/test_storage_defaults.py
📝 Walkthrough

Walkthrough

Changes

Service supervision is centralized in _ServiceSupervisor, adding readiness waiting, pidfile tracking, intentional-stop markers, exit classification, delayed respawns, and degraded-service handling. Tests add deterministic lifecycle coverage. Test bootstrap now isolates credentials, logging, and storage configuration.

Changes

Service supervision

Layer / File(s) Summary
Startup state and readiness
reflexio/cli/utils.py, tests/cli/test_utils.py
Adds supervisor state, timing controls, service startup, output streaming, pidfile updates, readiness waiting, and deterministic test scaffolding.
Exit handling and respawn policy
reflexio/cli/utils.py, tests/cli/test_utils.py
Handles intentional and unexpected exits, stop markers, rapid-failure degradation, failed respawns, shutdown, and sibling supervision.

Test environment isolation

Layer / File(s) Summary
Test bootstrap and storage validation
tests/conftest.py, tests/server/services/storage/test_storage_defaults.py, developer.md
Loads environment credentials before server imports, redirects test storage and logging to temporary locations, and verifies environment-driven storage resolution.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant run_services
  participant _ServiceSupervisor
  participant ServiceProcess
  participant Pidfile
  run_services->>_ServiceSupervisor: initialize and start services
  _ServiceSupervisor->>ServiceProcess: poll child processes
  ServiceProcess-->>_ServiceSupervisor: return exit code
  _ServiceSupervisor->>_ServiceSupervisor: classify exit and update failure state
  _ServiceSupervisor->>Pidfile: refresh active service PIDs
  _ServiceSupervisor->>ServiceProcess: respawn eligible service
Loading

Suggested reviewers: yyiilluu

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main themes of the changes: local service reliability and test isolation.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@wenchanghan
wenchanghan force-pushed the fix/bounded-service-respawn branch from 26ddace to be0223a Compare July 15, 2026 07:07
@wenchanghan
wenchanghan force-pushed the fix/bounded-service-respawn branch from be0223a to e621f64 Compare July 15, 2026 07:12
@wenchanghan
wenchanghan marked this pull request as ready for review July 15, 2026 07:16

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
reflexio/cli/utils.py (2)

259-269: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Docstring doesn't mention the new respawn/degradation behavior.

Worth a brief mention of the bounded-respawn policy (healthy window, rapid-failure limit, degraded marking) for future maintainers, since this materially changes run_services's runtime behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/cli/utils.py` around lines 259 - 269, Update the run_services
docstring to briefly document its bounded-respawn and degradation behavior,
including the healthy-window reset, rapid-failure limit, and marking services
degraded after the limit is reached. Keep the existing lifecycle and callback
documentation intact.

374-390: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Respawn delay blocks the monitor loop for other services.

time.sleep(_SERVICE_RESPAWN_DELAY_SECS) runs on the main monitoring thread inside handle_service_exit, so while one service is in its 2s respawn delay, proc.poll() isn't being checked for siblings until the next outer-loop pass. Bounded to 2s and mostly relevant only when multiple services fail near-simultaneously, so low real-world impact given the current design.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/cli/utils.py` around lines 374 - 390, The respawn delay in
handle_service_exit blocks monitoring of sibling services. Move the delayed
restart out of the main monitoring thread, such as by scheduling it
asynchronously, while preserving the shutting_down check and existing
respawn/error handling around start_service and write_current_pidfile.
tests/cli/test_utils.py (1)

108-133: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Missing pidfile-content assertion for respawn refresh.

This test confirms two processes get started but never checks the actual pidfile contents, despite "refresh the pidfile after successful respawn" being an explicit PR objective. Consider asserting utils.read_pidfile(pidfile) reflects the new PID after respawn.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/cli/test_utils.py` around lines 108 - 133, Update
test_run_services_respawns_unexpected_exit to retain the pidfile path used by
_patch_run_services_environment and assert via utils.read_pidfile that the
pidfile contains the respawned process’s PID after run_services completes, while
preserving the existing process-count and distinct-PID assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@reflexio/cli/utils.py`:
- Around line 282-296: Handle OSError and RuntimeError from subprocess.Popen
within the initial-start flow of run_services, ensuring a failed service does
not abort startup or leave previously started sibling processes unsupervised.
Reuse the existing cleanup and failure-handling behavior used by
handle_service_exit, and preserve the current respawn behavior while updating
the start_service/run_services interaction accordingly.
- Around line 402-413: The run_services startup flow blocks in
_wait_for_all_ready before monitoring child processes, delaying exit handling
and respawn. Update run_services and the readiness-monitoring logic so
process.poll() results are checked while waiting for readiness, and ensure the
on_all_ready callback still runs once when all services become ready; add
coverage in tests/cli/test_utils.py for this startup exit and callback path.

---

Nitpick comments:
In `@reflexio/cli/utils.py`:
- Around line 259-269: Update the run_services docstring to briefly document its
bounded-respawn and degradation behavior, including the healthy-window reset,
rapid-failure limit, and marking services degraded after the limit is reached.
Keep the existing lifecycle and callback documentation intact.
- Around line 374-390: The respawn delay in handle_service_exit blocks
monitoring of sibling services. Move the delayed restart out of the main
monitoring thread, such as by scheduling it asynchronously, while preserving the
shutting_down check and existing respawn/error handling around start_service and
write_current_pidfile.

In `@tests/cli/test_utils.py`:
- Around line 108-133: Update test_run_services_respawns_unexpected_exit to
retain the pidfile path used by _patch_run_services_environment and assert via
utils.read_pidfile that the pidfile contains the respawned process’s PID after
run_services completes, while preserving the existing process-count and
distinct-PID assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e93d6fa9-4bd1-4318-b47f-8ed0e3fb1a92

📥 Commits

Reviewing files that changed from the base of the PR and between 7d28379 and e621f64.

📒 Files selected for processing (2)
  • reflexio/cli/utils.py
  • tests/cli/test_utils.py

Comment thread reflexio/cli/utils.py Outdated
Comment thread reflexio/cli/utils.py Outdated
Keep default test storage and debug logs in a temporary directory while preserving provider-backed test behavior.
@wenchanghan wenchanghan changed the title feat: add bounded local service respawns feat: improve local service reliability and test isolation Jul 15, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
tests/server/services/storage/test_storage_defaults.py (1)

33-35: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prevent .env from overriding the isolated test environment during reload.

When server_module is reloaded, its module-level code executes load_reflexio_env(). Because patch.dict(..., clear=True) temporarily removes LOCAL_STORAGE_PATH from the environment, load_dotenv inside load_reflexio_env will read and re-inject it if the developer has LOCAL_STORAGE_PATH defined in their real ~/.reflexio/.env file. This causes the assertion on line 35 to fail locally for those developers.

Patch load_reflexio_env to be a no-op during the reload to maintain strict isolation.

🛠️ Proposed fix
     try:
         with patch.dict(os.environ, env, clear=True):
-            reloaded = importlib.reload(server_module)
-            assert expected == reloaded.LOCAL_STORAGE_PATH
+            with patch("reflexio.server.load_reflexio_env"):
+                reloaded = importlib.reload(server_module)
+                assert expected == reloaded.LOCAL_STORAGE_PATH
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/server/services/storage/test_storage_defaults.py` around lines 33 - 35,
Patch server_module.load_reflexio_env as a no-op within the patch.dict reload
context, before calling importlib.reload(server_module), so module
initialization cannot re-read the developer’s .env file. Keep the isolated
environment and existing LOCAL_STORAGE_PATH assertion unchanged.
tests/cli/test_utils.py (1)

44-58: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Test isolation broken by unmocked get_stop_request_path.

Writing test artifacts to a global, shared directory (tempfile.gettempdir()) breaks test isolation and can cause flakiness or state leakage, especially when parallelized with pytest-xdist (as the tests use the same hardcoded ports). The shared root cause is failing to mock get_stop_request_path to use the isolated tmp_path.

  • tests/cli/test_utils.py#L44-L58: In _patch_run_services_environment, patch get_stop_request_path to return an isolated path.
  • tests/cli/test_utils.py#L249-L274: In test_stop_services_writes_intent_before_killing_saved_pids, point stop_request_path to tmp_path and apply the same monkeypatch before the mock supervisor runs.
🛠️ Proposed fixes

For _patch_run_services_environment:

     monkeypatch.setattr(utils, "ensure_requested_ports_available", lambda _ports: None)
     monkeypatch.setattr(utils, "get_pidfile_path", lambda _ports: pidfile)
+    monkeypatch.setattr(
+        utils,
+        "get_stop_request_path",
+        lambda name, port: tmp_path / f"{name}_{port}.stop",
+    )
     monkeypatch.setattr(utils.signal, "signal", lambda *_args: None)

For test_stop_services_writes_intent_before_killing_saved_pids:

     pidfile.write_text('{"backend": 1234}')
     stop_requests: list[list[int]] = []
-    stop_request_path = utils.get_stop_request_path("backend", 8071)
+    stop_request_path = tmp_path / "backend_8071.stop"
     monkeypatch.setattr(utils, "get_pidfile_path", lambda _ports: pidfile)
+    monkeypatch.setattr(
+        utils,
+        "get_stop_request_path",
+        lambda name, port: stop_request_path,
+    )
     monkeypatch.setattr(utils, "find_pids_on_port", lambda _port: [])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/cli/test_utils.py` around lines 44 - 58, Update tests/cli/test_utils.py
lines 44-58 in _patch_run_services_environment to monkeypatch
utils.get_stop_request_path so it returns an isolated path under tmp_path. Also
update tests/cli/test_utils.py lines 249-274 in
test_stop_services_writes_intent_before_killing_saved_pids to set
stop_request_path under tmp_path and apply the same monkeypatch before starting
the mock supervisor.
🧹 Nitpick comments (1)
tests/cli/test_utils.py (1)

51-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use a nested function instead of __setitem__ for advancing the mock clock.

While mutating clock via __setitem__ inside a lambda functionally bypasses Python's scope restrictions, it is a less readable idiom. Using a standard nested helper function makes the intent explicitly clear.

♻️ Proposed refactor
     clock = [0.0]
     monkeypatch.setattr(utils.time, "monotonic", lambda: clock[0])
+
+    def advance_time(seconds: float) -> None:
+        clock[0] += seconds
+
     monkeypatch.setattr(
         utils.time,
         "sleep",
-        lambda seconds: clock.__setitem__(0, clock[0] + seconds),
+        advance_time,
     )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/cli/test_utils.py` around lines 51 - 57, Replace the lambda passed to
utils.time.sleep in the mock clock setup with a named nested helper function
that advances clock[0] by the requested seconds, then monkeypatch sleep with
that helper while preserving the existing monotonic behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/conftest.py`:
- Line 50: Update the test environment setup in tests/conftest.py to set
REFLEXIO_LOG_DIR to a path under _REFLEXIO_TEST_HOME, alongside
LOCAL_STORAGE_PATH. Ensure server-import file handlers resolve logs within the
isolated test directory rather than the developer’s real home directory.

---

Outside diff comments:
In `@tests/cli/test_utils.py`:
- Around line 44-58: Update tests/cli/test_utils.py lines 44-58 in
_patch_run_services_environment to monkeypatch utils.get_stop_request_path so it
returns an isolated path under tmp_path. Also update tests/cli/test_utils.py
lines 249-274 in test_stop_services_writes_intent_before_killing_saved_pids to
set stop_request_path under tmp_path and apply the same monkeypatch before
starting the mock supervisor.

In `@tests/server/services/storage/test_storage_defaults.py`:
- Around line 33-35: Patch server_module.load_reflexio_env as a no-op within the
patch.dict reload context, before calling importlib.reload(server_module), so
module initialization cannot re-read the developer’s .env file. Keep the
isolated environment and existing LOCAL_STORAGE_PATH assertion unchanged.

---

Nitpick comments:
In `@tests/cli/test_utils.py`:
- Around line 51-57: Replace the lambda passed to utils.time.sleep in the mock
clock setup with a named nested helper function that advances clock[0] by the
requested seconds, then monkeypatch sleep with that helper while preserving the
existing monotonic behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 902833b7-f5b4-451f-a13f-0baa553fe58c

📥 Commits

Reviewing files that changed from the base of the PR and between e621f64 and 7554cec.

📒 Files selected for processing (5)
  • developer.md
  • reflexio/cli/utils.py
  • tests/cli/test_utils.py
  • tests/conftest.py
  • tests/server/services/storage/test_storage_defaults.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • reflexio/cli/utils.py

Comment thread tests/conftest.py
Keep stop markers and environment reloads inside the test sandbox.
@yyiilluu
yyiilluu merged commit 497dcc1 into ReflexioAI:main Jul 16, 2026
1 check 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.

2 participants