# Sprint 11a — Implementation Plan: Fix 7 Critical Issues + verify_motion Tool

## Background

GodotIQ is an MCP server for AI-assisted Godot 4.x development. It consists of:
- A **Python MCP server** (`src/godotiq/`) that exposes tools to AI agents
- A **GDScript WebSocket addon** (`godot-addon/addons/godotiq/godotiq_server.gd`) running inside the Godot editor that executes commands

Communication flows: AI Agent → MCP Tool (Python) → WebSocket Bridge → GDScript Handler (Godot Editor)

During a complex city-building test, 7 critical issues were discovered that break the AI-assisted workflow. This sprint fixes all 7 plus adds a new `verify_motion` tool.

---

## Architecture Overview

Changes span two layers:

```
┌─────────────────────────────────────────────────┐
│  GDScript Layer (godotiq_server.gd)             │
│  • Fix _handle_run (play_current_scene)         │
│  • New handler: reload_script                   │
│  • New handler: set_main_scene                  │
│  • build_scene: add node_names to response      │
│  • build_scene: filesystem scan after creation  │
│  • _pending_run: 0.25s poll, 15s default timeout│
└──────────────────────┬──────────────────────────┘
                       │ WebSocket
┌──────────────────────┴──────────────────────────┐
│  Python Layer (src/godotiq/tools/bridge/)        │
│  • run.py: adaptive timeout + bridge timeout    │
│  • script_ops.py: best-effort reload after write│
│  • verify_motion.py: NEW tool                   │
│  • __init__.py: register verify_motion          │
│  • prompts/godot_development.md: NEW prompt     │
└─────────────────────────────────────────────────┘
```

### Critical Invariant: Dual Timeout Coordination

The system has two independent timeout mechanisms:
1. **GDScript polling timeout**: How long `_process()` polls `is_playing_scene()` (stored in `_pending_run.timeout_at`)
2. **Python bridge timeout**: How long `bridge.request()` waits for a WebSocket response (from `TOOL_TIMEOUTS` in `protocol.py`, currently `"run": 10.0`)

**The Python bridge timeout MUST always exceed the GDScript polling timeout.** Otherwise, Python times out and discards the future while GDScript is still polling. The GDScript response arrives with no matching handler.

**Solution**: `run.py` passes `timeout=computed_timeout + 5` to `bridge.request()` as the `timeout` kwarg. The `+5` buffer accounts for GDScript overhead (pre-flight checks, scene opening). The GDScript side receives `computed_timeout` in params for its polling duration.

---

## Section 1: Fix _handle_run — Reliable Scene Launch

### What Changes

The `_handle_run` function in `godotiq_server.gd` (currently at line 260) is rewritten to always use `play_current_scene()` instead of `play_main_scene()`.

### Current Behavior
- `scene == null || "main"` → `play_main_scene()` (UNRELIABLE)
- `scene == "current"` → `play_current_scene()` (works)
- `scene == "res://..."` → `play_custom_scene()` (sometimes fails)
- 5-second hardcoded timeout, 0.5s polling

### New Behavior

**Step 1: Resolve which scene to play**
- If `scene` param is a res:// path → use it directly
- If `scene` param is a name without .tscn → search using `_find_scene_by_name()`
- If `scene` is "current" → use whatever is open
- If `scene` is null/"main" → read `application/run/main_scene` from ProjectSettings
- If nothing resolved and nothing open → return error

**Step 2: Pre-flight script validation (PRESERVED from current code)**
Collect script paths from the resolved scene. Run `_check_scripts_valid()` on them. If errors found, return `SCRIPT_ERRORS` error. This existing gate (lines 264-291 of current code) must be preserved in the rewrite.

**Step 3: Ensure scene is open in editor**
- If the resolved scene is not the currently edited scene → call `EditorInterface.open_scene_from_path()`
- Use a two-step deferred approach: set `_pending_scene_open` state with scene path and play params, return immediately. In `_process()`, detect `_pending_scene_open`, call `play_current_scene()`, and transition to `_pending_run` polling state.
- This avoids the race condition where `open_scene_from_path` hasn't loaded yet when `play_current_scene()` is called.

**Step 4: If scene is already open, play immediately**
- Call `play_current_scene()` directly
- Set `_pending_run` with timeout from params (default 15.0 seconds)

