Do not retry a stale id; say how to fix an ambiguous target#98
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #98 +/- ##
==========================================
+ Coverage 85.26% 85.30% +0.04%
==========================================
Files 44 44
Lines 3265 3274 +9
Branches 452 454 +2
==========================================
+ Hits 2784 2793 +9
Misses 351 351
Partials 130 130 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Code reviewFound 6 issues. The first two are blockers.
libtmux-mcp/src/libtmux_mcp/middleware.py Lines 676 to 686 in a07a6e1
libtmux-mcp/src/libtmux_mcp/_utils.py Lines 1010 to 1020 in a07a6e1
An agent that follows the suggestion re-issues by id, gets the identical error, and loops — which is the opposite of the "agent-correctable" contract this function's own docstring promises. libtmux-mcp/src/libtmux_mcp/_utils.py Lines 1010 to 1020 in a07a6e1
Lines 10 to 12 in a07a6e1 One note worth weighing rather than fixing: this PR subclasses and overrides fastmcp's private 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
why: tmux-python/libtmux#718 shipped in 0.62.0, moving MultipleObjectsReturned and ObjectDoesNotExist -- and TmuxObjectDoesNotExist with them -- under LibTmuxException. The retry and error-mapping fixes that follow depend on that hierarchy, so the floor must require the release that provides it. what: - Raise the libtmux floor from >=0.61.0 to >=0.62.0 and relock against the PyPI release
why: Readonly tools retry on LibTmuxException, which is right for the transient socket errors libtmux wraps -- but most of that family is not transient. A pane that does not exist will not exist on the second look. Every readonly call with a stale id was paying a second tmux round-trip and a backoff window to fail identically, and the audit log recorded one call, so the doubled traffic was invisible. libtmux#718 widens the blast radius: TmuxObjectDoesNotExist becomes a LibTmuxException, so every missing session and window joins the panes that were already being retried. what: - Add NON_RETRYABLE_EXCEPTIONS and skip them in the retry decision, walking __cause__ as fastmcp does -- handle_tool_errors presents an ExpectedToolError, so the real failure is only visible one hop down - Cover every entry, and the decorated path end to end
why: A window shared between sessions is listed once per session holding it, so a name or index can match several rows and the lookup raises MultipleObjectsReturned. It reached the agent as an unexpected error with a stack trace and no way forward. Under libtmux#718 it would instead be reported as agent-correctable while still saying nothing about how to correct it, which is worse. what: - Map MultipleObjectsReturned to an expected error that names the fix: target the object by id - Widen the not-found arm to ObjectDoesNotExist, so a QueryList miss gets the same discovery hint as a tmux id miss
a07a6e1 to
d334c71
Compare
why: Record what this branch ships for a reader deciding whether to upgrade -- the libtmux floor bump and the two lookup behaviours -- at deliverable altitude rather than at the level of the exception classes involved. what: - Note the raised libtmux floor under Dependencies - State the once-not-twice retry fix and the ambiguous-target recovery hint as bold-subheading deliverables, matching the rest of the file
d334c71 to
c6b50ef
Compare
…ectsReturned why: rebasing onto main pulls in PR #98's retry/error-mapping code, which references libtmux.exc.MultipleObjectsReturned -- an exception that only joined libtmux.exc in the 0.62.0 release floor. The engine-ops [tool.uv.sources] dev pin resolves an older libtmux that predates it, so the package failed to import (middleware.py, at module load) and would raise AttributeError on every tool-error mapping (_utils.py). what: - middleware.py: guard the MultipleObjectsReturned entry in NON_RETRYABLE_EXCEPTIONS with hasattr so the tuple builds either way - _utils.py: resolve the isinstance target via getattr(..., ()) so the ambiguous-target branch is a safe no-op when the class is absent - both are no-ops on the libtmux>=0.62.0 floor: identical behavior once the release exception is present
… libtmux Rebasing onto main pulled in #98, whose retry-skip set and error mapping key on libtmux 0.62.0's re-parented query errors (tmux-python/libtmux#718 moved ObjectDoesNotExist and MultipleObjectsReturned under LibTmuxException). The chainable-commands experiment pins libtmux to an older branch that lacks MultipleObjectsReturned and where TmuxObjectDoesNotExist is not yet a LibTmuxException, so the merged code would not import there. Resolve the libtmux exception types by name in NON_RETRYABLE_EXCEPTIONS and in _map_exception_to_tool_error, falling back to a never-matched sentinel for any type this libtmux build does not define. Behavior is identical on the libtmux>=0.62.0 floor the package targets; on the older pin the missing arms fall through to the generic LibTmuxException handler and the package stays importable. Gate the #98 test cases that assert the 0.62.0 hierarchy so they run on that floor and are skipped on the older pin. Relock uv.lock against the merged pyproject.
Draft — do not merge with the git pin in place. Validates tmux-python/libtmux#718 downstream before it ships. Drop the
[tool.uv.sources]stanza and bump thelibtmuxfloor once #718 is released.Fixes #97 (the ambiguous-lookup half).
Why
libtmux#718 moves
MultipleObjectsReturnedandObjectDoesNotExistunderLibTmuxException— andTmuxObjectDoesNotExistinherits fromObjectDoesNotExist, so it moves too. This server keys two behaviours offLibTmuxException, and both change silently.The suite did not notice. Pinned to the #718 branch,
mainis green: 701 passed, 0 failed. Every changed path was uncovered. That is the finding, and it is why this PR leads with tests.What was broken
1. A stale id was retried.
ReadonlyRetryMiddlewareretries onLibTmuxException, which is correct for the transient socket errors libtmux wraps — but most of that family is not transient. A pane that does not exist will not exist on the second look; an ambiguous match does not become unambiguous during a backoff window. Every readonly call carrying a stale or guessed id paid a second tmux round-trip and 100 ms of latency to fail identically — and becauseAuditMiddlewaresits outside the retry, the audit log recorded one call, so the doubled traffic was invisible.This is not new with #718.
_resolve_paneraisesPaneNotFound, which is already aLibTmuxException— so panes have been over-retried all along. #718 merely widens it to every missing session and window, which is what made it impossible to keep ignoring. The fix closes the whole family, including the part that was already broken:2. An ambiguous target reached the agent with no way forward. A window shared between sessions (a grouped session, or
link-window) is listed once per session that holds it, so a name or index can match several rows and the lookup raisesMultipleObjectsReturned. Today that surfaces asUnexpected error: MultipleObjectsReturned:with an empty message and a stack trace. Under #718 it would land somewhere worse: reclassified as agent-correctable (expected: true, WARNING,"tmux error: …") while still saying nothing about how to correct it. It now names the fix — target the object by id.The fix
NON_RETRYABLE_EXCEPTIONSinmiddleware.py, skipped in the retry decision. The check walks__cause__one hop, exactly as fastmcp's_should_retrydoes — necessary becausehandle_tool_errorsre-raises every libtmux failure as anExpectedToolErrorchained off the original, so at the middleware layer the real failure is only visible one hop down.MultipleObjectsReturnedarm in_map_exception_to_tool_error, above theLibTmuxExceptionarm, carrying a recovery suggestion.TmuxObjectDoesNotExisttoObjectDoesNotExist, so aQueryListmiss gets the same discovery hint as a tmux-id miss.Verification
Every new test fails on
mainand passes here — proven by revertingsrc/and re-running:ruff format·pytest·ruff check --fix·mypy(strict) ·just build-docs— all green. 711 passed (was 701).The end-to-end test (
test_readonly_retry_skips_not_found_on_decorated_tool) drives the reallist_sessionstool through the production middleware stack, so it exercises thehandle_tool_errors→__cause__→ retry-decision chain rather than a unit stub. A unit test alone would pass even if the skip never fired in production.Before merge
[tool.uv.sources]frompyproject.toml.libtmux>=<that release>,<1.0(was>=0.61.0).uv lock, re-run the gates.