Skip to content

Give merge_conflicts its own deadline instead of the 30s request default - #38

Merged
TheValiantOne merged 1 commit into
mainfrom
fix/mcp-merge-call-timeout
Aug 21, 2026
Merged

Give merge_conflicts its own deadline instead of the 30s request default#38
TheValiantOne merged 1 commit into
mainfrom
fix/mcp-merge-call-timeout

Conversation

@TheValiantOne

Copy link
Copy Markdown
Owner

What's wrong

The "Resolve Script Conflicts" action ran both its dry-run preview and the real merge through a client left on mcpClient.ts's general-purpose DEFAULT_REQUEST_TIMEOUT_MS (30s). runMergeConflictsWorkflow called connect({ exePath, env }) with no requestTimeoutMs, so every merge_conflicts call inherited it.

A merge's runtime scales with the size of the load order. On a large one it simply cannot finish in 30 seconds.

How it failed

Hit for real on a 274-mod install with 44 conflicting script files. The preview alone exceeded 30s, so resolveScriptConflicts reported "Failed to preview script-conflict merges" and returned at the preview stage (resolveAction.ts:141) — the real merge below it never ran. Vortex's log recorded only the transport error:

[WARN] [witcherscriptmerger-companion] witcherscriptmerger-vortex: resolveScriptConflicts failed
  {"message":"Failed to preview script-conflict merges",
   "error":"WSM MCP request 'tools/call' timed out after 30000ms"}

Because a deploy had already cleared mod0000_MergedFiles, this left an empty merged mod and a game that wouldn't start — ~200 script compile errors naming members the merge is supposed to add to vanilla scripts:

Error [modspawncompanions] ... 'scmcc' is not a member of '&handle:CNewNPC'
Error [modz_sss6] ... Could not find function 'SSS_AddSkillSlot'
Error [modlegowellstockedcraftsmen] ... 'renewableIngredientList' is not a member of ...

Nothing in that error wall points at a timed-out MCP call, which is what made it expensive to diagnose.

The fix

  • Both call sites now pass MERGE_CALL_TIMEOUT_MS (10 minutes), matching nexusDownloader.ts's DEFAULT_DOWNLOAD_TIMEOUT_MS precedent for "an operation whose runtime is the user's data, not a round trip".
  • The preview gets it too, deliberately. A dry run does the entire scan-and-three-way-merge computation and only skips the writes, so it costs essentially the same as the merge it previews. Sizing it as if it were cheap is exactly what broke.
  • Passed per call, not on the client. callTool/request take an optional timeoutMs override. Raising requestTimeoutMs at connect time would also bound the initialize handshake — and a handshake that hasn't answered in a few seconds means a WSM process that failed to start, which should keep failing fast rather than inherit a merge-sized deadline.
  • A timeout now reports what it means for the install ("nothing was merged", plus that an empty or stale merged mod can stop the game starting) instead of a bare transport error. The original error still goes through as the notification detail.

Note the sibling call sites (conflictScan.ts, coexistenceGuard.ts) deliberately go the other way with 15s timeouts, and their comments explain why the 30s default is meant for short calls. Nothing had gone the other direction for the one call that legitimately takes minutes.

Verification

9 new tests, 217 total, all passing. npm run typecheck and npm run lint clean.

New src/mcpClient.test.ts covers the real client's deadline plumbing against a mocked child_process.spawn and fake timers:

  • a per-call override wins over the client default (still pending at 2× the default, rejects past the override)
  • no override falls back to the client default
  • an override does not leak into later calls on the same client
  • the initialize handshake keeps the client default

Extended src/resolveAction.test.ts:

  • the preview call carries the merge-sized timeout (with dryRun: true)
  • the real merge carries the same one (with dryRun: false)
  • connect is not handed a widened requestTimeoutMs
  • a timeout notification says "nothing was merged" and names the limit, keeping the original error as detail
  • a non-timeout failure message is left unchanged

One test-harness note worth flagging for review: the fake child answers initialize synchronously rather than via queueMicrotask/setTimeout, because vi.useFakeTimers controls both and the handshake reply has to land without advancing time — advancing time is what triggers the deadlines under test. That's safe because request() registers its pending entry before calling writeMessage, so a response can never arrive "too early". The tests also deliberately skip client.close(), whose grace period is itself a timer that fake timers would never fire.

Not covered here

This fixes the timeout. It does not address the fact that the merged mod can be left empty by a deploy in the first place — mod0000_MergedFiles is registered as a Vortex-managed mod (VK=… in mods.settings) pointing at an empty staging folder, so a purge/deploy cycle can wipe merge results that WSM, not Vortex, actually owns. Worth a follow-up.

Separately, [game-witcher] failed to ascertain merged mod name logs an ENOENT for <game>\WitcherScriptMerger\WitcherScriptMerger.exe.config; on a real install that folder is empty (WSM sits at the game root) and .NET Core names the file WitcherScriptMerger.dll.config. It falls back to the correct default, so it's benign today, but the path expectation is wrong on both counts.

AI-assisted development

Produced by Claude Code (Opus 5), including the log-driven diagnosis on the affected install, the fix, and the tests. Per CONTRIBUTING.md: the failure above was observed on a real 274-mod install, not inferred, and the test/typecheck/lint results are from real runs.

The "Resolve Script Conflicts" action ran both its dry-run preview and the
real merge through a client left on mcpClient.ts's general-purpose
DEFAULT_REQUEST_TIMEOUT_MS (30s). A merge's runtime scales with the size of the
load order, so on a large one it simply cannot finish in that window.

Hit for real on a 274-mod install with 44 conflicting script files: the preview
alone exceeded 30s, so resolveScriptConflicts reported "Failed to preview
script-conflict merges" and returned at the preview stage - the real merge below
it never ran. Because a deploy had already cleared mod0000_MergedFiles, that left
an empty merged mod and a game that wouldn't start, with ~200 script compile
errors naming members the merge is supposed to add to vanilla scripts
('scmcc' is not a member of 'CNewNPC', Could not find function 'SSS_AddSkillSlot',
and so on). Vortex's log recorded only the transport error:

  [WARN] witcherscriptmerger-vortex: resolveScriptConflicts failed
    {"error":"WSM MCP request 'tools/call' timed out after 30000ms"}

Both call sites now pass MERGE_CALL_TIMEOUT_MS (10 minutes, matching
nexusDownloader.ts's own precedent for an operation whose runtime is the user's
data rather than a round trip). The preview gets it too, deliberately: a dry run
does the entire scan-and-three-way-merge computation and only skips the writes,
so sizing it as if it were cheap is exactly what broke.

The deadline is passed per call - callTool/request take an optional override -
rather than raised on the client at connect time, because requestTimeoutMs also
bounds the initialize handshake, and a WSM process that fails to start should
keep failing fast instead of hanging for ten minutes.

A timeout also now reports what it means for the install ("nothing was merged",
plus the note that an empty or stale merged mod can stop the game starting)
rather than surfacing a bare transport error; the original error is still passed
through as the notification detail.

9 new tests (217 total): the timeout reaching both call sites, connect not being
widened, the per-call override winning over the client default, not leaking to
later calls on the same client, the handshake keeping the short default, and the
timeout vs. non-timeout notification wording. typecheck and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FP8H6rBLCGPBFRSVsF3Kgw
@TheValiantOne
TheValiantOne merged commit bf1e0cb into main Aug 21, 2026
1 check passed
@TheValiantOne
TheValiantOne deleted the fix/mcp-merge-call-timeout branch August 21, 2026 13:11
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.

1 participant