Thread context through Provider.Execute and stop reporting read failures as unknown

Finding 6 of REVIEW-2026-07-30.md. runWithTimeout could not cancel anything,
because Provider.Execute took no context: the goroutine ran on to the HTTP
client's 60s timeout, outliving the 30s capability timeout. Provider.Execute
now takes a context carrying that timeout, and the workspace provider issues
its tool call with http.NewRequestWithContext, so a timed-out execution
actually tears the request down.

A read-only capability whose provider call times out now resolves to failed
rather than unknown. Spec §4.3 reserves unknown for executions whose side
effect may or may not have landed, and never retries them — which made read
failures both unretryable and indistinguishable from genuinely ambiguous
mutations, for calls that by definition have no side effect. Mutating
capabilities still resolve to unknown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uea55zaiWuEByEDC4UBSdd
This commit is contained in:
kami
2026-07-30 23:39:50 +04:00
parent 47be24c4cc
commit be08938f1f
5 changed files with 129 additions and 32 deletions
+6 -1
View File
@@ -1,6 +1,7 @@
package provider
import (
"context"
"fmt"
"sync"
@@ -9,7 +10,11 @@ import (
type Provider interface {
Name() string
Execute(capability *domain.Capability, req *domain.ExecuteRequest) (map[string]any, error)
// Execute performs the capability's side effect. The context carries the
// capability's timeout: implementations MUST propagate it into every
// blocking call they make so that a timed-out execution is genuinely
// cancelled rather than abandoned to run on in the background.
Execute(ctx context.Context, capability *domain.Capability, req *domain.ExecuteRequest) (map[string]any, error)
}
type Registry struct {
+16 -10
View File
@@ -2,6 +2,7 @@ package provider
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@@ -109,13 +110,7 @@ func (p *WorkspaceMCPProvider) DiscoverTools() ([]WorkspaceTool, error) {
return result.Tools, nil
}
func (p *WorkspaceMCPProvider) DiscoveredTools() []WorkspaceTool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.tools
}
func (p *WorkspaceMCPProvider) Execute(capability *domain.Capability, req *domain.ExecuteRequest) (map[string]any, error) {
func (p *WorkspaceMCPProvider) Execute(ctx context.Context, capability *domain.Capability, req *domain.ExecuteRequest) (map[string]any, error) {
toolName := p.capabilityToTool(capability.Name)
if toolName == "" {
return nil, fmt.Errorf("no workspace tool mapped for capability %q", capability.Name)
@@ -146,11 +141,22 @@ func (p *WorkspaceMCPProvider) Execute(capability *domain.Capability, req *domai
}
body, _ := json.Marshal(args)
resp, err := p.httpClient.Post(
// The caller's context carries the capability timeout. Building the
// request with it means a cancelled execution tears down the in-flight
// HTTP call instead of leaving it to run to the client's own (much
// longer) timeout.
httpReq, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
fmt.Sprintf("%s/api/tool/%s", p.baseURL, toolName),
"application/json",
bytes.NewReader(body),
)
if err != nil {
return nil, fmt.Errorf("build workspace tool request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("call workspace tool %q: %w", toolName, err)
}
@@ -163,7 +169,7 @@ func (p *WorkspaceMCPProvider) Execute(capability *domain.Capability, req *domai
if resp.StatusCode >= 400 {
return map[string]any{
"error": string(respBody),
"error": string(respBody),
"http_status": resp.StatusCode,
}, fmt.Errorf("workspace tool %q returned %d: %s", toolName, resp.StatusCode, string(respBody))
}
+3 -2
View File
@@ -1,6 +1,7 @@
package provider
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -29,7 +30,7 @@ func TestExecute_SucceedsOnPlainResult(t *testing.T) {
})
cap := &domain.Capability{Name: "ws.do_thing"}
result, err := p.Execute(cap, &domain.ExecuteRequest{})
result, err := p.Execute(context.Background(), cap, &domain.ExecuteRequest{})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -52,7 +53,7 @@ func TestExecute_ErrorEnvelopeReportedAsFailure(t *testing.T) {
})
cap := &domain.Capability{Name: "ws.do_thing"}
_, err := p.Execute(cap, &domain.ExecuteRequest{})
_, err := p.Execute(context.Background(), cap, &domain.ExecuteRequest{})
if err == nil {
t.Fatal("expected error for ERROR-coded warning envelope, got nil")
}