Skip to content

feat(mcp): improve concurrent workspace execution - #864

Draft
skevetter wants to merge 18 commits into
mainfrom
feat/mcp-exec-safety-and-coverage
Draft

feat(mcp): improve concurrent workspace execution#864
skevetter wants to merge 18 commits into
mainfrom
feat/mcp-exec-safety-and-coverage

Conversation

@skevetter

@skevetter skevetter commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes 5 gaps found during a hands-on readiness evaluation of devsy's CLI/MCP surface for external agents orchestrating many devcontainer workspaces (deploy, exec, teardown), plus a cross-platform build fix and CodeRabbit review findings surfaced along the way.

  • Exec/delete race: workspace.ExecOneShot now takes the existing per-workspace flock before running, closing a race where a concurrent workspace_delete/workspace_create could interleave with an in-flight workspace_exec on the same workspace.
  • Unbounded MCP concurrency: added a bounded semaphore (default 8 slots, configurable via --mcp-max-concurrent-ops) gating workspace_exec/workspace_create/workspace_start, so an orchestrator driving many workspaces through one devsy mcp serve process can't overwhelm the local Docker/Kubernetes backend.
  • --ide-launch=skip still triggered an IDE install: now also defaults --ide to none when left unset, so headless/agent callers don't pay for an unwanted IDE server binary download.
  • No MCP-transport e2e coverage: added a reusable MCPClient (real stdio JSON-RPC against a real devsy mcp serve subprocess) and e2e specs covering workspace_list/workspace_exec and the unknown-workspace error path.
  • Config-write durability: WriteFileAtomic now fsyncs the parent directory after the atomic rename on POSIX (Windows unaffected, still a no-op).

Also fixes a missing !windows build tag discovered along the way, and addresses all 5 findings from a CodeRabbit review of the full diff (semaphore-gating test depth, source validation ordering, lock/unlock test assertions).

Test plan

  • go build ./... and the full non-e2e suite (go test ./... -race, excluding /test and /e2e) pass, 101 packages, zero failures.
  • golangci-lint run clean at every commit.
  • Live e2e (ginkgo --focus "devsy mcp serve" and the ide skip-launch spec) run against a real Docker/Colima backend, independently verified.

Summary by CodeRabbit

  • New Features

    • Added configurable limits for concurrent MCP workspace operations.
    • Added end-to-end MCP support for listing workspaces and executing commands.
    • --ide-launch=skip now defaults to no IDE when none is specified.
  • Bug Fixes

    • Prevented conflicting workspace executions from running simultaneously.
    • Improved crash durability for atomic file updates on POSIX systems.
    • Added clearer handling for busy workspaces and unknown workspace commands.
  • Tests

    • Expanded coverage for MCP operations, IDE behavior, locking, concurrency, and file durability.

…th a semaphore

Add an in-process opSemaphore gating workspace_exec, workspace_create, and
workspace_start so an orchestrator driving one devsy mcp serve process can't
overwhelm the local Docker/Kubernetes backend with an unbounded burst of
calls. workspace_list/status/stop/delete and provider_* tools stay ungated
since gating deletes could deadlock a caller trying to free resources while
all slots are held by stuck creates.

New flag --mcp-max-concurrent-ops (default 8) controls the limit.
…s unset

--ide-launch=skip only suppressed the host-side IDE launch; the container
still downloaded and installed an IDE server binary (e.g. openvscode-server)
because installIDE is gated solely on ide.Name, which never sees IDELaunch.
validate() now mirrors RunHeadless's existing pairing and defaults IDE to
none when launch is skipped and no explicit --ide was passed, while
respecting an explicit --ide choice.
…unch

The prior e2e assertion checked test -d /root/.openvscode-server inside the
container, but this fixture's devcontainer sets remoteUser=vscode, so
openvscode-server actually installs under /home/vscode/.openvscode-server.
The assertion printed "absent" whether or not the IDE server was installed,
giving zero regression protection.

