Skip to content

reliability: fix audited lifecycle and state edge cases - #962

Open
yisding wants to merge 4 commits into
mainfrom
agent/bug-audit-followup
Open

reliability: fix audited lifecycle and state edge cases#962
yisding wants to merge 4 commits into
mainfrom
agent/bug-audit-followup

Conversation

@yisding

@yisding yisding commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Problem

A broad reliability audit found lifecycle races, dropped task failures, inconsistent state serialization, and persistence/validation edge cases that could surface under cancellation, shutdown, parallel execution, or malformed inputs.

Changes

  • harden runtime, server, session, STT, and transport task ownership and teardown retry behavior
  • preserve and serialize agent, journal, and turn state consistently across failure paths
  • improve telephony retry/classification, debugger safeguards, and latency validation boundaries
  • add focused regressions and refresh reviewed lifecycle/source ratchets

Impact

Shutdown remains bounded while retaining failed work for an explicit retry, background failures are observed instead of leaked, and persisted/validated state is deterministic across concurrent and degraded paths.

Validation

  • full credential-free suite: 9,195 passed, 209 skipped, 69 deselected
  • Ruff lint: passed
  • Ruff format check: passed
  • mypy: passed for 289 source files
  • ratchet suite: 48 passed
  • final diff, conflict-marker, and worktree integrity checks: passed

Summary by CodeRabbit

  • Bug Fixes

    • Improved session, audio, WebSocket, WebRTC, journal, and telephony shutdown reliability with bounded cleanup, retries, and clearer failure reporting.
    • Prevented stale call events, invalid audio formats, unsafe origins, malformed statistics, and incomplete validation from causing inconsistent behavior.
    • Preserved pending work and resources when cancellation or cleanup cannot finish immediately.
  • Security

    • Strengthened sensitive-data redaction in framework state and removed credential details from serialized snapshots and error responses.
  • Validation

    • Added stricter configuration, timeout, retry, latency, reliability, and agent-response validation.
  • New Features

    • Added safer waveform limits, improved turn admission controls, and support for pruning empty runtime scopes.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR hardens configuration validation, framework-state serialization, journal ownership, session and STT teardown, WebSocket cleanup, telephony transitions, transport recovery, and latency data validation. It adds retryable cleanup state and broad regression coverage.

Changes

Configuration and data safety