**Step 5: Poll with dynamic timeout (in _process)**
- Poll `is_playing_scene()` every 0.25 seconds (changed from 0.5s)
- Timeout is read from `_pending_run.timeout_at` (set by `_handle_run`)
- Return `{action: "play", success: bool, scene: str, waited_seconds: float}`
- On timeout: `"Scene did not start within %.1f seconds"`

**Step 6: Auto-set main_scene (conditional)**
- If a scene param was provided AND `ProjectSettings.get_setting("application/run/main_scene")` is empty → call `_set_main_scene_internal()` to set it
- Add `"main_scene_set": true` to response so the agent knows project.godot was modified
- Only when main_scene is truly empty/unset — never overwrite an existing setting

### Helper: _find_scene_by_name

Search the project for a .tscn file matching a name (without extension). Use `EditorInterface.get_resource_filesystem()` to iterate (it has an index, faster than DirAccess). Fall back to `DirAccess` recursive search only if filesystem is not available.

**Ambiguity handling**: If multiple .tscn files match the same base name (e.g., `ui/main.tscn` and `levels/main.tscn`), return an error listing all matches. Do not silently pick the first one.

### New State Variable

Add `var _pending_scene_open = null` alongside `_pending_run`. In `_process()`, check for this state before the `_pending_run` check:
- If `_pending_scene_open` is set, that means we opened a scene last frame
- Call `play_current_scene()`, convert to `_pending_run` state, clear `_pending_scene_open`

---

## Section 2: Script Reload Handler + script_ops Integration

### GDScript Handler: reload_script

Add to dispatch table:
```
"reload_script" → _handle_reload_script(peer_id, id, params)
```

The handler:
1. Receives `{path: "res://scripts/enemy.gd"}`
2. Loads the script with `load(path) as GDScript`
3. Calls `script.reload()` to force recompilation
4. Calls `EditorInterface.get_resource_filesystem().scan()` to update the resource DB
5. Returns `{reloaded: true, path: "..."}` or `{reloaded: false, error: "..."}`

### Python Integration in script_ops.py

After any successful write or create operation that produces a `.gd` file, attempt a bridge reload:

```python
async def _try_reload_script(rel_path: str) -> None:
    """Best-effort script reload via bridge. Silent on failure."""
```

**Critical implementation details:**
1. Use **lazy import** of `get_bridge_or_none` inside the function body to avoid circular imports (script_ops is imported by bridge/__init__.py)
2. Use `get_bridge_or_none()` (NOT `get_bridge()`) — this returns `None` immediately if not connected, avoiding a 5-second connection attempt delay
3. If bridge is None, return silently
4. Construct the res:// path: `f"res://{rel_path}"`
5. Call `bridge.request("reload_script", params={"path": res_path})`
6. Catch ALL exceptions silently — this is best-effort

Call `_try_reload_script(rel_path)` at the end of `_op_write` and `_op_create` when the file ends with `.gd`.

### Filesystem Scan in build_scene

At the end of `_handle_build_scene` in godotiq_server.gd (after `send_response`), add:
```gdscript
EditorInterface.get_resource_filesystem().scan()
```

**Note**: `scan()` is asynchronous — it triggers a background scan completing over multiple frames. The client receives the response before the scan completes. If the agent immediately does a follow-up operation depending on scanned state, the scan may not be done yet. This is acceptable as best-effort; the scan improves reliability of subsequent operations.

---

## Section 3: build_scene Response Enhancement

### node_names in Response

The `_handle_build_scene` function (line 1261) creates nodes in a loop but doesn't track their names. Add an array to collect names of successfully created nodes.

**Use `_create_build_node` return value, not spec name.** The function returns `{"ok": true, "name": str(new_node.name)}` (line ~1536). Use `result.get("name", "")` because Godot may rename nodes on collision (appending `@` or incrementing a counter). The spec's `name` field may not match the actual node name.

Add `node_names` to the response dict:
```json
{
    "created": 12,
    "node_names": ["Road_0_0", "Road_0_1", "Building_1"],
    "errors": [],
    "total_requested": 12,
    "parent": "Main",
    "truncated": false
}
```

No changes needed on the Python side — `build_scene.py` passes through the bridge response as-is.

---

## Section 4: set_main_scene Handler

### GDScript Handler: set_main_scene

Add to dispatch table:
```
"set_main_scene" → _handle_set_main_scene(peer_id, id, params)
```

The handler:
1. Receives `{scene: "res://scenes/main.tscn"}`
2. Normalizes the path (prepend `res://` if missing)
3. Calls `ProjectSettings.set_setting("application/run/main_scene", scene_path)`
4. Calls `ProjectSettings.save()`
5. Returns `{main_scene: path, saved: true/false}`

### Internal Helper for run Integration

Also create `_set_main_scene_internal(scene_path: String) -> bool` that does the same without WebSocket response handling. This is called from `_handle_run` when auto-setting main scene.

**Warning**: This modifies `project.godot` on disk. It is not undoable through the editor undo system. The response includes `main_scene_set: true` to make this visible to the agent.

**This section must be implemented before or simultaneously with Section 1**, since Section 1's run integration depends on it.

---

## Section 5: Adaptive Timeout in run.py

### Current State

`run.py` is 29 lines. It just forwards action and scene to the bridge with no timeout parameter.

### New Behavior

Add a `timeout` parameter (default 0 = auto-detect):

```python
async def run(action: str = "play", scene: str = "main", timeout: float = 0) -> dict:
```

Auto-detection logic:
1. If `timeout > 0`, use as-is
2. Otherwise, read `GODOTIQ_PROJECT_ROOT` from `os.environ` and use `pathlib.Path.rglob("*.glb")` to count 3D assets
3. `>100` GLB → 20.0s, `>50` → 15.0s, else → 10.0s
4. If env var not set → 15.0s default

**Do NOT try to access the session object** — it lives in the MCP lifespan context and is not importable. Use the environment variable directly.

### Bridge Timeout Coordination

Pass the computed timeout to both places:
1. **GDScript params**: `params={"scene": scene, "timeout": computed_timeout}` — for GDScript polling duration
2. **Bridge request timeout**: `timeout=computed_timeout + 5` as a kwarg to `bridge.request()` — this overrides `TOOL_TIMEOUTS["run"]` for this call, ensuring Python always waits longer than GDScript

### Return Value

Update `run.py` to return the bridge response directly rather than constructing its own dict. The GDScript response now includes `{action, success, scene, waited_seconds}` which is more informative.

For `action == "stop"`, keep the current behavior of returning `{"action": "stop"}` since the stop handler response format is unchanged.

### MCP Wrapper Update

No new parameters exposed to the agent — the adaptive logic is internal. The `_run` function signature changes (adds `timeout`), so update the wrapper call in `__init__.py`.

---

## Section 6: verify_motion Tool

### New File: src/godotiq/tools/bridge/verify_motion.py

```python
async def verify_motion(
    node: str,
    property_name: str = "position",
    duration: float = 2.0,
) -> dict:
    """Verify a node property changes over time (proves movement/animation)."""
```

**Note**: Parameter is `property_name` not `property` to avoid shadowing the Python builtin.

### Logic

