fix(task-5): a rollback that fails after a cancellation no longer kills the session, and the COM rollback goes back to an UNDO Mark instead of prompting

Two review findings on 32821c6.

1. Once the shielded rollback body runs after a real client cancellation it
   can fail (the COM hard timeout, _safe_send_command's own deadline, an
   ezdxf quarantine refusal). That RuntimeError escaped run_spec as an
   ordinary error and the mcp lowlevel server, which had already answered
   the cancelled request, tried to respond a second time: `assert not
   self._completed` in mcp/shared/session.py escaped the server task group
   and the whole session died (measured through the real transport: the next
   system_status was never answered and Client.__aexit__ re-raised the
   AssertionError). run_spec now decides by the exception it is handling:
   after a cancellation the rollback error is logged and the cancellation is
   what propagates, so the SDK's cancellation suppression applies; after any
   other error the rollback error propagates as before, with the original
   chained as its context.

2. The docstring promised the rollback on both engines, but on the live
   AutoCAD 2026 ComBackend.transaction_rollback sent `_UNDO B` with no UNDO
   Mark in the stack (StartUndoMark/EndUndoMark form an undo *group*, not a
   Mark), AutoCAD answered "This will undo everything. OK? <Y>", SendCommand
   blocked at that prompt, _run gave up after 60 s, the entities stayed and
   every COM client was rejected until a human pressed ESC. transaction_begin
   now types `_.UNDO _M` before StartUndoMark (refused with ok:false while
   CMDACTIVE, where the Mark would be swallowed as input), and
   transaction_rollback ends the group and sends `_.UNDO _B`, which returns
   exactly to that Mark - also on an empty transaction, where `_UNDO 1` would
   be one step too many. A rollback with no transaction is refused the way the
   headless engine refuses it, because an unmarked Back is that prompt.
   Measured with the real ComBackend in a scratch Documents.Add() document on
   the running AutoCAD 2026 (closed with Close(False)): begin 31 ms ->
   entity_create_line + circle -> rollback 63 ms -> 3 entities back to 1,
   CMDACTIVE 0, no prompt; an empty begin/rollback leaves the older line;
   begin/commit unchanged.

Tests: the two failure modes on run_spec (anyio scope cancellation with a
raising rollback completes the task group and logs; a ValueError with a
raising rollback still raises the rollback error with the ValueError as
context), the real-transport case (client.cancel mid-run, rollback raises,
the next request is answered, cleanup through the real rollback), the COM
shape now pins `_.UNDO _M, StartUndoMark, EndUndoMark, _.UNDO _B`, and
test_com_sprint3 grows a Mark-keeping fake document whose unmarked Back
fails the way the prompt does: begin before the group, rollback consumes the
Mark, rollback without a transaction sends nothing, begin refuses on
CMDACTIVE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-21 21:46:08 +03:00
co-authored by Claude Opus 5
parent 32821c6410
commit 2e101b95e2
7 changed files with 304 additions and 21 deletions
+9 -3
View File
@@ -3988,7 +3988,8 @@ _BATCH_GUARANTEE_NOTES = {
"whole document to a temporary DXF, so it scales with drawing size."
),
"best_effort_undo": (
"Rollback ends the AutoCAD undo mark and sends '_UNDO B' to the command line. "
"Rollback ends the AutoCAD undo group and sends '_UNDO _B' (back to the UNDO "
"Mark transaction_begin set) to the command line. "
"AutoCAD executes that asynchronously and does not confirm it landed, so this "
"is best-effort, NOT atomic. The before/after fingerprint below is the only "
"evidence available; treat a mismatch as 'the undo has not landed (yet)' and "
@@ -4994,7 +4995,8 @@ async def transaction_commit(ctx: Context = None) -> dict:
async def transaction_rollback(ctx: Context = None) -> dict:
"""Rollback the current transaction to the point of transaction_begin.
COM: Undoes all operations back to the last undo mark.
COM: Undoes all operations back to the UNDO Mark transaction_begin set;
refused (`ok: false`) when no transaction is active.
ezdxf: Restores the document from the saved DXF snapshot.
WARNING: This is destructive – all changes since transaction_begin are lost.
@@ -6839,7 +6841,11 @@ async def pid_from_spec(
drawn port-to-port, inside one transaction: any bad item rolls the whole
sheet back and the error names it (`lines[2].to`); so does an interruption
(a client cancellation mid-run leaves no half-drawn sheet and no open
transaction). A non-finite coordinate, rotation, scale or stub is refused
transaction on either engine — the live one goes back to the UNDO Mark
`transaction_begin` set, so it never waits at an AutoCAD prompt; should
that rollback itself fail, the failure is logged, the cancellation is what
the client sees, and the drawing must be checked before a retry).
A non-finite coordinate, rotation, scale or stub is refused
by path before anything is placed. The response carries the graph read
back from the drawing and the P&ID critique issues, so the caller sees
dangling ends or duplicate tags in the same round trip. `dry_run` returns