Layer / File(s) Summary
Configuration validation and copying
src/easycat/config/*, src/easycat/telephony/retry.py, src/easycat/timeouts.py, tests/config/*, tests/telephony/test_outbound_config.py, tests/telephony/test_retry_strategy.py
Mutable configuration is revalidated before runtime use. Nested configuration is copied. Retry delays handle overflow.
Agent state and response handling
src/easycat/integrations/agents/*, src/easycat/validation/redaction.py, tests/integrations/agents/*
Agent responses require strings. History is copied defensively. Framework state uses shared secret-safe serialization with fail-closed handling.

Lifecycle and transport handling

Layer / File(s) Summary
Journal and runtime ownership
src/easycat/runtime/journal_sql.py, src/easycat/runtime/scope.py, src/easycat/_concurrency.py, tests/runtime/*
Journal ownership remains active until physical close. Close operations support bounded waits and retries. Empty scopes can be pruned safely.
Server drain and WebSocket cleanup
src/easycat/server/transports.py, src/easycat/server/voice_server.py, src/easycat/debugger/server.py, tests/server/*, tests/debugger/*
Cleanup reports failures, retains incomplete work, consumes late task results, validates request origins, and limits waveform memory.
Session and STT lifecycle
src/easycat/session/*, src/easycat/session_manager.py, src/easycat/turn_manager.py, src/easycat/teardown_budgets.py, tests/session/*, tests/turns/*
STT cleanup, provider close, turn admission, artifact writes, and session stops use bounded ownership transfer and retry paths.
Telephony and transport state
src/easycat/telephony/call_state.py, src/easycat/telephony/voicemail.py, src/easycat/transports/*, tests/telephony/*, tests/transports/*
State transitions serialize observer activity. Transport cleanup preserves primary errors and retryable resources. WebRTC statistics reject non-finite output.

Validation and supporting coverage

Layer / File(s) Summary
Latency and reliability validation
src/easycat/validation/_latency_*, tests/validation/*
Malformed structures, invalid numeric values, unsupported fields, and non-object samples now raise explicit errors.
Regression and ratchet updates
tests/integration/*, tests/stages/*, tests/cli/*, tests/teaching/*, tests/ratchets/*
Tests and manifests reflect bounded cleanup, single STT stream termination, generator materialization, deterministic disk checks, and new lifecycle patterns.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Session
  participant STTCommitter
  participant RuntimeSupervisor
  participant Provider
  Session->>STTCommitter: cancel or end stream
  STTCommitter->>Provider: execute bounded provider operation
  STTCommitter->>RuntimeSupervisor: retain unfinished task
  RuntimeSupervisor->>STTCommitter: retry retained cleanup
  STTCommitter-->>Session: report cleanup status
Loading

Possibly related PRs

  • yisding/easycat#635: Both changes modify STT cancellation and provider cleanup lifecycle handling.
  • yisding/easycat#704: Both changes modify runtime scopes, session lifecycle, transport cleanup, serialization, and validation.
  • yisding/easycat#868: Both changes modify WebSocket and voice-server teardown failure handling.

Suggested reviewers: charliecreates

Poem

I’m a rabbit with a tidy nest,
Where closing tasks can safely rest.
Secrets hide, bad numbers flee,
Streams retry patiently.
With every cleanup fence in place,
The code hops onward at a steady pace. 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 51.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main focus on reliability fixes for lifecycle and state edge cases.
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.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent/bug-audit-followup

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.

@yisding
yisding marked this pull request as ready for review August 7, 2026 14:07
Comment thread src/easycat/transports/webrtc.py Fixed

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2b0659c7f3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/easycat/config/_factory.py Outdated
Comment on lines +329 to +332
if type(telephony) is not TelephonyConfig:
raise EasyConfigError("telephony must be a TelephonyConfig instance or None.")
outbound = telephony.outbound
if outbound is not None and type(outbound) is not OutboundCallConfig:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Accept telephony configuration subclasses during copying

When a caller supplies a subclass of TelephonyConfig or OutboundCallConfig, create_session() now rejects it before build validation, even though EasyConfig.validate_for_build() explicitly accepts subclasses with isinstance() and the previous copy path handled them. This breaks otherwise valid extensions that add telephony policy fields; use subclass-aware checks for both guards or validate before copying.

Useful? React with 👍 / 👎.

yisding and others added 3 commits August 7, 2026 22:18
Resolve ratchet manifest conflicts by recounting merged entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n errors

Address PR 962 review comments: the runtime-copy guard now matches
validate_for_build's isinstance semantics, and the WebRTC SDP failure
response no longer echoes exception details to the client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- bound the rejected-start STT cleanup join after caller cancellation so
  an unresponsive provider cannot make stop(force=True) uncancellable
- report cooperatively-cancelled listener waits as force-timeout failures
  and retain the listener for retry instead of discarding it
- validate bridge event text only for text-bearing kinds; duck-typed
  tool events may carry text=None
- restore substring credential-name filtering for the generic workflow
  __dict__ fallback snapshot (authtoken-style names leaked)
- let observer-spawned tasks transition after the owning call-state
  transition settles; only an active transition rejects them
- classify the new teardown budgets and refresh source ratchets

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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: 20

🤖 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 `@src/easycat/debugger/server.py`:
- Around line 705-708: Remove the duplicated “an” in the comment near the
state-changing request handling, so it reads “can pass an explicit same-origin
value.”

In `@src/easycat/integrations/agents/_agent_runner.py`:
- Around line 208-213: Update the validation branches in AgentRunnerConfig for
preemptive_generation and preemptive_max_retries so every line stays within
Ruff’s 99-character limit. Preserve the existing validation behavior and noqa
suppression while splitting the error statements or extracting their messages
into local constants.
- Around line 65-67: Update the text extraction in the event validation flow
around _require_agent_text() to use None or a sentinel instead of an
empty-string default when the event lacks text, ensuring text-bearing events
without a text attribute are rejected before forwarding.

In `@src/easycat/integrations/agents/_state_serialization.py`:
- Around line 74-77: Update _remove_secret_shaped_keys to canonicalize unordered
state before serialization: recursively scrub set items, sort them using a safe
canonical JSON key, and serialize scrubbed mappings with sort_keys=True so
equivalent states produce identical bytes. Preserve sequence handling, and add a
regression test covering a multi-item set with varying iteration order.

In `@src/easycat/runtime/scope.py`:
- Around line 584-591: Update prune_empty_child around
_close_admission_recursive so its contract reflects that a refused prune may
still close admission and leave the child subtree in CLOSING, or move the
emptiness check ahead of admission closure to preserve a no-side-effect False
result. Ensure the docstring and return behavior consistently describe whichever
contract is implemented.

In `@src/easycat/server/voice_server.py`:
- Around line 640-646: Guard the completed-task result in the sweep-success path
before calling _record_incomplete_hard_sweep: catch exceptions re-raised by
sweep_task.result(), append the exception to cleanup_errors, and pass None as
report when retrieval fails. Preserve the existing successful result behavior
and ensure _record_incomplete_hard_sweep still executes so
_finalize_stop_cleanup remains retry-safe.

In `@src/easycat/session/_stt_committer.py`:
- Line 244: Promote the repeated "stt_segment_commit" identifier to a class
constant alongside FINAL_CLOSE_TASK_NAME, PROVIDER_END_TASK_NAME, and
PROVIDER_CLOSE_TASK_NAME. Replace every occurrence in lifecycle task lookups,
cancellation targets, and journal payloads with that constant, including the
references in the relevant session methods.
- Around line 1016-1049: Bound the retry loop in
_finish_transferred_provider_close with an explicit attempt cap or wall-clock
deadline. When the bound is reached, stop retrying while preserving
_provider_close_error, allow the owned task to settle, and leave
_provider_close_pending consistent so retry_transferred_provider_close can
perform any later attempt.

In `@src/easycat/teardown_budgets.py`:
- Around line 39-44: Adjust the rejected-start STT cleanup timing constants used
by _cleanup_rejected_stt_start() so their combined join and cancel-grace
duration fits within SESSION_FORCE_START_LOCK_TIMEOUT_S when invoked from the
force-cancellation path, or consistently increase that force-path cap; preserve
bounded cleanup before the remaining stop path proceeds.

In `@src/easycat/telephony/call_state.py`:
- Around line 426-432: Move _transition_context out of
OutboundCallStateMachine.__init__ and define one module-level ContextVar shared
by all instances. Store both the owning state machine and task in its value,
updating all transition-context reads and writes to use that shared pair, while
retaining _active_transition_owner as the current task.

In `@src/easycat/telephony/retry.py`:
- Around line 128-135: Update get_delay() to handle a zero base_delay_s when
state.attempts is still zero before evaluating the exponentiation, returning the
defined zero delay (or otherwise applying the established pre-attempt contract).
Preserve the existing overflow and non-finite delay clamping for recorded
attempts.

In `@tests/debugger/test_server_replay_export.py`:
- Around line 211-215: Use the canonical _SAFE_HEADERS from
tests/debugger/_server_helpers.py across both modules: in
tests/debugger/test_server_replay_export.py, import it and replace the local
mappings at lines 193-197, 211-215, 246-250, 268-272, 301-305, 321-325, 352-356,
386-390, and 404-408; in tests/debugger/test_aec_diagnostics.py, remove the
module-level definition at lines 276-280 and import _SAFE_HEADERS instead.

In `@tests/debugger/test_server_rest_api.py`:
- Around line 523-547: Add a test for
test_api_audio_waveform_rejects_pcm_over_memory_limit where the raw audio blobs
remain within _WAVEFORM_MAX_PCM_BYTES but _coerce_frames_to_format expands the
coerced PCM beyond that limit. Assert the post-coercion request still returns
HTTP 413 with the existing WAVEFORM_AUDIO_TOO_LARGE payload, covering the guard
after format conversion rather than the raw accumulation guard.

In `@tests/server/test_capacity_gate_drain.py`:
- Around line 247-268: Strengthen
test_timed_safe_await_observes_cancelled_gather_result by directly verifying
that _observe_future_result was attached to the gathered future after
_safe_await returns. Inspect the future’s registered callbacks before deleting
gathered, while preserving the existing exception-handler assertion.

In `@tests/server/test_shutdown_draining.py`:
- Around line 411-415: Update the shutdown test around server.stop() to expect
and assert the intended cleanup exception for the cooperative cancellation path.
Remove the assertion that server._ws_server is None, while preserving assertions
that the hanging listener is closed and cleanup task tracking is cleared.

In `@tests/server/test_websocket_runtime_drain.py`:
- Around line 294-299: Update the assertions in the test around the retry flow
to inspect runtime._connection_cleanup_retry directly instead of using
runtime._connections as a proxy. Keep the existing setup and drain calls
unchanged, and assert the ledger’s expected state before and after the retry
cleanup.

In `@tests/session/test_stt_committer.py`:
- Around line 737-748: Update the test surrounding _HangingSTT and
committer.end_stream to drain the runtime/provider task scope after the timeout,
matching the cleanup pattern used by the sibling tests. Ensure the cleanup runs
in a finally block so the parked hanging task is released even if an assertion
fails, while preserving the existing assertions.

In `@tests/stages/test_stages.py`:
- Around line 1407-1427: Update
test_turn_stage_materializes_generator_input_once_for_detection to pass a
journal and artifact store using the existing test pattern, then assert the
stage_start artifact contains exactly chunk.data. Keep the existing detect-input
assertion and result checks so the test verifies both capture and detection
receive identical audio.

In `@tests/telephony/test_retry_strategy.py`:
- Around line 117-130: Add the return annotation -> None to both test function
definitions in this test module, including
test_backoff_overflow_caps_at_max_delay and the additional test around the
referenced second location; leave their bodies and behavior unchanged.

In `@tests/turns/test_turn_manager.py`:
- Around line 237-243: Set detector.release before calling manager.shutdown() in
the test cleanup flow, so the cancelled detect task can finish while shutdown
awaits replaced. Preserve the existing assertions and retain the finally block’s
cleanup and exception-gathering behavior.
🪄 Autofix

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: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 72ddc6af-7e4b-449a-87d4-6a5250e8d67a

📥 Commits

Reviewing files that changed from the base of the PR and between 5c22bb1 and 9ec2fd8.

📒 Files selected for processing (87)
  • src/easycat/_concurrency.py
  • src/easycat/config/_factory.py
  • src/easycat/config/easy.py
  • src/easycat/debugger/server.py
  • src/easycat/integrations/agents/_agent_runner.py
  • src/easycat/integrations/agents/_state_serialization.py
  • src/easycat/integrations/agents/generic_workflow.py
  • src/easycat/integrations/agents/langchain.py
  • src/easycat/integrations/agents/langgraph.py
  • src/easycat/integrations/agents/llama_agents.py
  • src/easycat/integrations/agents/openai_agents.py
  • src/easycat/integrations/agents/pydantic_ai.py
  • src/easycat/integrations/agents/responses_api.py
  • src/easycat/integrations/agents/template.py
  • src/easycat/runtime/journal_sql.py
  • src/easycat/runtime/scope.py
  • src/easycat/server/transports.py
  • src/easycat/server/voice_server.py
  • src/easycat/session/_audio_router.py
  • src/easycat/session/_debug_backends.py
  • src/easycat/session/_journal_sink.py
  • src/easycat/session/_session.py
  • src/easycat/session/_stt_committer.py
  • src/easycat/session/_turn_runner.py
  • src/easycat/session_manager.py
  • src/easycat/stages/turn.py
  • src/easycat/teardown_budgets.py
  • src/easycat/telephony/call_state.py
  • src/easycat/telephony/retry.py
  • src/easycat/telephony/voicemail.py
  • src/easycat/timeouts.py
  • src/easycat/transports/_webrtc_stats.py
  • src/easycat/transports/local.py
  • src/easycat/transports/webrtc.py
  • src/easycat/turn_manager.py
  • src/easycat/validation/_latency_artifacts.py
  • src/easycat/validation/_latency_baseline.py
  • src/easycat/validation/_latency_budgets.py
  • src/easycat/validation/_latency_models.py
  • src/easycat/validation/redaction.py
  • tests/cli/test_doctor.py
  • tests/config/test_session_creation.py
  • tests/core/test_timeouts.py
  • tests/debugger/_server_helpers.py
  • tests/debugger/test_aec_diagnostics.py
  • tests/debugger/test_server_replay_export.py
  • tests/debugger/test_server_rest_api.py
  • tests/debugger/test_server_route_controller.py
  • tests/debugger/test_server_security_origin.py
  • tests/integration/test_session_pipeline.py
  • tests/integrations/agents/test_agent_runner.py
  • tests/integrations/agents/test_bridge_template.py
  • tests/integrations/agents/test_generic_workflow_bridge.py
  • tests/integrations/agents/test_pydantic_ai_v2.py
  • tests/ratchets/pause-generation-manifest.json
  • tests/ratchets/source-baseline.json
  • tests/ratchets/teardown-budget-manifest.json
  • tests/ratchets/turn-commit-manifest.json
  • tests/ratchets/turn-lifecycle-manifest.json
  • tests/runtime/test_scope.py
  • tests/runtime/test_sqlite_journal.py
  • tests/server/test_capacity_gate_drain.py
  • tests/server/test_shutdown_draining.py
  • tests/server/test_voice_server_lifecycle.py
  • tests/server/test_websocket_runtime_drain.py
  • tests/session/test_audio_router.py
  • tests/session/test_journal_sink.py
  • tests/session/test_session_journal_accounting.py
  • tests/session/test_session_lifecycle_teardown.py
  • tests/session/test_session_manager.py
  • tests/session/test_session_stop_ordering.py
  • tests/session/test_session_streaming_behavior.py
  • tests/session/test_stt_committer.py
  • tests/session/test_turn_runner.py
  • tests/stages/test_stages.py
  • tests/teaching/test_chapter_15_doctor_contract.py
  • tests/telephony/test_call_state_basics.py
  • tests/telephony/test_call_state_gate.py
  • tests/telephony/test_outbound_config.py
  • tests/telephony/test_retry_strategy.py
  • tests/telephony/test_voicemail.py
  • tests/transports/test_local_transport.py
  • tests/transports/test_webrtc_lifecycle_server.py
  • tests/transports/test_webrtc_stats_artifacts.py
  • tests/turns/test_turn_manager.py
  • tests/validation/test_latency_boundaries.py
  • tests/validation/test_latency_percentiles.py

Comment on lines 705 to +708
# On state-changing requests, a missing Origin from a
# browser is suspicious — refuse rather than trust the
# caller blindly. Server-to-server clients can pass an
# explicit ``Origin: http://localhost`` or use
# ``allow_remote``.
# an explicit same-origin value or use ``allow_remote``.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the duplicated article in the comment.

Line 708 reads "can pass an / an explicit same-origin value". The word an is repeated across the line break.

📝 Proposed fix
             # On state-changing requests, a missing Origin from a
             # browser is suspicious — refuse rather than trust the
             # caller blindly.  Server-to-server clients can pass an
-            # an explicit same-origin value or use ``allow_remote``.
+            # explicit same-origin value or use ``allow_remote``.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# On state-changing requests, a missing Origin from a
# browser is suspicious — refuse rather than trust the
# caller blindly. Server-to-server clients can pass an
# explicit ``Origin: http://localhost`` or use
# ``allow_remote``.
# an explicit same-origin value or use ``allow_remote``.
# On state-changing requests, a missing Origin from a
# browser is suspicious — refuse rather than trust the
# caller blindly. Server-to-server clients can pass an
# explicit same-origin value or use ``allow_remote``.
🤖 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 `@src/easycat/debugger/server.py` around lines 705 - 708, Remove the duplicated
“an” in the comment near the state-changing request handling, so it reads “can
pass an explicit same-origin value.”

Comment on lines +65 to +67
getattr(event, "text", ""),
source=f"agent bridge {kind} event text",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject text-bearing events that omit text.

Line 65 converts a missing text attribute into "". The malformed event then passes validation and is forwarded without a text attribute. Downstream consumers can fail when they read event.text.

Use None or a sentinel as the default so _require_agent_text() rejects the event.

Proposed fix
-        getattr(event, "text", ""),
+        getattr(event, "text", None),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
getattr(event, "text", ""),
source=f"agent bridge {kind} event text",
)
getattr(event, "text", None),
source=f"agent bridge {kind} event text",
)
🤖 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 `@src/easycat/integrations/agents/_agent_runner.py` around lines 65 - 67,
Update the text extraction in the event validation flow around
_require_agent_text() to use None or a sentinel instead of an empty-string
default when the event lacks text, ensuring text-bearing events without a text
attribute are rejected before forwarding.

Comment on lines +208 to 213
if not isinstance(self.preemptive_generation, bool):
raise ValueError("AgentRunnerConfig.preemptive_generation must be a boolean") # noqa: TRY004 domain-specific validation error
if isinstance(self.preemptive_max_retries, bool) or not isinstance(
self.preemptive_max_retries, int
):
raise ValueError("AgentRunnerConfig.preemptive_max_retries must be an integer") # noqa: TRY004 domain-specific validation error

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep validation lines within the configured limit.

Lines 209 and 213 exceed 99 characters because of the inline noqa comments. Split the error messages or extract them into local constants.

As per coding guidelines, **/*.py must “keep lines within Ruff's configured 99-character limit.”

