승인된 execution preset을 Edge 조정 경계와 Node workspace/tool 실행 경계로 연결해 단일 요청 수명주기와 관측 계약을 일관되게 처리한다.
209 lines
7.6 KiB
Go
209 lines
7.6 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
type countingWorkspaceLifecycle struct {
|
|
openCount atomic.Int32
|
|
toolCount atomic.Int32
|
|
cleanupCount atomic.Int32
|
|
cleanupStart chan struct{}
|
|
cleanupGate chan struct{}
|
|
cleanupErr error
|
|
startOnce sync.Once
|
|
}
|
|
|
|
func newCountingWorkspaceLifecycle(block bool, cleanupErr error) *countingWorkspaceLifecycle {
|
|
runtime := &countingWorkspaceLifecycle{cleanupStart: make(chan struct{}), cleanupErr: cleanupErr}
|
|
if block {
|
|
runtime.cleanupGate = make(chan struct{})
|
|
}
|
|
return runtime
|
|
}
|
|
|
|
func (r *countingWorkspaceLifecycle) workspaceOpen(_ context.Context, _ *SingleRequestWorkspaceBinding, req *iop.WorkspaceOpenRequest) (*iop.WorkspaceOpenResponse, error) {
|
|
r.openCount.Add(1)
|
|
return &iop.WorkspaceOpenResponse{RequestId: req.GetRequestId(), WorkspaceRef: req.GetWorkspaceRef(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS}, nil
|
|
}
|
|
|
|
func (r *countingWorkspaceLifecycle) workspaceTool(_ context.Context, _ *SingleRequestWorkspaceBinding, req *iop.WorkspaceToolRequest) (*iop.WorkspaceToolResponse, error) {
|
|
r.toolCount.Add(1)
|
|
return &iop.WorkspaceToolResponse{
|
|
RequestId: req.GetRequestId(), StageId: req.GetStageId(), ToolCallId: req.GetToolCallId(),
|
|
Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS, Content: []byte("workspace result"),
|
|
}, nil
|
|
}
|
|
|
|
func (r *countingWorkspaceLifecycle) CleanupWorkspace(ctx context.Context, _ *SingleRequestWorkspaceBinding, _ string) error {
|
|
r.cleanupCount.Add(1)
|
|
r.startOnce.Do(func() { close(r.cleanupStart) })
|
|
if r.cleanupGate != nil {
|
|
select {
|
|
case <-r.cleanupGate:
|
|
case <-ctx.Done():
|
|
return ErrSingleRequestWorkspaceCleanup
|
|
}
|
|
}
|
|
return r.cleanupErr
|
|
}
|
|
|
|
func cleanupTestRequest(t *testing.T) SingleRequestRequest {
|
|
t.Helper()
|
|
binding := createTestBinding(t)
|
|
binding.Workspace = &SingleRequestWorkspaceBinding{
|
|
Ref: "workspace-ref-123", NodeID: "node-cleanup", ConnectionGeneration: 7,
|
|
OperationIDs: []string{"read"},
|
|
Limits: SingleRequestWorkspaceLimits{MaxReadBytes: 1024},
|
|
}
|
|
return SingleRequestRequest{RequestID: "request-cleanup", Binding: binding, Prompt: "complete work"}
|
|
}
|
|
|
|
func startCleanupExecution(t *testing.T, runtime *countingWorkspaceLifecycle) SingleRequestExecution {
|
|
t.Helper()
|
|
executor := newScriptedInternalToolExecutor(InternalWorkspaceToolCall{
|
|
ToolCallID: "tool-read", Name: InternalWorkspaceToolRead,
|
|
Arguments: []byte(`{"relative_path":"README.md"}`),
|
|
})
|
|
handle, err := startSingleRequestWithToolLoop(context.Background(), executor, executor, runtime, cleanupTestRequest(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return handle
|
|
}
|
|
|
|
func waitCleanupStarted(t *testing.T, runtime *countingWorkspaceLifecycle) {
|
|
t.Helper()
|
|
select {
|
|
case <-runtime.cleanupStart:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("workspace cleanup did not start")
|
|
}
|
|
}
|
|
|
|
func TestSingleRequestCleanupPrecedesSuccessfulTerminal(t *testing.T) {
|
|
runtime := newCountingWorkspaceLifecycle(true, nil)
|
|
handle := startCleanupExecution(t, runtime)
|
|
waitCleanupStarted(t, runtime)
|
|
if handle.State() != SingleRequestStateFinalizing {
|
|
t.Fatalf("state during cleanup = %s", handle.State())
|
|
}
|
|
internal := handle.(*singleRequestHandle)
|
|
internal.mu.Lock()
|
|
cleanupComplete := internal.cleanupComplete
|
|
internal.mu.Unlock()
|
|
if cleanupComplete {
|
|
t.Fatal("cleanup completed before its lifecycle gate was released")
|
|
}
|
|
if err := handle.AcknowledgeTerminal(true); !errors.Is(err, ErrSingleRequestInvalidState) {
|
|
t.Fatalf("early acknowledgement = %v", err)
|
|
}
|
|
close(runtime.cleanupGate)
|
|
waitForSingleRequestCleanup(t, handle)
|
|
if err := handle.AcknowledgeTerminal(true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := waitForExecution(t, handle)
|
|
if err != nil || result.Output != "private tools completed" {
|
|
t.Fatalf("Wait = (%q, %v)", result.Output, err)
|
|
}
|
|
if runtime.openCount.Load() != 1 || runtime.toolCount.Load() != 1 || runtime.cleanupCount.Load() != 1 {
|
|
t.Fatalf("open/tool/cleanup = %d/%d/%d", runtime.openCount.Load(), runtime.toolCount.Load(), runtime.cleanupCount.Load())
|
|
}
|
|
}
|
|
|
|
func TestSingleRequestCleanupFailureFailsClosed(t *testing.T) {
|
|
runtime := newCountingWorkspaceLifecycle(false, errors.New("raw node cleanup detail"))
|
|
handle := startCleanupExecution(t, runtime)
|
|
_, err := waitForExecution(t, handle)
|
|
if !errors.Is(err, ErrSingleRequestWorkspaceCleanup) || handle.State() != SingleRequestStateFailed {
|
|
t.Fatalf("state/error = %s/%v", handle.State(), err)
|
|
}
|
|
if err != nil && err.Error() != ErrSingleRequestWorkspaceCleanup.Error() {
|
|
t.Fatalf("cleanup leaked implementation detail: %q", err)
|
|
}
|
|
if runtime.cleanupCount.Load() != 1 {
|
|
t.Fatalf("cleanup calls = %d", runtime.cleanupCount.Load())
|
|
}
|
|
}
|
|
|
|
func TestSingleRequestCleanupTerminalRacesExactlyOnce(t *testing.T) {
|
|
for iteration := 0; iteration < 20; iteration++ {
|
|
runtime := newCountingWorkspaceLifecycle(true, nil)
|
|
handle := startCleanupExecution(t, runtime)
|
|
waitCleanupStarted(t, runtime)
|
|
var group sync.WaitGroup
|
|
group.Add(4)
|
|
go func() { defer group.Done(); handle.Cancel() }()
|
|
go func() { defer group.Done(); _ = handle.AcknowledgeTerminal(false) }()
|
|
go func() {
|
|
defer group.Done()
|
|
_ = handle.SubmitEnvelope(SingleRequestEnvelope{RequestID: "request-cleanup", Sequence: 99, Stage: SingleRequestStateFailed, Err: ErrSingleRequestFailed})
|
|
}()
|
|
go func() { defer group.Done(); handle.Cancel() }()
|
|
group.Wait()
|
|
close(runtime.cleanupGate)
|
|
_, _ = waitForExecution(t, handle)
|
|
if runtime.cleanupCount.Load() != 1 {
|
|
t.Fatalf("iteration %d cleanup calls = %d", iteration, runtime.cleanupCount.Load())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSingleRequestCleanupPreservesCancelAndWriteFailureCategory(t *testing.T) {
|
|
t.Run("cancel", func(t *testing.T) {
|
|
runtime := newCountingWorkspaceLifecycle(true, ErrSingleRequestWorkspaceCleanup)
|
|
handle := startCleanupExecution(t, runtime)
|
|
waitCleanupStarted(t, runtime)
|
|
handle.Cancel()
|
|
close(runtime.cleanupGate)
|
|
_, err := waitForExecution(t, handle)
|
|
if !errors.Is(err, ErrSingleRequestCancelled) || !errors.Is(err, ErrSingleRequestWorkspaceCleanup) || handle.State() != SingleRequestStateCancelled {
|
|
t.Fatalf("cancel state/error = %s/%v", handle.State(), err)
|
|
}
|
|
})
|
|
|
|
t.Run("terminal write failure", func(t *testing.T) {
|
|
runtime := newCountingWorkspaceLifecycle(false, nil)
|
|
handle := startCleanupExecution(t, runtime)
|
|
waitForSingleRequestCleanup(t, handle)
|
|
if err := handle.AcknowledgeTerminal(false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := waitForExecution(t, handle)
|
|
if !errors.Is(err, ErrSingleRequestFailed) || handle.State() != SingleRequestStateFailed {
|
|
t.Fatalf("write failure state/error = %s/%v", handle.State(), err)
|
|
}
|
|
if runtime.cleanupCount.Load() != 1 {
|
|
t.Fatalf("write failure cleanup calls = %d", runtime.cleanupCount.Load())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSingleRequestCleanupSkipsUnopenedWorkspace(t *testing.T) {
|
|
runtime := newCountingWorkspaceLifecycle(false, nil)
|
|
executor := &channelFakeExecutor{fn: func(_ context.Context, req SingleRequestRequest, ctrl SingleRequestController) error {
|
|
return submitToFinalizing(req, ctrl, &SingleRequestResult{Output: "no workspace"})
|
|
}}
|
|
handle, err := startSingleRequestWithToolLoop(context.Background(), executor, nil, runtime, cleanupTestRequest(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitForState(t, handle, SingleRequestStateFinalizing)
|
|
if err := handle.AcknowledgeTerminal(true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := waitForExecution(t, handle); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if runtime.cleanupCount.Load() != 0 || runtime.openCount.Load() != 0 {
|
|
t.Fatalf("unopened workspace lifecycle = open %d cleanup %d", runtime.openCount.Load(), runtime.cleanupCount.Load())
|
|
}
|
|
}
|