Skip to content

Fix script edit retry duplication during reload#792

Merged
dsarno merged 1 commit intoCoplayDev:betafrom
dsarno:fix/script-apply-edits-retry-duplication
Feb 20, 2026
Merged

Fix script edit retry duplication during reload#792
dsarno merged 1 commit intoCoplayDev:betafrom
dsarno:fix/script-apply-edits-retry-duplication

Conversation

@dsarno
Copy link
Collaborator

@dsarno dsarno commented Feb 20, 2026

Summary

  • Script edit tools (script_apply_edits, apply_text_edits, create_script, delete_script) were retrying non-idempotent commands up to 40 times when Unity entered domain reload, causing duplicated methods/edits
  • Added retry_on_reload=False to all script-mutating send_with_unity_instance() calls — the edit lands on disk before the reload triggers, so retrying is unnecessary and harmful
  • Read-only operations (validate_script, get_sha, read) are unchanged and still retry as expected

Test plan

  • All 685 Python unit tests pass
  • Smoke tested create_script against live Unity — no duplication
  • Smoke tested script_apply_edits with insert_method — method inserted exactly once
  • Smoke tested apply_text_edits — edit applied exactly once
  • Smoke tested delete_script — clean deletion
  • No compilation errors after any operation

Fixes #790

🤖 Generated with Claude Code

Summary by Sourcery

Prevent non-idempotent Unity script mutation operations from being retried across domain reloads to avoid duplicate edits and creations.

Bug Fixes:

  • Disable retry-on-reload for script mutation calls (create, delete, and apply edits) so they are executed exactly once even when Unity reloads the domain.
  • Restrict retry-on-reload behavior in the generic manage_script entry point to read-only operations, preventing duplicate side effects during reloads.

Summary by CodeRabbit

  • Refactor
    • Streamlined background processing by switching to asyncio-based tasks for delayed actions and fire-and-forget work, improving reliability and responsiveness.
    • Standardized retry behavior for script operations: read actions may retry during reloads, while other actions avoid automatic reload retries to reduce unnecessary retries and improve stability.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Feb 20, 2026

Reviewer's guide (collapsed on small PRs)

Reviewer's Guide

Disables automatic retry-on-reload for non-idempotent script mutation tool calls to Unity, ensuring script edits, creations, and deletions are sent exactly once while preserving retries for read-only operations.

Sequence diagram for script mutation calls without retry on reload

sequenceDiagram
    actor Developer
    participant ScriptTool as ScriptMutationTool
    participant Sender as send_with_unity_instance
    participant Unity as UnityEditor

    Developer->>ScriptTool: Request script change
    ScriptTool->>Sender: manage_script(params, retry_on_reload=False)
    Sender->>Unity: manage_script(params)
    Unity-->>Sender: Response(success or failure)
    Sender-->>ScriptTool: Single response
    ScriptTool-->>Developer: Report result

    rect rgb(235,235,235)
        Unity->>Unity: Domain reload may occur
        Note over Unity: No retry of mutation
    end
Loading

Sequence diagram for read-only script operations with retry on reload

sequenceDiagram
    actor Developer
    participant ReadTool as ReadOnlyScriptTool
    participant Sender as send_with_unity_instance
    participant Unity as UnityEditor

    Developer->>ReadTool: Request script read/validate/get_sha
    ReadTool->>Sender: manage_script(params, retry_on_reload=True)
    loop Until success or max retries
        Sender->>Unity: manage_script(params)
        Unity-->>Sender: Response or domain reload
        alt Domain reload detected
            Sender->>Sender: Wait for Unity to come back
        else Successful response
            Sender->>Sender: Break loop
        end
    end
    Sender-->>ReadTool: Final response
    ReadTool-->>Developer: Return data/result
Loading

Flow diagram for retry_on_reload selection in manage_script

flowchart TD
    A[manage_script called] --> B{action value}
    B -->|read| C[Call send_with_unity_instance
with retry_on_reload True]
    B -->|not read: create, delete, apply_edits, etc.| D[Call send_with_unity_instance
with retry_on_reload False]
    C --> E[Read-only operation retries
across Unity domain reload]
    D --> F[Mutation operation sent once
no retry on reload]
Loading

File-Level Changes

Change Details Files
Disable retry-on-reload for all script mutation calls in script_apply_edits tool.
  • Pass retry_on_reload=False to send_with_unity_instance when applying structured script edits.
  • Pass retry_on_reload=False to send_with_unity_instance when applying text-based script edits.
  • Pass retry_on_reload=False to send_with_unity_instance for various manage_script edit paths so edits are not duplicated during domain reloads.