🤖 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 `@src/easycat/integrations/agents/_agent_runner.py` around lines 208 - 213,
Update the validation branches in AgentRunnerConfig for preemptive_generation
and preemptive_max_retries so every line stays within Ruff’s 99-character limit.
Preserve the existing validation behavior and noqa suppression while splitting
the error statements or extracting their messages into local constants.

Source: Coding guidelines

Comment on lines +74 to +77
if isinstance(value, AbstractSet):
return [_remove_secret_shaped_keys(item) for item in value]
if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
return [_remove_secret_shaped_keys(item) for item in value]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Canonicalize unordered framework state before encoding.

AbstractSet iteration order is unspecified. The direct list conversion can produce different artifact bytes for the same logical state. Mapping insertion order can also vary by state producer.

Sort normalized set items with a safe canonical JSON key. Encode scrubbed mappings with sort_keys=True. Add a regression test with a multi-item set.

Proposed fix
+def _canonical_json(value: Any) -> str:
+    return json.dumps(
+        value,
+        default=_redacted_string,
+        ensure_ascii=False,
+        allow_nan=False,
+        sort_keys=True,
+    )
+
 def _remove_secret_shaped_keys(value: Any) -> Any:
     ...
     if isinstance(value, AbstractSet):
-        return [_remove_secret_shaped_keys(item) for item in value]
+        items = [_remove_secret_shaped_keys(item) for item in value]
+        return sorted(items, key=_canonical_json)
     ...