Assert instead on the host-side workspace config's ws.IDE.Name, the exact
value applySkipLaunchIDEDefault sets and installIDE gates on. Verified via a
live Docker round-trip: reverting the fix makes the test fail (ide.Name
resolves to "openvscode" and openvscode-server actually installs); restoring
the fix makes it pass (ide.Name is "none", no install log line at all).
…kspace_exec

Adds a reusable MCPClient helper (e2e/framework/mcp.go) that drives a real
devsy mcp serve subprocess over stdio JSON-RPC, and a new e2e/tests/mcp
package exercising workspace_list and workspace_exec through the actual MCP
transport instead of the SDK's in-memory transport used by unit tests.
skip_launch_no_install.go calls setupBrowserIDE which is only available on
non-Windows platforms (defined in browser_returns.go with //go:build !windows).
Adding the same build tag ensures cross-platform build compatibility.
…verage

- Drive workspace_exec through a real MCP client in
  TestServer_WorkspaceExecRespectsSemaphore so it proves the tool handler
  itself is gated by the semaphore, not just the primitive.
- Assert errors.Is(err, context.DeadlineExceeded) in
  TestOpSemaphore_AcquireRespectsContextCancel instead of a bare non-nil
  check.
- Validate workspace_create's source before acquiring the op semaphore so
  malformed requests fail fast without consuming a scarce slot.
- Extend TestExecOneShot_UnlocksAfterSuccessfulLock to exercise Unlock and
  assert unlockCalls, matching what the test name promises.
Records the design plan for this branch's readiness-gap fixes and
ignores .superpowers/ (per-plan SDD scratch state) so it never lands
in a commit.
@netlify

netlify Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploy Preview for devsydev canceled.

Name Link
🔨 Latest commit 700a317
🔍 Latest deploy log https://app.netlify.com/projects/devsydev/deploys/6a70e9980f5ffe0008c1f384

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b6483be9-315b-4bda-b5b1-0df81c0e522b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change adds bounded MCP concurrency, workspace execution locking, IDE skip-launch normalization, MCP stdio end-to-end tests, and POSIX atomic-write directory synchronization. It also adds supporting tests, documentation, and repository ignore configuration.

Changes

MCP operation concurrency

Layer / File(s) Summary
Semaphore contract and server wiring
cmd/mcp/semaphore.go, cmd/mcp/serve.go, pkg/flags/names/names.go
Adds a context-aware semaphore and the mcp-max-concurrent-ops flag with a default limit of 8.
Gated workspace tools and validation
cmd/mcp/tools_exec.go, cmd/mcp/tools_workspace.go, cmd/mcp/semaphore_test.go, cmd/mcp/serve_test.go
Limits workspace execution, creation, and startup. Tests cover blocking, release, cancellation, and integration behavior.

Workspace execution locking

Layer / File(s) Summary
Execution lock lifecycle
pkg/workspace/exec.go, pkg/workspace/exec_test.go
Adds bounded workspace lock acquisition and releases the lock across resolution and execution failure paths. Tests cover lock failures and unlocking.

IDE launch normalization

Layer / File(s) Summary
Validation and end-to-end coverage
cmd/workspace/up/up_validate.go, cmd/workspace/up/up_test.go, e2e/tests/ide/skip_launch_no_install.go
Sets an unset IDE to config.IDENone when launch is skipped. Unit and end-to-end tests cover unset, explicit, and automatic IDE behavior.

MCP stdio end-to-end coverage

Layer / File(s) Summary
MCP client and workspace scenarios
e2e/framework/mcp.go, e2e/e2e_suite_test.go, e2e/tests/mcp/*
Adds a JSON-RPC MCP client, Docker-backed workspace helpers, and tests for listing, execution, and unknown-workspace errors.

Atomic provider-write durability

Layer / File(s) Summary
Platform-specific synchronization and validation
pkg/provider/atomic.go, pkg/provider/atomic_posix.go, pkg/provider/atomic_windows.go, pkg/provider/atomic_test.go
Synchronizes the parent directory after atomic rename on POSIX systems and preserves Windows compatibility. Tests verify persisted content.

Supporting repository changes

Layer / File(s) Summary
Readiness implementation plan
docs/superpowers/plans/..., .gitignore
Adds the readiness-gap implementation plan and ignores .superpowers/.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MCPTest
  participant MCPClient
  participant DevsyMCPServer
  participant Workspace
  MCPTest->>MCPClient: StartMCPServer
  MCPClient->>DevsyMCPServer: initialize over stdio
  MCPTest->>MCPClient: CallTool workspace_exec
  MCPClient->>DevsyMCPServer: JSON-RPC tool request
  DevsyMCPServer->>Workspace: execute workspace command
  Workspace-->>DevsyMCPServer: command result
  DevsyMCPServer-->>MCPClient: JSON-RPC response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% 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
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the MCP concurrency and workspace execution changes, which are central to the pull request.

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.

@netlify

netlify Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploy Preview for images-devsy-sh canceled.

Name Link
🔨 Latest commit 700a317
🔍 Latest deploy log https://app.netlify.com/projects/images-devsy-sh/deploys/6a70e99808a44b0008e23c97

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (6)
pkg/workspace/exec_test.go (1)

200-243: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Test the production lock lifecycle.

These tests call acquireExecLock directly. They do not execute resolveExecTarget or ExecOneShot.

They will pass if resolvedExecTarget.unlock is not populated, if a post-lock resolution failure leaks the lock, or if ExecOneShot no longer defers resolved.unlock().

Add an injectable workspace and runtime seam. Assert one unlock after successful execution and after each failure that occurs after lock acquisition.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/workspace/exec_test.go` around lines 200 - 243, Replace the direct
acquireExecLock tests with production-path tests that exercise resolveExecTarget
and ExecOneShot through injectable workspace and runtime seams. Verify
resolvedExecTarget.unlock is populated, ExecOneShot unlocks exactly once after
successful execution, and every failure after lock acquisition also unlocks
exactly once; retain the lock-failure assertion that no unlock occurs when
acquisition fails.
pkg/provider/atomic_test.go (1)

70-85: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Rename the test to match what it proves.

The test reads the file after WriteFileAtomic returns. This verifies content visibility, but it does not prove crash durability or exercise a failed syncDir. It also passes on Windows, where syncDir is a no-op.

At minimum, use a name such as TestWriteFileAtomic_SucceedsAndPreservesData. Add separate platform-specific or failure-injection coverage if crash durability requires direct regression testing.

Proposed rename
-func TestWriteFileAtomic_SucceedsAndDataIsDurable(t *testing.T) {
+func TestWriteFileAtomic_SucceedsAndPreservesData(t *testing.T) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/provider/atomic_test.go` around lines 70 - 85, Rename
TestWriteFileAtomic_SucceedsAndDataIsDurable to reflect that it verifies
successful writing and preserved file content, such as
TestWriteFileAtomic_SucceedsAndPreservesData. Do not describe this test as
proving crash durability; leave platform-specific or failure-injection coverage
outside this change.
e2e/tests/mcp/helper.go (1)

10-25: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Register the temp-dir cleanup before the provider setup.

If framework.SetupDockerProvider fails at line 17, the function returns before line 21. The temporary directory created at line 11 then stays on disk for the whole CI run. Move the CleanupTempDir registration directly after CopyToTempDir.

♻️ Proposed refactor
 	tempDir, err := framework.CopyToTempDir(testdataPath)
 	if err != nil {
 		return "", nil, err
 	}
+	ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir)
 
 	f, err := framework.SetupDockerProvider(initialDir+"/bin", "docker")
 	if err != nil {
 		return "", nil, err
 	}
 
-	ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir)
 	ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, tempDir)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/tests/mcp/helper.go` around lines 10 - 25, Move the ginkgo.DeferCleanup
registration for framework.CleanupTempDir immediately after the successful
framework.CopyToTempDir call in setupWorkspace, before
framework.SetupDockerProvider runs; keep the provider cleanup registration after
successful setup.
e2e/framework/mcp.go (2)

45-58: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Capture the server stderr for diagnostics.

cmd.Stderr stays nil, so the subprocess stderr goes to /dev/null. If devsy mcp serve fails to start or panics, the test reports only an EOF from readResponse. Attach a buffer or ginkgo.GinkgoWriter and include its content in handshake errors.

♻️ Proposed refactor
 	cmd := exec.CommandContext(ctx, filepath.Join(f.DevsyBinDir, f.DevsyBinName), "mcp", "serve")
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
 	stdinPipe, err := cmd.StdinPipe()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/framework/mcp.go` around lines 45 - 58, Update StartMCPServer to attach
the subprocess stderr to a diagnostic buffer or GinkgoWriter before cmd.Start.
Include the captured stderr content when reporting handshake/readResponse
failures, while preserving the existing startup error handling.

100-114: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Correlate the response ID with the request ID.

readResponse returns the next line on stdout. The code assumes that line is the response for this request. If the server writes a notification or any other JSON-RPC message, CallTool decodes the wrong payload and the test fails with a confusing message. Compare resp.ID with the sent ID, or skip lines whose id is 0.

♻️ Proposed refactor
-	if err := c.send(jsonRPCRequest{
+	id := c.nextID.Add(1)
+	if err := c.send(jsonRPCRequest{
 		JSONRPC: jsonRPCVersion,
-		ID:      c.nextID.Add(1),
+		ID:      id,
 		Method:  "tools/call",
 		Params:  map[string]any{"name": name, "arguments": args},
 	}); err != nil {
 		return nil, false, err
 	}
 	resp, err := c.readResponse()
 	if err != nil {
 		return nil, false, err
 	}
+	if resp.ID != id {
+		return nil, false, fmt.Errorf("response id mismatch: want %d, got %d", id, resp.ID)
+	}
 	if resp.Error != nil {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/framework/mcp.go` around lines 100 - 114, Update CallTool to retain the
request ID generated for the tools/call request and verify that the response
returned by readResponse has the same ID before processing resp.Error or the
result. Skip notification or unrelated JSON-RPC messages, including responses
with ID 0, and continue reading until the matching response is received.
e2e/tests/mcp/mcp.go (1)

30-35: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert that the fixture workspace appears in the list.

The current check only requires a non-empty list. Any workspace left over from another spec or from the developer machine satisfies it. The spec title states that it lists a running workspace, so assert that the fixture workspace is present in the result.

♻️ Proposed refactor
 			workspaces, ok := listResult["workspaces"].([]any)
 			gomega.Expect(ok).To(gomega.BeTrue())
 			gomega.Expect(workspaces).NotTo(gomega.BeEmpty())
+			names := []string{}
+			for _, w := range workspaces {
+				entry, isMap := w.(map[string]any)
+				gomega.Expect(isMap).To(gomega.BeTrue())
+				name, _ := entry["name"].(string)
+				names = append(names, name)
+			}
+			gomega.Expect(names).To(gomega.ContainElement(filepath.Base(tempDir)))

Add the path/filepath import. Adjust the expected value if workspace_exec resolves names differently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/tests/mcp/mcp.go` around lines 30 - 35, Update the workspace_list
assertions in the MCP test to verify that the fixture workspace is present,
rather than only requiring a non-empty result. Derive the expected workspace
name or path from the existing fixture configuration, using filepath as needed,
and assert that one entry matches it while preserving the existing tool error
checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/mcp/semaphore.go`:
- Around line 27-32: Update acquire in cmd/mcp/semaphore.go#L27-L32 to check
ctx.Err() before selecting, and recheck after acquiring a slot; if cancellation
is detected, release the token and return the context error. Add a test in
cmd/mcp/semaphore_test.go#L82-L96 covering a pre-canceled context with available
semaphore capacity, verifying no permit remains consumed.

In `@cmd/mcp/serve_test.go`:
- Around line 62-132: Reduce the cyclomatic complexity of
TestServer_WorkspaceExecRespectsSemaphore below the configured limit by
extracting either the MCP server/client setup or the res.IsError content
inspection into a focused helper. Preserve the test’s semaphore assertions and
existing failure messages, and keep the helper anchored to the setup or
result-validation logic rather than changing behavior.

In `@e2e/framework/mcp.go`:
- Around line 70-93: Update StartMCPServer’s handshake error paths to invoke
closeFn before returning after cmd.Start succeeds, ensuring the subprocess and
pipe goroutines are cleaned up. Capture the initialize response from
readResponse and validate its JSON-RPC error field before sending
notifications/initialized; return a descriptive error when initialization fails,
while preserving successful handshake behavior.

---

Nitpick comments:
In `@e2e/framework/mcp.go`:
- Around line 45-58: Update StartMCPServer to attach the subprocess stderr to a
diagnostic buffer or GinkgoWriter before cmd.Start. Include the captured stderr
content when reporting handshake/readResponse failures, while preserving the
existing startup error handling.
- Around line 100-114: Update CallTool to retain the request ID generated for
the tools/call request and verify that the response returned by readResponse has
the same ID before processing resp.Error or the result. Skip notification or
unrelated JSON-RPC messages, including responses with ID 0, and continue reading
until the matching response is received.

In `@e2e/tests/mcp/helper.go`:
- Around line 10-25: Move the ginkgo.DeferCleanup registration for
framework.CleanupTempDir immediately after the successful
framework.CopyToTempDir call in setupWorkspace, before
framework.SetupDockerProvider runs; keep the provider cleanup registration after
successful setup.

In `@e2e/tests/mcp/mcp.go`:
- Around line 30-35: Update the workspace_list assertions in the MCP test to
verify that the fixture workspace is present, rather than only requiring a
non-empty result. Derive the expected workspace name or path from the existing
fixture configuration, using filepath as needed, and assert that one entry
matches it while preserving the existing tool error checks.

In `@pkg/provider/atomic_test.go`:
- Around line 70-85: Rename TestWriteFileAtomic_SucceedsAndDataIsDurable to
reflect that it verifies successful writing and preserved file content, such as
TestWriteFileAtomic_SucceedsAndPreservesData. Do not describe this test as
proving crash durability; leave platform-specific or failure-injection coverage
outside this change.

In `@pkg/workspace/exec_test.go`:
- Around line 200-243: Replace the direct acquireExecLock tests with
production-path tests that exercise resolveExecTarget and ExecOneShot through
injectable workspace and runtime seams. Verify resolvedExecTarget.unlock is
populated, ExecOneShot unlocks exactly once after successful execution, and
every failure after lock acquisition also unlocks exactly once; retain the
lock-failure assertion that no unlock occurs when acquisition fails.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 158a6d82-9b08-4581-bae1-7882041c9076

📥 Commits

Reviewing files that changed from the base of the PR and between 564708e and d98626b.

📒 Files selected for processing (23)
  • .gitignore
  • cmd/mcp/semaphore.go
  • cmd/mcp/semaphore_test.go
  • cmd/mcp/serve.go
  • cmd/mcp/serve_test.go
  • cmd/mcp/tools_exec.go
  • cmd/mcp/tools_workspace.go
  • cmd/workspace/up/up_test.go
  • cmd/workspace/up/up_validate.go
  • docs/superpowers/plans/2026-08-03-devsy-mcp-readiness-gaps.md
  • e2e/e2e_suite_test.go
  • e2e/framework/mcp.go
  • e2e/tests/ide/skip_launch_no_install.go
  • e2e/tests/mcp/helper.go
  • e2e/tests/mcp/mcp.go
  • e2e/tests/mcp/testdata/basic/.devcontainer/devcontainer.json
  • pkg/flags/names/names.go
  • pkg/provider/atomic.go
  • pkg/provider/atomic_posix.go
  • pkg/provider/atomic_test.go
  • pkg/provider/atomic_windows.go
  • pkg/workspace/exec.go
  • pkg/workspace/exec_test.go

Comment thread cmd/mcp/semaphore.go
Comment on lines +27 to +32
select {
case s.slots <- struct{}{}:
return func() { <-s.slots }, nil
case <-ctx.Done():
return nil, fmt.Errorf("waiting for a free operation slot: %w", ctx.Err())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reject an already-canceled context before acquiring a slot.

When ctx is already canceled and a slot is free, both cases in Line 27 are ready. Go can select the channel send. The handler can then start a canceled workspace operation and consume a permit.

  • cmd/mcp/semaphore.go#L27-L32: Check ctx.Err() before the select. If cancellation is observed after the channel send, remove the token and return the context error.
  • cmd/mcp/semaphore_test.go#L82-L96: Add a test that cancels a context before calling acquire while semaphore capacity is available.
📍 Affects 2 files
  • cmd/mcp/semaphore.go#L27-L32 (this comment)
  • cmd/mcp/semaphore_test.go#L82-L96
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/mcp/semaphore.go` around lines 27 - 32, Update acquire in
cmd/mcp/semaphore.go#L27-L32 to check ctx.Err() before selecting, and recheck
after acquiring a slot; if cancellation is detected, release the token and
return the context error. Add a test in cmd/mcp/semaphore_test.go#L82-L96
covering a pre-canceled context with available semaphore capacity, verifying no
permit remains consumed.

Comment thread cmd/mcp/serve_test.go
Comment thread e2e/framework/mcp.go
Comment on lines +70 to +93
if err := c.send(jsonRPCRequest{
JSONRPC: jsonRPCVersion,
ID: c.nextID.Add(1),
Method: "initialize",
Params: map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "devsy-e2e", "version": "0.1"},
},
}); err != nil {
return nil, err
}
if _, err := c.readResponse(); err != nil {
return nil, fmt.Errorf("initialize handshake: %w", err)
}
if err := c.send(jsonRPCRequest{
JSONRPC: jsonRPCVersion,
Method: "notifications/initialized",
}); err != nil {
return nil, err
}

return c, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Clean up the subprocess when the handshake fails, and check the initialize error.

Two problems exist in this block:

  1. Each early return at lines 80, 83, and 89 happens after cmd.Start() succeeded. The function never calls closeFn, so stdin stays open and cmd.Wait is never called. The subprocess and its pipe goroutines stay alive until the test context ends.
  2. The code discards the initialize response body. If the server returns a JSON-RPC error for initialize, StartMCPServer reports success. The failure then appears later in an unrelated CallTool assertion.
🔧 Proposed fix
-	if err := c.send(jsonRPCRequest{
+	fail := func(err error) (*MCPClient, error) {
+		_ = c.Close()
+		return nil, err
+	}
+
+	if err := c.send(jsonRPCRequest{
 		JSONRPC: jsonRPCVersion,
 		ID:      c.nextID.Add(1),
 		Method:  "initialize",
 		Params: map[string]any{
 			"protocolVersion": "2024-11-05",
 			"capabilities":    map[string]any{},
 			"clientInfo":      map[string]any{"name": "devsy-e2e", "version": "0.1"},
 		},
 	}); err != nil {
-		return nil, err
+		return fail(err)
 	}
-	if _, err := c.readResponse(); err != nil {
-		return nil, fmt.Errorf("initialize handshake: %w", err)
+	resp, err := c.readResponse()
+	if err != nil {
+		return fail(fmt.Errorf("initialize handshake: %w", err))
+	}
+	if resp.Error != nil {
+		return fail(fmt.Errorf(
+			"initialize handshake: jsonrpc error %d: %s", resp.Error.Code, resp.Error.Message))
 	}
 	if err := c.send(jsonRPCRequest{
 		JSONRPC: jsonRPCVersion,
 		Method:  "notifications/initialized",
 	}); err != nil {
-		return nil, err
+		return fail(err)
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if err := c.send(jsonRPCRequest{
JSONRPC: jsonRPCVersion,
ID: c.nextID.Add(1),
Method: "initialize",
Params: map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "devsy-e2e", "version": "0.1"},
},
}); err != nil {
return nil, err
}
if _, err := c.readResponse(); err != nil {
return nil, fmt.Errorf("initialize handshake: %w", err)
}
if err := c.send(jsonRPCRequest{
JSONRPC: jsonRPCVersion,
Method: "notifications/initialized",
}); err != nil {
return nil, err
}
return c, nil
}
fail := func(err error) (*MCPClient, error) {
_ = c.Close()
return nil, err
}
if err := c.send(jsonRPCRequest{
JSONRPC: jsonRPCVersion,
ID: c.nextID.Add(1),
Method: "initialize",
Params: map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "devsy-e2e", "version": "0.1"},
},
}); err != nil {
return fail(err)
}
resp, err := c.readResponse()
if err != nil {
return fail(fmt.Errorf("initialize handshake: %w", err))
}
if resp.Error != nil {
return fail(fmt.Errorf(
"initialize handshake: jsonrpc error %d: %s", resp.Error.Code, resp.Error.Message))
}
if err := c.send(jsonRPCRequest{
JSONRPC: jsonRPCVersion,
Method: "notifications/initialized",
}); err != nil {
return fail(err)
}
return c, nil
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/framework/mcp.go` around lines 70 - 93, Update StartMCPServer’s handshake
error paths to invoke closeFn before returning after cmd.Start succeeds,
ensuring the subprocess and pipe goroutines are cleaned up. Capture the
initialize response from readResponse and validate its JSON-RPC error field
before sending notifications/initialized; return a descriptive error when
initialization fails, while preserving successful handshake behavior.

TestServer_WorkspaceExecRespectsSemaphore scored 9 against the repo's
cyclop limit of 8 after the CodeRabbit fix wave rewrote it into a full
MCP round-trip test. CI's Lint check caught it since the local pre-push
check only covers new-from-rev at the branch's own base, not main's
current tip.
Comments across this branch had drifted toward restating behavior
already clear from names/signatures, or explaining implementation-plan
mechanics (e.g. "Step 3") with no meaning outside that planning
session. Cut to one-liners covering only genuinely non-obvious
rationale (asymmetric lock timeouts, POSIX/Windows fsync split, the
IDELaunch/IDE-name gating mismatch).

Also removes docs/superpowers/plans/2026-08-03-devsy-mcp-readiness-gaps.md
— planning artifacts from the superpowers skill workflow are local
working files, not project documentation, and should never land in a
shipped diff.
e2e/framework/mcp.go's MCPClient.readResponse read exactly one line
after each request, assuming it was that request's response. Tool
calls that stream log progress (workspace_create/workspace_start)
interleave id-less notifications on the same stdout stream, so a
future spec exercising those would have decoded a notification as the
response. Now reads until a response with the matching id arrives.

Also: CallTool wasn't safe for concurrent use (shared stdin/stdout
with no synchronization), and a handshake failure in StartMCPServer
leaked the subprocess instead of killing it. Both fixed.

opSemaphore.acquire had a narrow race: a context that's cancelled
between winning the select and returning would hand out a slot anyway.
Checked before and after acquiring.

Found by a second CodeRabbit CLI pass after the initial fix wave.
@skevetter
skevetter marked this pull request as draft August 3, 2026 15:35
CallTool accepted a context but readResponseFor's blocking ReadBytes
ignored it, so a hung devsy mcp serve process would block a caller
past its own timeout — and since c.mu stays held for the call's
duration, every later CallTool on that client would also hang forever
behind the same unreleased lock.

readResponseForCtx now races the read against ctx.Done(). bufio.Reader
isn't safe for concurrent use, so a cancellation that fires mid-read
can't simply abandon that goroutine and let a later call start a second
read on the same stream — instead the client is marked poisoned and
every subsequent call fails fast.

Verified against a real devsy mcp serve subprocess: an already-expired
context aborts in ~85µs (not a multi-second hang), and the next call
on the poisoned client fails immediately rather than reading state
left by the abandoned goroutine.

Found by a third CodeRabbit CLI pass.
main merged a lint-config change (0eeed9a, #868) enabling
revive.nested-structs while this branch was in flight, so CI's Lint
job — which resolves against main's current tip — flagged the
anonymous struct in jsonRPCResponse.Error that this branch's own
.golangci.yaml doesn't yet know about. Same fix either way: named type,
no behavior change.
@skevetter skevetter changed the title fix: MCP exec safety, concurrency gating, and readiness-gap fixes for external-agent orchestration feat(mcp): improve concurrent workspace execution Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant