iop/apps/edge/internal/service/single_request_artifact.go
toki d7a150c7fe feat(agent): 단일 요청 실행 경로를 완성한다
Claude의 단일 Anthropic 요청 안에서 IOP가 Plan, Work, Review와 workspace 도구 실행을 끝내고 실제 dev smoke로 계약을 검증할 수 있어야 한다.\n\n완료 task evidence와 마일스톤 검토 상태도 같은 변경에 고정한다.
2026-08-08 23:35:13 +09:00

297 lines
9.8 KiB
Go

package service
import (
"context"
"errors"
"time"
iop "iop/proto/gen/iop"
)
var (
ErrSingleRequestInternalArtifactUnavailable = errors.New("single-request internal artifact is unavailable")
ErrSingleRequestInternalArtifactInvalid = errors.New("single-request internal artifact request is invalid")
ErrSingleRequestInternalArtifactBudget = errors.New("single-request internal artifact budget exceeded")
ErrSingleRequestInternalArtifactFailed = errors.New("single-request internal artifact operation failed")
ErrSingleRequestWorkspaceOpen = errors.New("single-request workspace open failed")
)
// SingleRequestArtifactKind is the coordinator-facing closed artifact set. It
// deliberately carries no filename or relative path.
type SingleRequestArtifactKind string
const (
SingleRequestArtifactPlan SingleRequestArtifactKind = "plan"
SingleRequestArtifactReview SingleRequestArtifactKind = "review"
)
type singleRequestWorkspaceArtifactRuntime interface {
workspaceArtifact(context.Context, *SingleRequestWorkspaceBinding, *iop.WorkspaceArtifactRequest, int) (*iop.WorkspaceArtifactResponse, error)
}
type singleRequestArtifactOperation struct {
ctx context.Context
cancel context.CancelFunc
runtime singleRequestWorkspaceArtifactRuntime
openRuntime singleRequestWorkspaceToolRuntime
binding *SingleRequestWorkspaceBinding
kind iop.WorkspaceArtifactKind
maximum int
complete func()
}
func (h *singleRequestHandle) ReadInternalArtifact(ctx context.Context, kind SingleRequestArtifactKind) ([]byte, error) {
operation, err := h.prepareSingleRequestArtifact(ctx, kind, 0)
if err != nil {
h.failSingleRequestArtifact(ctx, err)
return nil, err
}
defer operation.cancel()
defer operation.complete()
if err := operation.ctx.Err(); err != nil {
return nil, h.finishSingleRequestArtifactFailure(operation.ctx, err)
}
if err := h.ensureSingleRequestWorkspaceOpen(operation.ctx, operation.openRuntime, operation.binding); err != nil {
return nil, h.finishSingleRequestArtifactFailure(operation.ctx, err)
}
if err := operation.ctx.Err(); err != nil {
return nil, h.finishSingleRequestArtifactFailure(operation.ctx, err)
}
response, err := operation.runtime.workspaceArtifact(operation.ctx, operation.binding, &iop.WorkspaceArtifactRequest{
RequestId: h.req.RequestID,
Kind: operation.kind,
Operation: iop.WorkspaceArtifactOperation_WORKSPACE_ARTIFACT_OPERATION_READ,
}, operation.maximum)
if err != nil || response.GetStatus() != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
return nil, h.finishSingleRequestArtifactFailure(operation.ctx, err)
}
if err := operation.ctx.Err(); err != nil {
return nil, h.finishSingleRequestArtifactFailure(operation.ctx, err)
}
return append([]byte(nil), response.GetContent()...), nil
}
func (h *singleRequestHandle) WriteInternalArtifact(ctx context.Context, kind SingleRequestArtifactKind, content []byte) error {
operation, err := h.prepareSingleRequestArtifact(ctx, kind, len(content))
if err != nil {
h.failSingleRequestArtifact(ctx, err)
return err
}
defer operation.cancel()
defer operation.complete()
if err := operation.ctx.Err(); err != nil {
return h.finishSingleRequestArtifactFailure(operation.ctx, err)
}
if err := h.ensureSingleRequestWorkspaceOpen(operation.ctx, operation.openRuntime, operation.binding); err != nil {
return h.finishSingleRequestArtifactFailure(operation.ctx, err)
}
if err := operation.ctx.Err(); err != nil {
return h.finishSingleRequestArtifactFailure(operation.ctx, err)
}
response, err := operation.runtime.workspaceArtifact(operation.ctx, operation.binding, &iop.WorkspaceArtifactRequest{
RequestId: h.req.RequestID,
Kind: operation.kind,
Operation: iop.WorkspaceArtifactOperation_WORKSPACE_ARTIFACT_OPERATION_WRITE,
Content: append([]byte(nil), content...),
}, operation.maximum)
if err != nil || response.GetStatus() != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
return h.finishSingleRequestArtifactFailure(operation.ctx, err)
}
if err := operation.ctx.Err(); err != nil {
return h.finishSingleRequestArtifactFailure(operation.ctx, err)
}
return nil
}
func (h *singleRequestHandle) prepareSingleRequestArtifact(ctx context.Context, kind SingleRequestArtifactKind, contentBytes int) (*singleRequestArtifactOperation, error) {
if ctx == nil {
return nil, ErrSingleRequestInternalArtifactInvalid
}
if err := ctx.Err(); err != nil {
return nil, err
}
protoKind, ok := singleRequestArtifactProtoKind(kind)
if !ok {
return nil, ErrSingleRequestInternalArtifactInvalid
}
h.mu.Lock()
if isTerminalState(h.state) || h.state == SingleRequestStateFinalizing {
h.mu.Unlock()
return nil, ErrSingleRequestTerminal
}
if h.state == SingleRequestStateInternalTool || h.activeStageIDLocked() == "" || h.toolLoop.stageDeadline.IsZero() {
h.mu.Unlock()
return nil, ErrSingleRequestInternalArtifactInvalid
}
openRuntime := h.toolLoop.runtime
runtime, ok := openRuntime.(singleRequestWorkspaceArtifactRuntime)
binding := h.binding.Workspace.Clone()
deadline := h.toolLoop.stageDeadline
if !ok || runtime == nil || binding == nil {
h.mu.Unlock()
return nil, ErrSingleRequestInternalArtifactUnavailable
}
maximum := binding.Limits.MaxOutputBytes
if maximum < 1 || contentBytes < 0 || contentBytes > maximum {
h.mu.Unlock()
return nil, ErrSingleRequestInternalArtifactBudget
}
if !time.Now().Before(deadline) {
h.mu.Unlock()
return nil, ErrSingleRequestInternalArtifactBudget
}
h.toolWg.Add(1)
h.toolWork++
h.mu.Unlock()
operationCtx, cancel := singleRequestArtifactContext(h.execCtx, ctx, deadline)
return &singleRequestArtifactOperation{
ctx: operationCtx, cancel: cancel, runtime: runtime, openRuntime: openRuntime, binding: binding,
kind: protoKind, maximum: maximum,
complete: func() {
h.mu.Lock()
h.toolWork--
h.mu.Unlock()
h.toolWg.Done()
},
}, nil
}
func singleRequestArtifactProtoKind(kind SingleRequestArtifactKind) (iop.WorkspaceArtifactKind, bool) {
switch kind {
case SingleRequestArtifactPlan:
return iop.WorkspaceArtifactKind_WORKSPACE_ARTIFACT_KIND_PLAN, true
case SingleRequestArtifactReview:
return iop.WorkspaceArtifactKind_WORKSPACE_ARTIFACT_KIND_REVIEW, true
default:
return iop.WorkspaceArtifactKind_WORKSPACE_ARTIFACT_KIND_UNSPECIFIED, false
}
}
func singleRequestArtifactContext(execution, caller context.Context, stageDeadline time.Time) (context.Context, context.CancelFunc) {
deadline := stageDeadline
if callerDeadline, ok := caller.Deadline(); ok && callerDeadline.Before(deadline) {
deadline = callerDeadline
}
ctx, cancel := context.WithDeadline(execution, deadline)
stop := context.AfterFunc(caller, cancel)
return ctx, func() {
stop()
cancel()
}
}
func (h *singleRequestHandle) finishSingleRequestArtifactFailure(ctx context.Context, _ error) error {
err := ErrSingleRequestInternalArtifactFailed
if ctx != nil && ctx.Err() != nil {
err = ctx.Err()
}
h.failSingleRequestArtifact(ctx, err)
return err
}
func (h *singleRequestHandle) failSingleRequestArtifact(ctx context.Context, err error) {
fallback := singleRequestErrorClassFromErr(err)
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrSingleRequestInternalArtifactBudget) {
fallback = singleRequestErrorClassTimeout
}
outcome, errorClass := h.classifyChildOperationContext(ctx, fallback)
h.mu.Lock()
defer h.mu.Unlock()
if isTerminalState(h.state) || h.state == SingleRequestStateFinalizing {
return
}
if outcome == singleRequestOutcomeCancel {
h.cancelLocked()
return
}
if errorClass == singleRequestErrorClassInternalToolBudget {
h.failLockedWithErrorClass(ErrSingleRequestInternalToolBudget, singleRequestErrorClassInternalToolBudget)
return
}
if errorClass == singleRequestErrorClassTimeout {
h.failLockedWithErrorClass(ErrSingleRequestInternalArtifactBudget, singleRequestErrorClassTimeout)
return
}
h.failLockedWithErrorClass(err, errorClass)
}
// ensureSingleRequestWorkspaceOpen serializes the one permitted open attempt
// across model-tool and internal-artifact callers. A failed attempt is cached so
// no racing caller can silently send a second open on the same request.
func (h *singleRequestHandle) ensureSingleRequestWorkspaceOpen(ctx context.Context, runtime singleRequestWorkspaceToolRuntime, binding *SingleRequestWorkspaceBinding) error {
for {
if err := ctx.Err(); err != nil {
return err
}
h.mu.Lock()
if h.toolLoop.opened {
h.mu.Unlock()
return nil
}
if h.toolLoop.opening {
done := h.toolLoop.openDone
h.mu.Unlock()
select {
case <-done:
continue
case <-ctx.Done():
return ctx.Err()
}
}
if h.toolLoop.openAttempted {
err := h.toolLoop.openErr
if err == nil {
err = ErrSingleRequestWorkspaceOpen
}
h.mu.Unlock()
return err
}
if runtime == nil || binding == nil {
h.mu.Unlock()
return ErrSingleRequestWorkspaceOpen
}
h.toolLoop.openAttempted = true
h.toolLoop.opening = true
h.toolLoop.openDone = make(chan struct{})
done := h.toolLoop.openDone
h.mu.Unlock()
response, err := runtime.workspaceOpen(ctx, binding, &iop.WorkspaceOpenRequest{
RequestId: h.req.RequestID, WorkspaceRef: binding.Ref,
TimeoutMs: singleRequestContextRemainingMilliseconds(ctx),
})
accepted := err == nil && response.GetStatus() == iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS
h.mu.Lock()
if accepted {
h.toolLoop.opened = true
h.toolLoop.openErr = nil
} else {
h.toolLoop.openErr = ErrSingleRequestWorkspaceOpen
}
h.toolLoop.opening = false
close(done)
h.mu.Unlock()
if !accepted {
if ctx.Err() != nil {
return ctx.Err()
}
return ErrSingleRequestWorkspaceOpen
}
if ctx.Err() != nil {
return ctx.Err()
}
return nil
}
}
func singleRequestContextRemainingMilliseconds(ctx context.Context) int64 {
deadline, ok := ctx.Deadline()
if !ok {
return 0
}
return internalToolRemainingMilliseconds(deadline)
}