-        return json.dumps(
-            scrubbed,
-            default=_redacted_string,
-            ensure_ascii=False,
-            allow_nan=False,
-        ).encode("utf-8")
+        return _canonical_json(scrubbed).encode("utf-8")

Also applies to: 108-113

🤖 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 `@src/easycat/integrations/agents/_state_serialization.py` around lines 74 -
77, Update _remove_secret_shaped_keys to canonicalize unordered state before
serialization: recursively scrub set items, sort them using a safe canonical
JSON key, and serialize scrubbed mappings with sort_keys=True so equivalent
states produce identical bytes. Preserve sequence handling, and add a regression
test covering a multi-item set with varying iteration order.

Comment on lines +584 to +591
child._close_admission_recursive()
if (
child.tasks()
or child.children()
or child._pending_finalizer_names()
or child.terminal_results()
):
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document that a refused prune still closes the child's admission.

_close_admission_recursive() runs before the emptiness check. When the check fails and the method returns False, the child subtree stays in CLOSING and survivor_registry.close_owner() has already run for every scope in it. The child can no longer admit tasks, finalizers, or sub-children after that point.

The current callers tolerate this: STTCommitter._provider_operation_scope only prunes scopes it has already marked retired, and _prune_provider_operation_scope guards with scope.empty first. The docstring and the False return still read as "no change was made", so a future caller can use prune_empty_child as a probe and silently disable a live child scope.