Server/src/services/tools/script_apply_edits.py
Ensure manage_script helper functions only retry read-only operations on reload while mutation operations execute once.
  • Pass retry_on_reload=False to send_with_unity_instance in manage_script helper that writes script changes.
  • Update create_script to pass retry_on_reload=False so creations are not retried during reload.
  • Update delete_script to pass retry_on_reload=False so deletions are not retried during reload.
  • In manage_script, set retry_on_reload based on action=='read' so only read operations are retried on reload.
Server/src/services/tools/manage_script.py

Assessment against linked issues

Issue Objective Addressed Explanation
#790 Update the retry logic for script_apply_edits so that non-idempotent write operations (e.g., insert_method) are not retried on Unity domain reload, preventing duplicate edits from being applied.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 20, 2026

📝 Walkthrough

Walkthrough

Adds explicit retry_on_reload=False for write/manage_script Unity transport calls and replaces threading/time.sleep with asyncio-based background tasks; read operations keep retry_on_reload=True. This prevents automatic retries of non-idempotent edits during Unity domain reloads.

Changes

Cohort / File(s) Summary
Manage script operations
Server/src/services/tools/manage_script.py
Introduce module-level _background_tasks, replace threading/time.sleep with asyncio equivalents and asyncio.create_task(...), add done-callback to discard completed tasks, and set retry_on_reload=(action == "read") for Unity transport calls.
Script edit application
Server/src/services/tools/script_apply_edits.py
Add retry_on_reload=False to multiple Unity transport/manage_script invocations across error handling, expansion, and write/apply paths to avoid retrying non-idempotent write operations during Unity reloads.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Client
participant Server
participant Unity
note over Client,Unity: Old flow (before change)
Client->>Server: apply_text_edits (insert_method)
Server->>Unity: manage_script (write) [retry_on_reload=default]
Unity-->>Server: "Unity is reloading; please retry"
Server->>Unity: retry manage_script (resend same write)
Unity-->>Server: ack (duplicate edits applied)
Server-->>Client: final response (duplicates possible)

mermaid
sequenceDiagram
participant Client
participant Server
participant Unity
note over Client,Unity: New flow (after change)
Client->>Server: apply_text_edits (insert_method)
Server->>Unity: manage_script (write) [retry_on_reload=False]
Unity-->>Server: "Unity is reloading; please retry"
Server->>Server: do not retry write (treat as completed/stop)
Server-->>Client: final response (no duplicate edits)

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰
I hopped through queues at break of day,
Replaced the threads with lighter play.
No echoes now of copies spawned,
One gentle task — the duplicates gone. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% 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
Title check ✅ Passed The title clearly and concisely summarizes the main change: fixing duplicate script edits that occur during domain reload by disabling retries.
Description check ✅ Passed The description provides a clear summary, test results, and issue reference, but does not follow the template structure with explicit sections for Type of Change, Changes Made, and Documentation Updates.
Linked Issues check ✅ Passed The PR successfully addresses issue #790 by disabling retry-on-reload for all script-mutating operations while preserving retries for read-only operations, meeting all stated objectives.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the retry duplication bug: adding retry_on_reload parameters to mutation calls and refactoring async task tracking, with no unrelated modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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 and usage tips.

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've left some high level feedback:

  • Consider centralizing the retry_on_reload decision (e.g., in manage_script or a small helper) so that all script-mutating calls share a single source of truth instead of repeating retry_on_reload=False across multiple call sites.
  • It may be safer to make retry_on_reload default to False at the send_with_unity_instance/manage_script boundary and explicitly opt in for read-only actions, to reduce the risk of future mutating actions accidentally inheriting retry-on-reload behavior.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider centralizing the `retry_on_reload` decision (e.g., in `manage_script` or a small helper) so that all script-mutating calls share a single source of truth instead of repeating `retry_on_reload=False` across multiple call sites.
- It may be safer to make `retry_on_reload` default to `False` at the `send_with_unity_instance`/`manage_script` boundary and explicitly opt in for read-only actions, to reduce the risk of future mutating actions accidentally inheriting retry-on-reload behavior.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (1)
Server/src/services/tools/manage_script.py (1)

356-371: ⚠️ Potential issue | 🟡 Minor

_flip_async is an async def used as a threading.Thread target — the coroutine is never executed.

threading.Thread(target=_flip_async, daemon=True).start() calls _flip_async() in the new thread, which only creates a coroutine object; Python doesn't execute it, it just creates a coroutine object. Neither time.sleep(0.1) nor the await async_send_command_with_retry(...) ever runs, so force_sentinel_reload is silently a no-op.

The fix is to run the coroutine in the existing event loop from the new thread using asyncio.run_coroutine_threadsafe:

