package service import ( "context" "slices" "strings" "time" iop "iop/proto/gen/iop" ) type singleRequestWorkspaceToolRuntime interface { workspaceOpen(context.Context, *SingleRequestWorkspaceBinding, *iop.WorkspaceOpenRequest) (*iop.WorkspaceOpenResponse, error) workspaceTool(context.Context, *SingleRequestWorkspaceBinding, *iop.WorkspaceToolRequest) (*iop.WorkspaceToolResponse, error) } // SingleRequestWorkspaceLifecycle is optional for executors that never open a // workspace. Once a workspace is open, terminal commit fails closed unless the // lifecycle can complete one typed cleanup for the immutable request. type SingleRequestWorkspaceLifecycle interface { CleanupWorkspace(context.Context, *SingleRequestWorkspaceBinding, string) error } type singleRequestToolUsage struct { iterations int outputBytes int } type singleRequestToolLoopState struct { continuation SingleRequestToolContinuation runtime singleRequestWorkspaceToolRuntime lifecycle SingleRequestWorkspaceLifecycle opened bool openAttempted bool opening bool openDone chan struct{} openErr error seenCallIDs map[string]struct{} usage map[string]singleRequestToolUsage pendingCallID string pendingResultReady bool stageID string stageDeadline time.Time stageEpoch uint64 stageTimer *time.Timer } type singleRequestPendingTool struct { request *iop.WorkspaceToolRequest stageID string deadline time.Time } func (h *singleRequestHandle) prepareInternalWorkspaceToolLocked(call *InternalWorkspaceToolCall) (*singleRequestPendingTool, error, singleRequestErrorClass) { if h.toolLoop.continuation == nil || h.toolLoop.runtime == nil { return nil, ErrSingleRequestInternalToolUnavailable, "" } if h.toolLoop.pendingCallID != "" { return nil, ErrSingleRequestInternalToolInvalidCall, "" } cloned := call.Clone() request, err := decodeInternalWorkspaceToolCall(cloned) if err != nil { return nil, err, "" } expectedStageID := canonicalSingleRequestStageID(h.state) if expectedStageID == "" || request.GetRequestId() != h.req.RequestID || request.GetStageId() != expectedStageID { return nil, ErrSingleRequestIdentityMismatch, "" } if h.binding == nil || h.binding.Workspace == nil || !h.internalWorkspaceToolCapabilityAllowed(request) { return nil, ErrSingleRequestInternalToolDenied, "" } if _, duplicate := h.toolLoop.seenCallIDs[request.GetToolCallId()]; duplicate { return nil, ErrSingleRequestInternalToolInvalidCall, "" } usage := h.toolLoop.usage[expectedStageID] if usage.iterations >= h.binding.Limits.MaxToolIterations || h.toolLoop.stageDeadline.IsZero() { return nil, ErrSingleRequestInternalToolBudget, "" } if !time.Now().Before(h.toolLoop.stageDeadline) { _, errorClass := h.classifyChildOperationContext(nil, singleRequestErrorClassTimeout) return nil, ErrSingleRequestInternalToolBudget, errorClass } usage.iterations++ h.toolLoop.usage[expectedStageID] = usage h.toolLoop.seenCallIDs[request.GetToolCallId()] = struct{}{} h.toolLoop.pendingCallID = request.GetToolCallId() h.toolLoop.pendingResultReady = false return &singleRequestPendingTool{ request: request, stageID: expectedStageID, deadline: h.toolLoop.stageDeadline, }, nil, "" } func (h *singleRequestHandle) internalWorkspaceToolCapabilityAllowed(request *iop.WorkspaceToolRequest) bool { workspace := h.binding.Workspace operationID := "" switch request.GetOperation() { case iop.WorkspaceOperation_WORKSPACE_OPERATION_READ: operationID = "read" case iop.WorkspaceOperation_WORKSPACE_OPERATION_LIST: operationID = "list" case iop.WorkspaceOperation_WORKSPACE_OPERATION_WRITE: operationID = "write" if write := request.GetWrite(); write == nil || len(write.GetContent()) > workspace.Limits.MaxWriteBytes { return false } case iop.WorkspaceOperation_WORKSPACE_OPERATION_DELETE: operationID = "delete" case iop.WorkspaceOperation_WORKSPACE_OPERATION_COMMAND: operationID = "command" if _, allowed := slices.BinarySearch(workspace.CommandIDs, request.GetCommandId()); !allowed { return false } environmentBytes := 0 for name, value := range request.GetEnvironment() { if _, allowed := slices.BinarySearch(workspace.EnvironmentNames, name); !allowed || strings.ContainsRune(value, 0) { return false } environmentBytes += len(name) + len(value) if environmentBytes > h.binding.Limits.MaxOutputBytes { return false } } default: return false } _, allowed := slices.BinarySearch(workspace.OperationIDs, operationID) return allowed } func (h *singleRequestHandle) executeInternalWorkspaceTool(pending *singleRequestPendingTool) { outcome := singleRequestOutcomeSuccess errorClass := singleRequestErrorClass("") toolObserved := false observeTool := func(observedOutcome singleRequestOutcome, observedErrorClass singleRequestErrorClass) { if toolObserved { return } h.mu.Lock() h.timing.onToolExit(observedOutcome, observedErrorClass) h.mu.Unlock() toolObserved = true } defer func() { observeTool(outcome, errorClass) }() if pending == nil || pending.request == nil { outcome, errorClass = singleRequestOutcomeError, singleRequestErrorClassValidation h.failInternalWorkspaceTool(ErrSingleRequestInternalToolInvalidCall) return } ctx, cancel := context.WithDeadline(h.execCtx, pending.deadline) defer cancel() h.mu.Lock() runtime := h.toolLoop.runtime continuation := h.toolLoop.continuation binding := h.binding.Workspace.Clone() h.mu.Unlock() if runtime == nil || continuation == nil || binding == nil { outcome, errorClass = singleRequestOutcomeError, singleRequestErrorClassProvider h.failInternalWorkspaceTool(ErrSingleRequestInternalToolUnavailable) return } if err := h.ensureSingleRequestWorkspaceOpen(ctx, runtime, binding); err != nil { outcome, errorClass = h.failInternalWorkspaceToolOutcome(ctx, singleRequestErrorClassInternalToolFailed, ErrSingleRequestInternalToolBudget) return } h.mu.Lock() terminal := isTerminalState(h.state) h.mu.Unlock() if terminal || ctx.Err() != nil { outcome, errorClass = h.classifyChildOperationContext(ctx, singleRequestErrorClassInternalToolFailed) return } pending.request.TimeoutMs = 0 if pending.request.GetOperation() == iop.WorkspaceOperation_WORKSPACE_OPERATION_COMMAND { pending.request.TimeoutMs = internalToolCommandTimeoutMilliseconds(pending.deadline, binding.Limits.MaxCommandTimeoutMS) } response, err := runtime.workspaceTool(ctx, binding, pending.request) if err != nil { outcome, errorClass = h.failInternalWorkspaceToolOutcome(ctx, singleRequestErrorClassInternalToolFailed, ErrSingleRequestInternalToolBudget) return } if response.GetStatus() != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS && response.GetErrorCode() != iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_NOT_FOUND { if response.GetStatus() == iop.WorkspaceStatus_WORKSPACE_STATUS_TIMEOUT || response.GetErrorCode() == iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_TIMEOUT { outcome, errorClass = h.failInternalWorkspaceToolOutcome(ctx, singleRequestErrorClassTimeout, ErrSingleRequestInternalToolFailed) return } outcome, errorClass = singleRequestOutcomeError, singleRequestErrorClassInternalToolFailed h.failInternalWorkspaceTool(ErrSingleRequestInternalToolFailed) return } result := internalWorkspaceToolResult(response) h.mu.Lock() if isTerminalState(h.state) { outcome, errorClass = h.classifyChildOperationContext(ctx, singleRequestErrorClassInternalToolFailed) h.mu.Unlock() return } if h.state != SingleRequestStateInternalTool || h.savedStage == "" || h.toolLoop.pendingCallID != result.ToolCallID || canonicalSingleRequestStageID(h.savedStage) != pending.stageID || result.RequestID != h.req.RequestID || result.StageID != pending.stageID { outcome, errorClass = singleRequestOutcomeError, singleRequestErrorClassValidation h.failLocked(ErrSingleRequestIdentityMismatch) h.mu.Unlock() return } usage := h.toolLoop.usage[pending.stageID] outputBytes := internalWorkspaceToolOutputBytes(result) if outputBytes < 0 || outputBytes > h.binding.Limits.MaxOutputBytes-usage.outputBytes { outcome, errorClass = singleRequestOutcomeError, singleRequestErrorClassInternalToolBudget h.failLocked(ErrSingleRequestInternalToolBudget) h.mu.Unlock() return } usage.outputBytes += outputBytes h.toolLoop.usage[pending.stageID] = usage h.toolLoop.pendingResultReady = true h.mu.Unlock() // Complete the successful workspace-tool observation before external // continuation code can synchronously advance the resumed provider stages. // The deferred closer remains responsible for every earlier failure path. observeTool(singleRequestOutcomeSuccess, "") if err := continuation.ContinueInternalTool(ctx, result.Clone()); err != nil { outcome, errorClass = h.failInternalWorkspaceToolOutcome(ctx, singleRequestErrorClassInternalToolFailed, ErrSingleRequestInternalToolBudget) } } // CleanupWorkspace maps the private wire terminal to one safe coordinator // outcome. Node error text and filesystem details never enter coordinator state. func (s *Service) CleanupWorkspace(ctx context.Context, binding *SingleRequestWorkspaceBinding, requestID string) error { response, err := s.workspaceCleanup(ctx, binding, &iop.WorkspaceCleanupRequest{RequestId: requestID}) if err != nil || response.GetStatus() != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS { return ErrSingleRequestWorkspaceCleanup } return nil } func (h *singleRequestHandle) failInternalWorkspaceToolOutcome(ctx context.Context, fallback singleRequestErrorClass, timeoutErr error) (singleRequestOutcome, singleRequestErrorClass) { outcome, errorClass := h.classifyChildOperationContext(ctx, fallback) h.mu.Lock() defer h.mu.Unlock() if isTerminalState(h.state) { return outcome, errorClass } switch { case outcome == singleRequestOutcomeCancel: h.cancelLocked() case errorClass == singleRequestErrorClassInternalToolBudget: h.failLockedWithErrorClass(ErrSingleRequestInternalToolBudget, singleRequestErrorClassInternalToolBudget) case errorClass == singleRequestErrorClassTimeout: if timeoutErr == nil { timeoutErr = ErrSingleRequestInternalToolBudget } h.failLockedWithErrorClass(timeoutErr, singleRequestErrorClassTimeout) default: h.failLockedWithErrorClass(ErrSingleRequestInternalToolFailed, errorClass) } return outcome, errorClass } func (h *singleRequestHandle) failInternalWorkspaceTool(err error) { h.failInternalWorkspaceToolWithErrorClass(err, "") } func (h *singleRequestHandle) failInternalWorkspaceToolWithErrorClass(err error, errorClass singleRequestErrorClass) { h.mu.Lock() if !isTerminalState(h.state) { h.failLockedWithErrorClass(err, errorClass) } h.mu.Unlock() } func (h *singleRequestHandle) activeStageIDLocked() string { if h.state == SingleRequestStateInternalTool { return canonicalSingleRequestStageID(h.savedStage) } return canonicalSingleRequestStageID(h.state) } func canonicalSingleRequestStageID(state SingleRequestState) string { switch state { case SingleRequestStatePlanning: return "plan" case SingleRequestStateWorking: return "work" case SingleRequestStateReviewing, SingleRequestStateRepairing: return "review" default: return "" } } func (h *singleRequestHandle) updateStageBudgetLocked(previousStageID string) { nextStageID := h.activeStageIDLocked() if previousStageID == nextStageID { return } h.stopStageBudgetLocked() h.toolLoop.stageID = nextStageID if nextStageID == "" { h.toolLoop.stageDeadline = time.Time{} return } h.toolLoop.stageDeadline = time.Now().Add(time.Duration(h.binding.Limits.StageTimeoutMS) * time.Millisecond) h.toolLoop.stageEpoch++ epoch := h.toolLoop.stageEpoch deadline := h.toolLoop.stageDeadline h.toolLoop.stageTimer = time.AfterFunc(time.Until(deadline), func() { h.mu.Lock() defer h.mu.Unlock() if isTerminalState(h.state) || h.toolLoop.stageEpoch != epoch || h.activeStageIDLocked() != nextStageID { return } h.failLockedWithErrorClass(ErrSingleRequestInternalToolBudget, singleRequestErrorClassTimeout) }) } func (h *singleRequestHandle) stopStageBudgetLocked() { if h.toolLoop.stageTimer != nil { h.toolLoop.stageTimer.Stop() h.toolLoop.stageTimer = nil } } func internalToolRemainingMilliseconds(deadline time.Time) int64 { remaining := time.Until(deadline) if remaining <= time.Millisecond { return 1 } return int64((remaining + time.Millisecond - 1) / time.Millisecond) } func internalToolCommandTimeoutMilliseconds(deadline time.Time, workspaceMaximum int) int64 { remaining := internalToolRemainingMilliseconds(deadline) if workspaceMaximum > 0 && int64(workspaceMaximum) < remaining { return int64(workspaceMaximum) } return remaining }