Either state the side effect in the docstring or move the emptiness check before the admission closure.

♻️ Option: check emptiness before closing admission
     def prune_empty_child(self, child: RuntimeScope) -> bool:
-        """Retire and unlink one settled direct child without reopening its owner."""
+        """Retire and unlink one settled direct child without reopening its owner.
+
+        A child that is not settled is left registered, but its admission is
+        already closed by this call.
+        """
         if child.parent is not self:
             raise ValueError("RuntimeScope child does not belong to this parent")
         child._close_admission_recursive()
🤖 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 `@src/easycat/runtime/scope.py` around lines 584 - 591, Update
prune_empty_child around _close_admission_recursive so its contract reflects
that a refused prune may still close admission and leave the child subtree in
CLOSING, or move the emptiness check ahead of admission closure to preserve a
no-side-effect False result. Ensure the docstring and return behavior
consistently describe whichever contract is implemented.

Comment on lines +294 to +299
assert runtime._connections == {key: connection}

connection.fail_close = False
await runtime.drain(server, drain_timeout_s=0.0, force_timeout_s=1.0)

assert runtime._connections == {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the retry ledger directly.

Line 294 and line 299 check runtime._connections, which is an indirect proxy for the retry state this test targets. test_force_timeout_is_shared_across_all_runtime_cleanup_steps already asserts runtime._connection_cleanup_retry directly at lines 577 and 590. Use the same assertion here so a regression that leaves the ledger populated is caught.

♻️ Proposed refactor
     assert "WebSocket connection close task" in caplog.text
     assert "retryable connection failure" in caplog.text
     assert runtime._connections == {key: connection}
+    assert runtime._connection_cleanup_retry == {key: connection}
 
     connection.fail_close = False
     await runtime.drain(server, drain_timeout_s=0.0, force_timeout_s=1.0)
 
     assert runtime._connections == {}
+    assert runtime._connection_cleanup_retry == {}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
assert runtime._connections == {key: connection}
connection.fail_close = False
await runtime.drain(server, drain_timeout_s=0.0, force_timeout_s=1.0)
assert runtime._connections == {}
assert runtime._connections == {key: connection}
assert runtime._connection_cleanup_retry == {key: connection}
connection.fail_close = False
await runtime.drain(server, drain_timeout_s=0.0, force_timeout_s=1.0)
assert runtime._connections == {}
assert runtime._connection_cleanup_retry == {}
🤖 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/test_websocket_runtime_drain.py` around lines 294 - 299, Update
the assertions in the test around the retry flow to inspect
runtime._connection_cleanup_retry directly instead of using runtime._connections
as a proxy. Keep the existing setup and drain calls unchanged, and assert the
ledger’s expected state before and after the retry cleanup.

Comment on lines 737 to +748
stt = _HangingSTT()
no_turn = TurnContext("no-turn", CancelToken())
tm = TurnManager(bus, config=TurnManagerConfig())
committer = STTCommitter(
wiring=make_wiring(stt=lambda: stt, emit=_emit),
event_bus=bus,
journal_sink=sink,
runtime_scope=RuntimeScope(),
committer, _stt, emitted, _no_turn, _tm = _make_committer(
stt=stt,
timeout_config=TimeoutConfig(stt_timeout=0.01),
segment_silence_ms=0,
no_turn=no_turn,
turn_manager=tm,
)
turn = _new_turn()

await committer.end_stream(turn)

assert stt.end_stream_calls == 1
assert turn.pending_stt_segment_futures == []
stt_errors = [e for e in errors if e.stage == ErrorStage.STT]
stt_errors = [e for e in emitted if isinstance(e, Error) and e.stage == ErrorStage.STT]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Release the hanging provider task at the end of this test.

_HangingSTT.end_stream awaits asyncio.Event().wait() and never returns. end_stream times out and parks that owned task. The test then finishes without draining it.

The task stays pending until the event loop closes. That produces a "Task was destroyed but it is pending" warning and holds the survivor reservation for the rest of the loop's life. The sibling tests added in this file already handle this: Line 640 drains the runtime scope and Line 430 drains the provider-error scope in a finally.

Add the same cleanup so the test does not leak a pending task into later tests on the same loop.

💚 Proposed cleanup
     turn = _new_turn()
 
-    await committer.end_stream(turn)
-
-    assert stt.end_stream_calls == 1
-    assert turn.pending_stt_segment_futures == []
-    stt_errors = [e for e in emitted if isinstance(e, Error) and e.stage == ErrorStage.STT]
-    assert stt_errors
-    assert stt_errors[0].provider == "openai"
+    try:
+        await committer.end_stream(turn)
+
+        assert stt.end_stream_calls == 1
+        assert turn.pending_stt_segment_futures == []
+        stt_errors = [e for e in emitted if isinstance(e, Error) and e.stage == ErrorStage.STT]
+        assert stt_errors
+        assert stt_errors[0].provider == "openai"
+    finally:
+        await committer._runtime_scope.cancel_and_drain()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
stt = _HangingSTT()
no_turn = TurnContext("no-turn", CancelToken())
tm = TurnManager(bus, config=TurnManagerConfig())
committer = STTCommitter(
wiring=make_wiring(stt=lambda: stt, emit=_emit),
event_bus=bus,
journal_sink=sink,
runtime_scope=RuntimeScope(),
committer, _stt, emitted, _no_turn, _tm = _make_committer(
stt=stt,
timeout_config=TimeoutConfig(stt_timeout=0.01),
segment_silence_ms=0,
no_turn=no_turn,
turn_manager=tm,
)
turn = _new_turn()
await committer.end_stream(turn)
assert stt.end_stream_calls == 1
assert turn.pending_stt_segment_futures == []
stt_errors = [e for e in errors if e.stage == ErrorStage.STT]
stt_errors = [e for e in emitted if isinstance(e, Error) and e.stage == ErrorStage.STT]
stt = _HangingSTT()
committer, _stt, emitted, _no_turn, _tm = _make_committer(
stt=stt,
timeout_config=TimeoutConfig(stt_timeout=0.01),
)
turn = _new_turn()
try:
await committer.end_stream(turn)
assert stt.end_stream_calls == 1
assert turn.pending_stt_segment_futures == []
stt_errors = [e for e in emitted if isinstance(e, Error) and e.stage == ErrorStage.STT]
assert stt_errors
assert stt_errors[0].provider == "openai"
finally:
await committer._runtime_scope.cancel_and_drain()
🤖 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/session/test_stt_committer.py` around lines 737 - 748, Update the test
surrounding _HangingSTT and committer.end_stream to drain the runtime/provider
task scope after the timeout, matching the cleanup pattern used by the sibling
tests. Ensure the cleanup runs in a finally block so the parked hanging task is
released even if an assertion fails, while preserving the existing assertions.

Comment on lines +1407 to +1427
async def test_turn_stage_materializes_generator_input_once_for_detection(self):
chunk = AudioChunk(data=b"\x01\x02", format=PCM16_MONO_16K)
seen: list[AudioChunk] = []

class _CapturingSmartTurn:
async def detect(self, audio_chunks):
seen.extend(audio_chunks)
return {"prediction": 1, "probability": 0.95}

def audio_window():
yield chunk

result = await TurnStage(_CapturingSmartTurn()).execute(
audio_window(),
_make_ctx(),
_make_turn(),
)

assert result["prediction"] == 1
assert seen == [chunk]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Also assert the captured artifact bytes.

The test pins one half of the fix: detect receives the materialized chunk. The docstring of _materialize_one_shot_iterable states the full contract — "capture and detection see identical audio". The capture half is not asserted here, so a regression that materializes for detect but drains the generator before _concat_chunks would still pass.

Pass a journal and an artifact store, then assert the stage_start artifact equals chunk.data. This file already uses that pattern at Line 1259-1261.

💚 Proposed additional assertion
+        journal = InMemoryRingBuffer(capacity=100)
+        artifacts = InMemoryArtifactStore()
         result = await TurnStage(_CapturingSmartTurn()).execute(
             audio_window(),
-            _make_ctx(),
+            _make_ctx(journal=journal, artifact_store=artifacts),
             _make_turn(),
         )
 
         assert result["prediction"] == 1
         assert seen == [chunk]
+        start = next(record for record in journal.read() if record.name == "stage_start")
+        assert start.input_ref is not None
+        assert artifacts.get(start.input_ref) == chunk.data
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def test_turn_stage_materializes_generator_input_once_for_detection(self):
chunk = AudioChunk(data=b"\x01\x02", format=PCM16_MONO_16K)
seen: list[AudioChunk] = []
class _CapturingSmartTurn:
async def detect(self, audio_chunks):
seen.extend(audio_chunks)
return {"prediction": 1, "probability": 0.95}
def audio_window():
yield chunk
result = await TurnStage(_CapturingSmartTurn()).execute(
audio_window(),
_make_ctx(),
_make_turn(),
)
assert result["prediction"] == 1
assert seen == [chunk]
async def test_turn_stage_materializes_generator_input_once_for_detection(self):
chunk = AudioChunk(data=b"\x01\x02", format=PCM16_MONO_16K)
seen: list[AudioChunk] = []
class _CapturingSmartTurn:
async def detect(self, audio_chunks):
seen.extend(audio_chunks)
return {"prediction": 1, "probability": 0.95}
def audio_window():
yield chunk
journal = InMemoryRingBuffer(capacity=100)
artifacts = InMemoryArtifactStore()
result = await TurnStage(_CapturingSmartTurn()).execute(
audio_window(),
_make_ctx(journal=journal, artifact_store=artifacts),
_make_turn(),
)
assert result["prediction"] == 1
assert seen == [chunk]
start = next(record for record in journal.read() if record.name == "stage_start")
assert start.input_ref is not None
assert artifacts.get(start.input_ref) == chunk.data
🤖 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/stages/test_stages.py` around lines 1407 - 1427, Update
test_turn_stage_materializes_generator_input_once_for_detection to pass a
journal and artifact store using the existing test pattern, then assert the
stage_start artifact contains exactly chunk.data. Keep the existing detect-input
assertion and result checks so the test verifies both capture and detection
receive identical audio.

Comment on lines +117 to +130
def test_backoff_overflow_caps_at_max_delay(self) -> None:
config = RetryStrategyConfig(
max_retries=5,
sms_fallback_after=5,
base_delay_s=1.0,
backoff_factor=1e308,
max_delay_s=10.0,
jitter_fraction=0.0,
)
strategy = RetryStrategy(config)
for _ in range(3):
strategy.record_attempt("+1555", "other")

assert strategy.get_delay("+1555") == 10.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add return annotations to the test functions.

Add -> None to both test definitions. As per coding guidelines, "**/*.py: Use Python 3.11 or later with typing-first code."

Also applies to: 160-165

🤖 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/telephony/test_retry_strategy.py` around lines 117 - 130, Add the
return annotation -> None to both test function definitions in this test module,
including test_backoff_overflow_caps_at_max_delay and the additional test around
the referenced second location; leave their bodies and behavior unchanged.

Source: Coding guidelines

Comment on lines +237 to +243
try:
await manager.shutdown()
assert replaced.done()
assert manager._silence_timer_tasks == set()
finally:
detector.release.set()
await asyncio.gather(replaced, return_exceptions=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Release the detector before awaiting shutdown.

manager.shutdown() waits for replaced to finish. detect() waits for detector.release after cancellation. The test sets that event only after shutdown() returns. The test therefore hangs.

Proposed fix
     try:
-        await manager.shutdown()
+        shutdown = asyncio.create_task(manager.shutdown())
+        await asyncio.sleep(0)
+        assert not shutdown.done()
+        detector.release.set()
+        await asyncio.wait_for(shutdown, timeout=1)
         assert replaced.done()
         assert manager._silence_timer_tasks == set()
     finally:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
await manager.shutdown()
assert replaced.done()
assert manager._silence_timer_tasks == set()
finally:
detector.release.set()
await asyncio.gather(replaced, return_exceptions=True)
try:
shutdown = asyncio.create_task(manager.shutdown())
await asyncio.sleep(0)
assert not shutdown.done()
detector.release.set()
await asyncio.wait_for(shutdown, timeout=1)
assert replaced.done()
assert manager._silence_timer_tasks == set()
finally:
detector.release.set()
await asyncio.gather(replaced, return_exceptions=True)
🤖 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/turns/test_turn_manager.py` around lines 237 - 243, Set
detector.release before calling manager.shutdown() in the test cleanup flow, so
the cancelled detect task can finish while shutdown awaits replaced. Preserve
the existing assertions and retain the finally block’s cleanup and
exception-gathering behavior.

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