🛠️ Suggested fix
-threading.Thread(target=_flip_async, daemon=True).start()
+import asyncio
+loop = asyncio.get_event_loop()
+asyncio.run_coroutine_threadsafe(_flip_async(), loop)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Server/src/services/tools/manage_script.py` around lines 356 - 371, The
_flip_async coroutine is never executed because
threading.Thread(target=_flip_async, ...) only creates the coroutine object;
replace this pattern by scheduling the coroutine on the running event loop with
asyncio.run_coroutine_threadsafe. Specifically, make _flip_async use await
asyncio.sleep(0.1) instead of time.sleep, keep the try/except around the await
of transport.legacy.unity_connection.async_send_command_with_retry(...), and
start it from the thread by calling
asyncio.run_coroutine_threadsafe(_flip_async(), loop) where loop is the
application event loop (capture/passed from the main thread) instead of
threading.Thread(...).start(), so the coroutine actually runs and the
async_send_command_with_retry executes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@Server/src/services/tools/manage_script.py`:
- Around line 356-371: The _flip_async coroutine is never executed because
threading.Thread(target=_flip_async, ...) only creates the coroutine object;
replace this pattern by scheduling the coroutine on the running event loop with
asyncio.run_coroutine_threadsafe. Specifically, make _flip_async use await
asyncio.sleep(0.1) instead of time.sleep, keep the try/except around the await
of transport.legacy.unity_connection.async_send_command_with_retry(...), and
start it from the thread by calling
asyncio.run_coroutine_threadsafe(_flip_async(), loop) where loop is the
application event loop (capture/passed from the main thread) instead of
threading.Thread(...).start(), so the coroutine actually runs and the
async_send_command_with_retry executes.

@dsarno dsarno force-pushed the fix/script-apply-edits-retry-duplication branch from af31636 to cfbdd3a Compare February 20, 2026 05:17
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
Server/src/services/tools/manage_script.py (1)

339-342: import os is redundant; move standard-library imports to module level.

os is already imported at the module top (line 2); the re-import at line 342 is dead code. asyncio, json, and glob are standard-library modules and should also be hoisted to module-level imports rather than buried inside a conditional try block.

♻️ Proposed refactor

At the top of the file, add the missing module-level imports:

 import base64
 import os
+import asyncio
+import glob
+import json
 from typing import Annotated, Any, Literal

Then remove the inline imports:

         try:
-                import asyncio
-                import json
-                import glob
-                import os
-
             def _latest_status() -> dict | None:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Server/src/services/tools/manage_script.py` around lines 339 - 342, Remove
the inline standard-library imports in manage_script.py and hoist them to the
module level: add "import asyncio", "import json", and "import glob" to the
top-of-file imports (where "os" is already imported) and delete the inline block
that currently does "import asyncio, json, glob, os"; also remove the redundant
re-import of "os" so only the top-level "os" import remains.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Server/src/services/tools/manage_script.py`:
- Line 370: The call to asyncio.create_task(_flip_async()) can be
garbage-collected because no strong reference is kept; retain a strong reference
to the Task so it isn't dropped (e.g., assign the returned Task to an attribute
or append it to a persistent list/set), and optionally attach a done callback to
log exceptions; update the code that creates the task to store the Task
(reference the asyncio.create_task call and the _flip_async coroutine) and
ensure cleanup after completion.

---

Nitpick comments:
In `@Server/src/services/tools/manage_script.py`:
- Around line 339-342: Remove the inline standard-library imports in
manage_script.py and hoist them to the module level: add "import asyncio",
"import json", and "import glob" to the top-of-file imports (where "os" is
already imported) and delete the inline block that currently does "import
asyncio, json, glob, os"; also remove the redundant re-import of "os" so only
the top-level "os" import remains.

When Unity enters domain reload after a script edit, the retry loop in
send_command_with_retry would re-send the identical edit command up to
40 times, duplicating insert_method/anchor_insert edits. Pass
retry_on_reload=False on all script-mutating send calls since the edit
lands on disk before the reload triggers.

Also fix _flip_async coroutine in apply_text_edits that was passed as a
threading.Thread target — the coroutine was never awaited. Replace with
asyncio.create_task so the sentinel reload flip actually executes.

Fixes CoplayDev#790

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dsarno dsarno force-pushed the fix/script-apply-edits-retry-duplication branch from cfbdd3a to dd6de1d Compare February 20, 2026 16:27
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
Server/src/services/tools/manage_script.py (1)

342-346: Move lazy stdlib imports to the module level; import os at line 345 is redundant.

asyncio, json, and glob are imported inside the try/except Exception: pass block (lines 342–346). Because the handler silently swallows all exceptions, any (unlikely) import failure is invisible. Additionally, os at line 345 is already imported at line 2.

Moving these to the top of the file is the idiomatic pattern and would also allow _background_tasks to carry an accurate type annotation.

