Files
SivanCola affb71f9dd fix(lease): release provisional paths before mutation entry
Problem:
A refused write can pin a never-executed path through later model requests.
Repeated failed restoration can also report success to an expired caller.

Root cause:
Admission merges every request into the run-retained extent before local
preparation succeeds. Call cleanup only decrements in-flight accounting.
Resume keeps its once-only result in per-invocation locals, and refused
upgrades omit the terminal wait notice after registrar contention.

Fix:
Track identified provisional calls separately from retained extents and
publish their remaining union through the existing claim registrar. Retain
before possibly writing hooks or execution. Preserve earlier writes and
parallel claims, report failed cleanup conservatively, validate lifetimes
before and after reacquisition, and cache terminal callback outcomes.
Close registrar waits on refusal and clear completed cleanup's wait state.

Verification:
Complete 238-package inventory and required builtin/boot pass on the final
source; full workspacelease/agent race and focused real boot race pass.
Compiling original execution, resume and upgrade-wait owners fail the
selected deterministic regressions. Formatting, vet, lint and check pass.
Local platform evidence is macOS only; hosted checks and review are separate.

Refs #14025
Refs #14133
2026-10-25 05:36:29 +08:00

81 lines
2.5 KiB
Go

package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"reasonix/internal/contract/event"
"reasonix/internal/contract/tool"
"reasonix/internal/runtime/writeclaim"
"reasonix/internal/state/workspacelease"
)
func (a *Agent) workspaceWritePaths(plan *toolCallPlan) []string {
if toolHooksMayMutateWorkspace(a.svc.hooks) || plan.runTool == nil {
return nil
}
if !tool.WritesNamedPaths(plan.runTool) {
return nil
}
writer, ok := plan.runTool.(tool.WritePathResolver)
if !ok {
return nil
}
paths, err := writer.WritePaths(plan.runArgs)
if err != nil {
return nil
}
return paths
}
func workspaceLeaseConflictScope(err error) *event.WorkspaceLease {
var conflict *workspacelease.ConflictError
if !errors.As(err, &conflict) {
return nil
}
return &event.WorkspaceLease{Holder: conflict.Holder, HolderSessionID: conflict.SessionID,
Paths: conflict.Paths, RequestedPaths: conflict.RequestedPaths, Repository: conflict.Repository}
}
func workspaceLeaseRefusalCode(err error) string {
if errors.Is(err, workspacelease.ErrClaimTimeout) {
return workspacelease.CodeClaimTimeout
}
if errors.Is(err, workspacelease.ErrConflict) {
return workspacelease.CodeWriteConflict
}
return ""
}
func (a *Agent) parentToolWriteReservation(writer tool.Tool, args json.RawMessage) (writeclaim.WritePathSet, error) {
if resolver, ok := writer.(tool.WritePathResolver); ok {
paths, err := resolver.WritePaths(args)
if err != nil {
return writeclaim.WholeWorkspaceWriteClaim(a.writeWorkspaceRoot)
}
return parentResolvedWriteReservation(a.writeWorkspaceRoot, paths)
}
return parentWriteReservation(a.writeWorkspaceRoot, writer.Name(), args)
}
// holdWorkspaceLease provisionally claims paths before local preparation.
// Only entry into writing hooks or execution retains them for the run.
func (a *Agent) holdWorkspaceLease(ctx context.Context, plan *toolCallPlan) (context.Context, toolOutcome, bool) {
if !plan.mutates || a.svc.workspaceLease == nil {
return ctx, toolOutcome{}, false
}
heldCtx, preparation, err := a.svc.workspaceLease.PreparePaths(ctx, a.workspaceWritePaths(plan))
if err != nil {
return ctx, toolOutcome{
output: fmt.Sprintf("blocked: %v", err),
blocked: true,
errMsg: "blocked: workspace write lease unavailable",
refusalCode: workspaceLeaseRefusalCode(err),
workspaceLease: workspaceLeaseConflictScope(err),
}, true
}
plan.leasePreparation = preparation
return heldCtx, toolOutcome{}, false
}