1. **Check game is running**: Call `bridge.request("ping")` and verify `game_running` is true. If not, return `{"error": "Game is not running. Start it with godotiq_run first."}`
2. Request `state_inspect` with the node and property_name → capture value 1
3. If first inspection fails → return error immediately (don't sleep)
4. `await asyncio.sleep(duration)`
5. Request `state_inspect` again → capture value 2
6. If second inspection fails → return `{verdict: "ERROR", error: "Game may have stopped during verification"}`
7. Compare stringified values
8. Return `{node, property_name, before, after, changed: bool, verdict: "MOVING"|"STATIC", duration_seconds}`

### MCP Registration in __init__.py

Add import and `@mcp.tool()` wrapper following the established pattern:
- Name: `godotiq_verify_motion`
- Annotations: `readOnlyHint: True, destructiveHint: False, idempotentHint: False, openWorldHint: True`
- Parameters: `node: str, property_name: str = "position", duration: float = 2.0, detail: str = "normal"`
- Apply `apply_output_limit` to the response (use the `detail` parameter)
- Error handling: same bridge error pattern as other tools

---

## Section 7: Fresh MCP Prompt (godot_development.md)

### File: src/godotiq/prompts/godot_development.md

The prompt file is currently deleted from the working tree. Retrieve the base content from git:
```bash
git show HEAD:src/godotiq/prompts/godot_development.md
```

Create a fresh version incorporating all existing content PLUS these new sections:

**New Section: Known Godot Quirks**
1. play_main_scene() unreliable — always use run(action="play")
2. Script cache staleness — GodotIQ auto-reloads, but exec can force it
3. class_name in new scripts — use load() pattern instead
4. Asset loading time — adaptive timeout handles it
5. Verifying movement — use verify_motion or double state_inspect
6. Setting main scene — happens automatically on first run, or use exec

**New Section: Script Loading Pattern for New Scripts**
- Prefer `load("res://path.gd").new()` over `ClassName.new()` for scripts created during the session
- Explain why: editor cache staleness with class_name registration

**New Section: Verifying Movement and Animations**
- Screenshots are single frames
- verify_motion tool as the preferred approach
- Double state_inspect pattern as manual alternative with examples

**New Section: Game Loading Time**
- Asset-heavy projects take 10-15+ seconds
- run() handles adaptive timeout automatically
- If timeout, use wait + state_inspect fallback

**Updated Tool Reference**
- Add verify_motion to the tool list
- Update run() docs to mention scene parameter behavior (auto-open, auto-set main scene)

---

## Testing Strategy

### Existing Tests — No Regressions
All existing tests in `tests/` must pass unchanged. The Python-side changes (run.py, script_ops.py) are backward-compatible.

### New Tests Required

**test_verify_motion.py**
- Mock bridge, verify two state_inspect calls with sleep between
- Test MOVING verdict when values differ
- Test STATIC verdict when values are same
- Test with different property_name values
- Test game-not-running returns error
- Test second inspection failure returns ERROR verdict

**test_run_adaptive_timeout.py**
- Mock `os.environ` with GODOTIQ_PROJECT_ROOT pointing to temp dirs with varying GLB counts
- Verify timeout calculation: 0 GLB → 10s, 60 GLB → 15s, 120 GLB → 20s
- Verify explicit timeout overrides auto-detection
- Verify no env var → 15s default
- Verify bridge.request receives timeout = computed + 5

**test_script_ops_reload.py**
- Mock `get_bridge_or_none` to return a mock bridge
- Verify reload_script request is made after .gd write with correct res:// path
- Verify reload is NOT attempted for non-.gd files (.tres, .tscn, etc.)
- Mock `get_bridge_or_none` returning None → verify silent skip
- Mock bridge.request raising exception → verify silent skip

### GDScript Validation

The GDScript changes cannot be tested with pytest (they run inside Godot). Validation approach:
1. Manual syntax check: ensure the modified `godotiq_server.gd` is valid GDScript
2. Verify dispatch table completeness: all existing method strings still map to handlers
3. Verify new handlers (reload_script, set_main_scene) are in dispatch table
4. Verify _pending_scene_open handling in _process doesn't break existing _pending_run flow
5. Manual testing checklist (see Verification section of spec)

---

## Implementation Order

The sections should be implemented in this order due to dependencies:

1. **Section 4**: set_main_scene handler (dependency for Section 1)
2. **Section 1**: Fix _handle_run (foundational — uses set_main_scene, everything depends on reliable run)
3. **Section 2**: Script reload handler + script_ops integration (independent)
4. **Section 3**: build_scene response enhancement (independent)
5. **Section 5**: Adaptive timeout in run.py (depends on Section 1 for GDScript timeout params)
6. **Section 6**: verify_motion tool (independent)
7. **Section 7**: Fresh MCP prompt (must be last — documents all other changes)

Sections 2, 3 can be implemented in parallel after Section 1.
Sections 5 and 6 have no dependencies on each other.
Section 7 must be last (documents everything).

---

## Risk Assessment

| Risk | Impact | Mitigation |
|------|--------|------------|
| play_current_scene has own quirks | High | open_scene_from_path ensures correct scene is loaded first; two-step deferred avoids race |
| Dual timeout mismatch | Critical | Python timeout = GDScript timeout + 5s buffer. Documented invariant. |
| filesystem.scan() is slow | Low | Called only after specific operations; async so doesn't block response |
| Adaptive timeout too aggressive | Medium | User can always override with explicit timeout |
| verify_motion sleep blocks MCP | Low | asyncio.sleep is non-blocking; other tools remain responsive |
| auto-set main_scene modifies project.godot | Medium | Only when truly empty; response includes `main_scene_set: true` |
| Prompt changes break agent behavior | Medium | Prompt is additive — existing patterns preserved, new sections added |
| script_ops circular import | Low | Lazy import inside function body; get_bridge_or_none avoids connection delay |
