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
+32 -13
View File
@@ -218,14 +218,23 @@ func (e *Engine) Execute(req *domain.ExecuteRequest) (*ExecuteResult, error) {
switch {
case timedOut:
// Outcome is never "failed" on timeout, and never retried automatically
// per ECOSYSTEM-SPEC.md §4.3 — the side effect may or may not have landed.
exec.Status = domain.ExecutionUnknown
// ECOSYSTEM-SPEC.md §4.3 reserves "unknown" for executions whose side
// effect may or may not have landed — such an execution is never
// retried automatically. That reasoning only applies to mutations. A
// read_only capability has no side effect by definition, so a timed-out
// read is unambiguously a failure and is safe to retry; reporting it as
// "unknown" would both mislead and strand it.
exec.Error = "execution timed out after " + timeout.String()
exec.Result = map[string]any{"error": exec.Error}
outcome := "unknown"
exec.Status = domain.ExecutionUnknown
if capability.ReadOnly {
outcome = "failed"
exec.Status = domain.ExecutionFailed
}
e.emitEventCorrelated(domain.EventExecutionFailed, exec.ID, exec.CorrelationID, exec.CausationID, map[string]any{
"capability_id": req.CapabilityID,
"outcome": "unknown",
"outcome": outcome,
"reason": "timeout",
})
case execErr != nil:
@@ -251,25 +260,35 @@ func (e *Engine) Execute(req *domain.ExecuteRequest) (*ExecuteResult, error) {
return &ExecuteResult{Execution: exec}, nil
}
// runWithTimeout executes the provider call on a goroutine and returns
// (nil, nil, true) if it doesn't finish within timeout. The goroutine is not
// cancelled — providers don't accept a context today — so a slow executor
// keeps running in the background; its eventual result is discarded, never
// retried, and the caller has already moved on with outcome=unknown.
// runWithTimeout executes the provider call under a context carrying the
// capability's timeout, and returns (nil, nil, true) if it doesn't finish in
// time. Unlike the previous implementation, the context is handed to the
// provider, so a timeout genuinely cancels the underlying work (an in-flight
// HTTP request is torn down) rather than abandoning a goroutine that runs on
// to whatever longer timeout the provider's own client happens to use.
//
// The goroutine still exists — a provider that ignores its context can only
// be waited on, not killed — but the channel is buffered, so it can always
// deliver and exit; nothing is leaked permanently.
//
// This timeout is deliberately separate from, and downstream of, the bounded
// Nexus lookup in validateTarget: target validation has already completed by
// the time we get here, so the two never nest.
func (e *Engine) runWithTimeout(prov provider.Provider, capability *domain.Capability, req *domain.ExecuteRequest, timeout time.Duration) (map[string]any, error, bool) {
type outcome struct {
result map[string]any
err error
}
ch := make(chan outcome, 1)
go func() {
r, err := prov.Execute(capability, req)
ch <- outcome{result: r, err: err}
}()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
go func() {
r, err := prov.Execute(ctx, capability, req)
ch <- outcome{result: r, err: err}
}()
select {
case o := <-ch:
return o.result, o.err, false