♻️ Proposed fix

Add at the top of the file (alongside the existing imports):

 import base64
+import asyncio
+import glob
+import json
 import os
 from typing import Annotated, Any, Literal

Then remove the now-redundant in-function imports:

         if resp.get("success") and (options or {}).get("force_sentinel_reload"):
             # Optional: flip sentinel via menu if explicitly requested
             try:
-                import asyncio
-                import json
-                import glob
-                import os
 
                 def _latest_status() -> dict | None:

And tighten the type annotation on the module-level set:

-_background_tasks: set = set()
+_background_tasks: set[asyncio.Task[None]] = set()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Server/src/services/tools/manage_script.py` around lines 342 - 346, Move the
lazy imports asyncio, json, and glob out of the try/except block and place them
at module top alongside existing imports, remove the redundant in-function
import of os, and remove the broad try/except that hides import errors; then
update the module-level _background_tasks declaration to a precise type (e.g.,
Set[asyncio.Task] or set[asyncio.Task]) so its annotation is accurate. Ensure
you reference the same symbol names (_background_tasks) and update any local
code that assumed those imports remained local.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@Server/src/services/tools/manage_script.py`:
- Around line 342-346: Move the lazy imports asyncio, json, and glob out of the
try/except block and place them at module top alongside existing imports, remove
the redundant in-function import of os, and remove the broad try/except that
hides import errors; then update the module-level _background_tasks declaration
to a precise type (e.g., Set[asyncio.Task] or set[asyncio.Task]) so its
annotation is accurate. Ensure you reference the same symbol names
(_background_tasks) and update any local code that assumed those imports
remained local.

@dsarno dsarno merged commit 0a13dcd into CoplayDev:beta Feb 20, 2026
2 checks passed
dsarno added a commit that referenced this pull request Feb 20, 2026
… detection

Fixes #795 — the HTTP/WebSocket transport path (PluginHub) now respects
retry_on_reload=False and performs pre-send liveness checks, matching
the stdio path behavior from PR #792.

Co-Authored-By: Codex <noreply@openai.com>
dsarno added a commit that referenced this pull request Feb 20, 2026
… detection

Fixes #795 — the HTTP/WebSocket transport path (PluginHub) now respects
retry_on_reload=False and performs pre-send liveness checks, matching
the stdio path behavior from PR #792.

Co-Authored-By: Codex <noreply@openai.com>
dsarno added a commit that referenced this pull request Feb 20, 2026
… detection (#798)

Fixes #795 — the HTTP/WebSocket transport path (PluginHub) now respects
retry_on_reload=False and performs pre-send liveness checks, matching
the stdio path behavior from PR #792.
msanatan pushed a commit to msanatan/unity-mcp that referenced this pull request Feb 25, 2026
…oplayDev#792)

When Unity enters domain reload after a script edit, the retry loop in
send_command_with_retry would re-send the identical edit command up to
40 times, duplicating insert_method/anchor_insert edits. Pass
retry_on_reload=False on all script-mutating send calls since the edit
lands on disk before the reload triggers.

Also fix _flip_async coroutine in apply_text_edits that was passed as a
threading.Thread target — the coroutine was never awaited. Replace with
asyncio.create_task so the sentinel reload flip actually executes.

Fixes CoplayDev#790

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
msanatan pushed a commit to msanatan/unity-mcp that referenced this pull request Feb 25, 2026
… detection (CoplayDev#798)

Fixes CoplayDev#795 — the HTTP/WebSocket transport path (PluginHub) now respects
retry_on_reload=False and performs pre-send liveness checks, matching
the stdio path behavior from PR CoplayDev#792.
msanatan pushed a commit to msanatan/unity-mcp that referenced this pull request Feb 25, 2026
…oplayDev#792)

When Unity enters domain reload after a script edit, the retry loop in
send_command_with_retry would re-send the identical edit command up to
40 times, duplicating insert_method/anchor_insert edits. Pass
retry_on_reload=False on all script-mutating send calls since the edit
lands on disk before the reload triggers.

Also fix _flip_async coroutine in apply_text_edits that was passed as a
threading.Thread target — the coroutine was never awaited. Replace with
asyncio.create_task so the sentinel reload flip actually executes.

Fixes CoplayDev#790

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
msanatan pushed a commit to msanatan/unity-mcp that referenced this pull request Feb 25, 2026
… detection (CoplayDev#798)

Fixes CoplayDev#795 — the HTTP/WebSocket transport path (PluginHub) now respects
retry_on_reload=False and performs pre-send liveness checks, matching
the stdio path behavior from PR CoplayDev#792.
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.

script_apply_edits retries duplicate non-idempotent edits on domain reload

1 participant