diff --git a/apps/daemon/internal/agent/claudesdk/commands_session_test.go b/apps/daemon/internal/agent/claudesdk/commands_session_test.go index 9789776c0..b19a7584e 100644 --- a/apps/daemon/internal/agent/claudesdk/commands_session_test.go +++ b/apps/daemon/internal/agent/claudesdk/commands_session_test.go @@ -18,7 +18,7 @@ import ( func TestWorkspaceCommandsRequirePackagedFeature(t *testing.T) { config := preparationFixture(t, "old-command-runtime") - if _, err := NewPreparationFactory(config)(t.Context(), preparationRequest()); err == nil || !strings.Contains(err.Error(), "workspace preparation is unavailable") { + if _, err := NewExecutorFactory(config)(t.Context(), preparationRequest()); err == nil || !strings.Contains(err.Error(), "workspace preparation is unavailable") { t.Fatal("old bridge accepted command observations", err) } if _, err := os.Stat(filepath.Join(config.StateDir, "launched")); !os.IsNotExist(err) { @@ -28,16 +28,16 @@ func TestWorkspaceCommandsRequirePackagedFeature(t *testing.T) { func TestWorkspaceCommandFramesKeepStartIdentityAndObservedOutput(t *testing.T) { config := preparationFixture(t, "commands-success") - resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) + resource, err := NewExecutorFactory(config)(t.Context(), preparationRequest()) if err != nil { t.Fatal(err) } - defer resource.Close() + defer resource.Close(context.Background()) if _, err := os.Stat(filepath.Join(config.StateDir, "start.json")); !os.IsNotExist(err) { t.Fatal("preparation submitted a command") } out := make(chan proto.Envelope, 16) - s, err := resource.Start(t.Context(), "actual-command-run", proto.TextInput("hello"), out) + s, err := resource.StartTurn(t.Context(), "actual-command-run", proto.TextInput("hello"), out) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/claudesdk/contracts.go b/apps/daemon/internal/agent/claudesdk/contracts.go index 72b94bdad..3d6be7a98 100644 --- a/apps/daemon/internal/agent/claudesdk/contracts.go +++ b/apps/daemon/internal/agent/claudesdk/contracts.go @@ -14,12 +14,7 @@ var ( _ agent.WorkspaceReader = (*session)(nil) _ agent.WorkspaceDirectoryLister = (*session)(nil) _ agent.WorkspaceWriter = (*session)(nil) - _ agent.Prepared = (*prepared)(nil) - _ agent.PreparedCancellation = (*prepared)(nil) _ agent.WorkspaceReader = (*executor)(nil) _ agent.WorkspaceDirectoryLister = (*executor)(nil) _ agent.WorkspaceWriter = (*executor)(nil) - _ agent.WorkspaceReader = (*prepared)(nil) - _ agent.WorkspaceDirectoryLister = (*prepared)(nil) - _ agent.WorkspaceWriter = (*prepared)(nil) ) diff --git a/apps/daemon/internal/agent/claudesdk/declaration.go b/apps/daemon/internal/agent/claudesdk/declaration.go index 6459a635f..d422248fa 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration.go +++ b/apps/daemon/internal/agent/claudesdk/declaration.go @@ -116,8 +116,8 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d } caps := &out.Info.Capabilities caps.EnvironmentNone, caps.FunctionTools = proto.CapabilityUnsupported, proto.CapabilityFromBool(info.SupportsWorkspaceFunctions()) - caps.Preparation, caps.LocalEnvironment = proto.CapabilitySupported, proto.CapabilitySupported - caps.WorkspaceReadPreparation, caps.NativeSessionRecovery = proto.CapabilitySupported, proto.CapabilitySupported + caps.LocalEnvironment, caps.WorkspaceReadPreparation = proto.CapabilitySupported, proto.CapabilitySupported + caps.NativeSessionRecovery = proto.CapabilitySupported } out.Info.Available, out.Info.Version = true, info.SDK out.Info.Capabilities.MessageImages = proto.CapabilityFromBool(info.SupportsMessageImages()) @@ -139,10 +139,6 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d out.Info.Capabilities.MCPHTTPRequired = proto.CapabilityUnsupported } out.Executor = NewExecutorFactory(config) - if out.Info.Capabilities.LocalEnvironment.IsSupported() { - out.Preparation = NewPreparationFactory(config) - out.WorkspaceReadPreparation = true - } fmt.Fprintf(options.Stdout, "Claude SDK preflight ok (SDK %s, %s)\n", info.SDK, info.Native) return out diff --git a/apps/daemon/internal/agent/claudesdk/declaration_test.go b/apps/daemon/internal/agent/claudesdk/declaration_test.go index 6ff1a6350..9181b5454 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration_test.go +++ b/apps/daemon/internal/agent/claudesdk/declaration_test.go @@ -116,7 +116,7 @@ func TestRuntimeDiscoveryConfigurationAndRegistration(t *testing.T) { } continue } - if calls != 1 || runtime.Info.Available != ready || (runtime.Executor != nil) != ready || runtime.Preparation != nil { + if calls != 1 || runtime.Info.Available != ready || (runtime.Executor != nil) != ready || runtime.Info.Capabilities.WorkspaceReadPreparation.IsSupported() { t.Fatalf("runtime: %+v", runtime) } registry := agent.NewRegistry() diff --git a/apps/daemon/internal/agent/claudesdk/preparation.go b/apps/daemon/internal/agent/claudesdk/preparation.go deleted file mode 100644 index af9df8e01..000000000 --- a/apps/daemon/internal/agent/claudesdk/preparation.go +++ /dev/null @@ -1,116 +0,0 @@ -package claudesdk - -import ( - "context" - "errors" - "sync" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// prepared is a single admission for direct adapter callers. It uses the same -// Executor as pooled Runtime execution; workspace reads retain the owner. -type prepared struct { - mu sync.Mutex - executor *executor - session *session - binding *preparedStart - closed bool -} - -type preparedStart struct { - turn agent.Turn - done chan struct{} -} - -func NewPreparationFactory(config Config) agent.PreparationFactory { - factory := NewExecutorFactory(config) - return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - if config.Workspace == nil { - return nil, errors.New("claudesdk: workspace preparation requires a bound workspace") - } - resource, err := factory(ctx, req) - if resource == nil { - return nil, err - } - e := resource.(*executor) - return &prepared{executor: e, session: e.base}, err - } -} - -func (p *prepared) Start(ctx context.Context, run string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - p.mu.Lock() - if p.closed || p.binding != nil { - p.mu.Unlock() - return nil, errors.New("claudesdk: admission is unavailable") - } - binding := &preparedStart{done: make(chan struct{})} - p.binding = binding - p.mu.Unlock() - turn, err := p.executor.StartTurn(ctx, run, input, out) - p.mu.Lock() - binding.turn = turn - if turn == nil { - p.binding = nil - } - close(binding.done) - p.mu.Unlock() - if turn != nil { - go func() { _, _ = turn.AwaitSettlement(context.Background()); _ = p.executor.Close(context.Background()) }() - } - return turn, err -} - -func (p *prepared) Close() error { - p.mu.Lock() - if p.binding != nil { - p.mu.Unlock() - return nil - } - p.closed = true - p.mu.Unlock() - return p.executor.Close(context.Background()) -} - -func (p *prepared) Cancel(ctx context.Context) error { - if ctx == nil { - ctx = context.Background() - } - p.mu.Lock() - p.closed = true - binding := p.binding - p.mu.Unlock() - if binding == nil { - return p.executor.Close(ctx) - } - select { - case <-binding.done: - default: - if err := p.executor.Close(ctx); err != nil { - return err - } - select { - case <-binding.done: - case <-ctx.Done(): - return ctx.Err() - } - } - if binding.turn == nil { - return p.executor.Close(ctx) - } - if err := binding.turn.Cancel(ctx); err != nil { - return err - } - p.executor.retire() - return nil -} - -func (p *prepared) CancellationOutcome() proto.DonePayload { - p.mu.Lock() - defer p.mu.Unlock() - if p.binding == nil || p.binding.turn == nil { - return proto.DonePayload{} - } - return p.binding.turn.CancellationOutcome() -} diff --git a/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go index 224a25feb..081a199b9 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go @@ -106,10 +106,6 @@ func runPreparationHelper() { _ = json.Unmarshal(fields["turn_id"], &turnID) emit, finish := helperTurnOutput(scanner, turnID) defer finish() - if mode == "cancellation" { - runCancellationHelper(request, "cancellation-wait", scanner, emit) - return - } if strings.HasPrefix(mode, "commands") { runCommandsHelper(request, mode, scanner, emit) return diff --git a/apps/daemon/internal/agent/claudesdk/preparation_test.go b/apps/daemon/internal/agent/claudesdk/preparation_test.go index f6c868640..5c03d23c5 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_test.go @@ -5,12 +5,9 @@ package claudesdk import ( "context" "encoding/json" - "errors" "os" "path/filepath" - "reflect" "strings" - "sync" "syscall" "testing" "time" @@ -24,15 +21,15 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { req := preparationRequest() ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() - result := make(chan agent.Prepared, 1) + result := make(chan agent.Executor, 1) failed := make(chan error, 1) go func() { - p, err := NewPreparationFactory(config)(ctx, req) + e, err := NewExecutorFactory(config)(ctx, req) if err != nil { failed <- err return } - result <- p + result <- e }() raw := waitPreparationFile(t, filepath.Join(config.StateDir, "prepare.json")) select { @@ -45,17 +42,17 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { if err := os.WriteFile(filepath.Join(config.StateDir, "ready"), nil, 0o600); err != nil { t.Fatal(err) } - var preparedResource agent.Prepared + var resource agent.Executor select { - case preparedResource = <-result: + case resource = <-result: case err := <-failed: t.Fatal(err) case <-ctx.Done(): t.Fatal(ctx.Err()) } - p := preparedResource.(*prepared) - defer p.Cancel(context.Background()) - pid := p.session.process.Cmd.Process.Pid + e := resource.(*executor) + defer e.Close(context.Background()) + pid := e.base.process.Cmd.Process.Pid if _, err := os.Stat(filepath.Join(config.StateDir, "start.json")); !os.IsNotExist(err) { t.Fatal("preparation submitted input") } @@ -68,24 +65,17 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { } config.Env[0] = "ANTHROPIC_AUTH_TOKEN=changed" config.Workspace.Directory = "/changed" - config.Workspace.Directory = "/changed" req.AgentOptions["model"] = "changed" req.AgentSessionID = "changed" out := make(chan proto.Envelope, 16) operation, stopOperation := context.WithCancel(ctx) - s, err := p.Start(operation, "actual-run", proto.TextInput("hello"), out) + turn, err := e.StartTurn(operation, "actual-run", proto.TextInput("hello"), out) stopOperation() if err != nil { t.Fatal(err) } - if s.(*session).owner != p.executor || s.(*session).process.Cmd.Process.Pid != pid { - t.Fatal("Start replaced the prepared native process") - } - if err := p.Close(); err != nil { - t.Fatal(err) - } - if _, err := p.Start(ctx, "duplicate", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { - t.Fatal("duplicate Start was accepted") + if turn.(*session).owner != e || turn.(*session).process.Cmd.Process.Pid != pid { + t.Fatal("StartTurn replaced the prepared native process") } var done proto.DonePayload for event := range out { @@ -107,7 +97,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { } func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) { - for _, name := range []string{"run", "prompt", "attachments", "subagents", "workspace-missing", "none", "functions", "mcp", "controls", "old-runtime"} { + for _, name := range []string{"run", "prompt", "attachments", "subagents", "none", "functions", "mcp", "controls", "old-runtime"} { t.Run(name, func(t *testing.T) { config := preparationFixture(t, name) req := preparationRequest() @@ -120,8 +110,6 @@ func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) req.Input = proto.MessageInput{{Content: []proto.InputContent{{Type: "input_image"}}}} case "subagents": req.ObserveSubagentIdentities = true - case "workspace-missing": - config.Workspace = nil case "none": req.DisableExecutionEnvironment = true case "functions": @@ -131,7 +119,7 @@ func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) case "controls": req.ExecutionControls = &proto.ExecutionControls{WebSearch: "enabled", TextVerbosity: "medium"} } - if _, err := NewPreparationFactory(config)(t.Context(), req); err == nil { + if _, err := NewExecutorFactory(config)(t.Context(), req); err == nil { t.Fatal("invalid preparation was accepted") } if _, err := os.Stat(filepath.Join(config.StateDir, "launched")); !os.IsNotExist(err) { @@ -147,7 +135,7 @@ func TestPreparationFailureAndUnusedRelease(t *testing.T) { config := preparationFixture(t, mode) owner, stop := context.WithCancel(t.Context()) defer stop() - resource, err := NewPreparationFactory(config)(owner, preparationRequest()) + resource, err := NewExecutorFactory(config)(owner, preparationRequest()) if mode == "history-missing" || mode == "invalid-receipt" { if err == nil || mode == "history-missing" && !strings.Contains(err.Error(), "history_unavailable") { t.Fatal("preparation failure was lost", err) @@ -157,15 +145,15 @@ func TestPreparationFailureAndUnusedRelease(t *testing.T) { if err != nil { t.Fatal(err) } - p := resource.(*prepared) - defer p.Cancel(context.Background()) + e := resource.(*executor) + defer e.Close(context.Background()) switch mode { case "close": - err = p.Close() + err = e.Close(t.Context()) case "owner-cancel": stop() case "native-exit": - err = p.session.process.Cmd.Process.Signal(syscall.SIGKILL) + err = e.base.process.Cmd.Process.Signal(syscall.SIGKILL) case "invalid-start", "cancelled-start": operation, cancel := context.WithCancel(t.Context()) prompt := "" @@ -173,150 +161,24 @@ func TestPreparationFailureAndUnusedRelease(t *testing.T) { prompt = "hello" cancel() } - _, startErr := p.Start(operation, "run", proto.TextInput(prompt), make(chan proto.Envelope, 8)) + _, startErr := e.StartTurn(operation, "run", proto.TextInput(prompt), make(chan proto.Envelope, 8)) cancel() if startErr == nil { - t.Fatal("invalid or cancelled Start succeeded") + t.Fatal("invalid or cancelled StartTurn succeeded") } - err = p.Close() + err = e.Close(t.Context()) } if err != nil { t.Fatal(err) } select { - case <-p.executor.done: + case <-e.done: case <-time.After(5 * time.Second): t.Fatal("unused process was not settled") } - if _, err := p.Start(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { - t.Fatal("released preparation was reusable") - } - if got := p.CancellationOutcome(); !reflect.DeepEqual(got, proto.DonePayload{}) { - t.Fatal("unstarted process fabricated an execution outcome", got) + if _, err := e.StartTurn(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { + t.Fatal("released Executor was reusable") } }) } } - -func TestPreparedCancellationKeepsOwnershipUntilOutputDrain(t *testing.T) { - config := preparationFixture(t, "cancellation") - owner, stop := context.WithTimeout(t.Context(), 10*time.Second) - defer stop() - resource, err := NewPreparationFactory(config)(owner, preparationRequest()) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - defer p.Cancel(context.Background()) - out := make(chan proto.Envelope) - operation, stopOperation := context.WithCancel(owner) - if _, err := p.Start(operation, "run", proto.TextInput("hello"), out); err != nil { - t.Fatal(err) - } - stopOperation() - if err := p.Close(); err != nil { - t.Fatal(err) - } - if event := <-out; event.Type != proto.TypeDelta { - t.Fatal("operation cancellation or Close cancelled the Session") - } - short, cancel := context.WithTimeout(owner, 30*time.Millisecond) - err = p.Cancel(short) - cancel() - if !errors.Is(err, context.DeadlineExceeded) || !reflect.DeepEqual(p.CancellationOutcome(), proto.DonePayload{}) { - t.Fatal("pending output drain reported settled ownership", err) - } - if err := os.WriteFile(filepath.Join(config.StateDir, "release"), nil, 0o600); err != nil { - t.Fatal(err) - } - if err := p.Cancel(owner); err != nil { - t.Fatal(err) - } - got := p.CancellationOutcome() - if got.Content != "partialtaildrained" || got.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || got.Usage.Raw["claude_sdk_result"] == nil { - t.Fatal("cancellation across transfer lost observed output", got) - } - for range out { - } -} - -func TestPreparedStartRacesCloseAndCancellation(t *testing.T) { - for _, cancelResource := range []bool{false, true} { - for range 6 { - config := preparationFixture(t, "success") - resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - out := make(chan proto.Envelope, 16) - var running agent.Session - var startErr error - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - running, startErr = p.Start(t.Context(), "run", proto.TextInput("hello"), out) - }() - go func() { - defer wg.Done() - if cancelResource { - _ = p.Cancel(t.Context()) - } else { - _ = p.Close() - } - }() - wg.Wait() - if startErr == nil { - if running == nil { - t.Fatal("successful transfer lost its Session") - } - for range out { - } - } - if err := p.Cancel(t.Context()); err != nil { - // A racing Close can confirm resource cleanup without proving the Turn result. - if closeErr := p.executor.Close(t.Context()); closeErr != nil { - t.Fatal(closeErr) - } - } - select { - case <-p.session.process.Done(): - default: - t.Fatal("race left the owned process alive") - } - } - } -} - -func TestPreparedConcurrentStartTransfersOnlyOnce(t *testing.T) { - config := preparationFixture(t, "success") - resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - defer p.Cancel(context.Background()) - results := make(chan bool, 2) - var wg sync.WaitGroup - for range 2 { - wg.Add(1) - go func() { - defer wg.Done() - out := make(chan proto.Envelope, 16) - _, err := p.Start(t.Context(), "run", proto.TextInput("hello"), out) - results <- err == nil - if err == nil { - for event := range out { - if event.Type == proto.TypeError { - t.Error("losing Start cancelled the transferred Session") - } - } - } - }() - } - wg.Wait() - if first, second := <-results, <-results; first == second { - t.Fatal("concurrent Start did not produce exactly one owner") - } -} diff --git a/apps/daemon/internal/agent/claudesdk/session.go b/apps/daemon/internal/agent/claudesdk/session.go index cf241ef07..f252ce14f 100644 --- a/apps/daemon/internal/agent/claudesdk/session.go +++ b/apps/daemon/internal/agent/claudesdk/session.go @@ -72,23 +72,6 @@ func launch(ctx context.Context, config Config, start startRequest, env []string return &session{process: process, writeMu: &sync.Mutex{}, functions: functionState{calls: map[string]*pendingFunction{}}, settled: make(chan struct{})}, nil } -func (s *session) drain(scanner *bridgeOutput, stderrDone <-chan struct{}, failure error) (error, bool) { - for scanner.Scan() { - } - if scanner.Err() != nil { - failure = fmt.Errorf("claudesdk: SDK bridge output read failed") - s.process.Cancel() - } - <-stderrDone - s.stopWorkspaceReads() - s.stopWorkspaceDirectories() - waitErr := s.process.Wait() - if waitErr != nil && failure == nil { - failure = fmt.Errorf("claudesdk: SDK process failed") - } - return failure, scanner.Err() == nil && waitErr == nil -} - func bridgeFailure(code string) error { switch code { case "invalid_request", "history_unavailable", "execution_failed", "cancelled": diff --git a/apps/daemon/internal/agent/claudesdk/unsupported.go b/apps/daemon/internal/agent/claudesdk/unsupported.go index d66e3cd5f..154d904a4 100644 --- a/apps/daemon/internal/agent/claudesdk/unsupported.go +++ b/apps/daemon/internal/agent/claudesdk/unsupported.go @@ -13,7 +13,3 @@ func (s *executor) WriteWorkspaceFile(context.Context, string, []byte) (agent.Wo func (s *session) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported } - -func (s *prepared) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} diff --git a/apps/daemon/internal/agent/claudesdk/unsupported_test.go b/apps/daemon/internal/agent/claudesdk/unsupported_test.go index 9934a1038..7fe6048c5 100644 --- a/apps/daemon/internal/agent/claudesdk/unsupported_test.go +++ b/apps/daemon/internal/agent/claudesdk/unsupported_test.go @@ -24,7 +24,7 @@ func TestUnsupportedExtensionsHaveNoNativeEffects(t *testing.T) { t.Fatal("unsupported error disclosed input") } } - for _, owner := range []agent.WorkspaceWriter{(*executor)(nil), (*session)(nil), (*prepared)(nil)} { + for _, owner := range []agent.WorkspaceWriter{(*executor)(nil), (*session)(nil)} { result, err := owner.WriteWorkspaceFile(ctx, secret, []byte(secret)) check(err) if result.SizeBytes != 0 { diff --git a/apps/daemon/internal/agent/claudesdk/workspace_directory.go b/apps/daemon/internal/agent/claudesdk/workspace_directory.go index 5b4150cba..1bde6cbe5 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_directory.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_directory.go @@ -39,23 +39,8 @@ type workspaceDirectoryEvent struct { Error string `json:"error"` } -var _ agent.WorkspaceDirectoryLister = (*prepared)(nil) var _ agent.WorkspaceDirectoryLister = (*session)(nil) -func (p *prepared) ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) { - p.mu.Lock() - if p.closed || p.binding != nil { - p.mu.Unlock() - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnavailable - } - read, err := p.session.admitWorkspaceDirectory(ctx, path, maxEntries) - p.mu.Unlock() - if err != nil { - return agent.WorkspaceDirectoryResult{}, err - } - return p.session.awaitWorkspaceDirectory(ctx, read) -} - func (s *session) ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) { if s.owner != nil { return s.owner.base.ListWorkspaceDirectory(ctx, path, maxEntries) diff --git a/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go b/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go index 1c052d736..e073cffae 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_directory_test.go @@ -84,70 +84,64 @@ func runWorkspaceDirectoryHelper(scanner *bufio.Scanner, state, mode string) { } } -func directoryPreparation(t *testing.T, mode string) (*prepared, Config) { +func directoryExecutor(t *testing.T, mode string) (*executor, Config) { t.Helper() config := preparationFixture(t, mode) - resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) + resource, err := NewExecutorFactory(config)(t.Context(), preparationRequest()) if err != nil { t.Fatal(err) } - p := resource.(*prepared) - t.Cleanup(func() { _ = p.Cancel(context.Background()) }) - return p, config + e := resource.(*executor) + t.Cleanup(func() { _ = e.Close(context.Background()) }) + return e, config } -func TestWorkspaceDirectoryPreparedBoundsAndMetadata(t *testing.T) { - p, _ := directoryPreparation(t, "directory-normal") +func TestWorkspaceDirectoryBoundsAndMetadata(t *testing.T) { + e, _ := directoryExecutor(t, "directory-normal") for _, path := range []string{"/absolute", "../escape", "a//b", "a/./b", "a\\b", "a\x00b", strings.Repeat("界", 3000)} { - if _, err := p.ListWorkspaceDirectory(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { + if _, err := e.ListWorkspaceDirectory(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { t.Fatalf("accepted %q: %v", path, err) } } for _, limit := range []int{0, -1, workspaceDirectoryMaxEntries + 1} { - if _, err := p.ListWorkspaceDirectory(t.Context(), "", limit); err != agent.ErrWorkspaceReadInvalid { + if _, err := e.ListWorkspaceDirectory(t.Context(), "", limit); err != agent.ErrWorkspaceReadInvalid { t.Fatal(limit, err) } } - result, err := p.ListWorkspaceDirectory(t.Context(), "", 4) + result, err := e.ListWorkspaceDirectory(t.Context(), "", 4) if err != nil || result.Truncated || len(result.Entries) != 1 || result.Entries[0].SizeBytes == nil || *result.Entries[0].SizeBytes != 3 { t.Fatal(result, err) } - empty, err := p.ListWorkspaceDirectory(t.Context(), "empty", 4) + empty, err := e.ListWorkspaceDirectory(t.Context(), "empty", 4) if err != nil || len(empty.Entries) != 0 || empty.Entries == nil { t.Fatal(empty, err) } for path, expected := range map[string]error{"not_found": fs.ErrNotExist, "permission": fs.ErrPermission, "invalid": agent.ErrWorkspaceReadInvalid} { - if _, err := p.ListWorkspaceDirectory(t.Context(), path, 4); err != expected { + if _, err := e.ListWorkspaceDirectory(t.Context(), path, 4); err != expected { t.Fatal(path, err) } } - if _, err := p.ListWorkspaceDirectory(t.Context(), "", 4); err != nil { + if _, err := e.ListWorkspaceDirectory(t.Context(), "", 4); err != nil { t.Fatal(err) } } -func TestWorkspaceDirectoryDetachAndTransfer(t *testing.T) { - p, config := directoryPreparation(t, "directory-held") +func TestWorkspaceDirectoryDetachAndTurnStart(t *testing.T) { + e, config := directoryExecutor(t, "directory-held") ctx, detach := context.WithCancel(t.Context()) defer detach() result := make(chan error, 1) - go func() { _, err := p.ListWorkspaceDirectory(ctx, "file", 4); result <- err }() + go func() { _, err := e.ListWorkspaceDirectory(ctx, "file", 4); result <- err }() waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) detach() - if _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { + if _, err := e.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { t.Fatal(err) } out := make(chan proto.Envelope, 16) - running, err := p.Start(t.Context(), "run", proto.TextInput("hello"), out) + running, err := e.StartTurn(t.Context(), "run", proto.TextInput("hello"), out) if err != nil { t.Fatal(err) } - if p.Close() != nil { - t.Fatal("transferred Close failed") - } - if _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUnavailable { - t.Fatal(err) - } select { case err := <-result: t.Fatal("caller detach discarded native wait", err) @@ -168,24 +162,24 @@ func TestWorkspaceDirectoryDetachAndTransfer(t *testing.T) { func TestWorkspaceDirectoryUnknownAndRelease(t *testing.T) { for _, path := range []string{"uncertain", "wrong-id", "bad-kind", "bad-size", "missing-size", "duplicate", "escape-name", "null", "extra"} { t.Run(path, func(t *testing.T) { - p, _ := directoryPreparation(t, "directory-normal") - result, err := p.ListWorkspaceDirectory(t.Context(), path, 4) + e, _ := directoryExecutor(t, "directory-normal") + result, err := e.ListWorkspaceDirectory(t.Context(), path, 4) if err != agent.ErrWorkspaceReadUncertain || len(result.Entries) != 0 { t.Fatal(result, err) } - if _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { + if _, err := e.ListWorkspaceDirectory(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { t.Fatal(err) } }) } for _, mode := range []string{"directory-held", "directory-exit"} { t.Run(mode, func(t *testing.T) { - p, config := directoryPreparation(t, mode) + e, config := directoryExecutor(t, mode) done := make(chan error, 1) - go func() { _, err := p.ListWorkspaceDirectory(t.Context(), "file", 4); done <- err }() + go func() { _, err := e.ListWorkspaceDirectory(t.Context(), "file", 4); done <- err }() waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) if mode == "directory-held" { - if err := p.Close(); err != nil { + if err := e.Close(t.Context()); err != nil { t.Fatal(err) } } @@ -202,19 +196,19 @@ func TestWorkspaceDirectoryUnknownAndRelease(t *testing.T) { } func TestWorkspaceDirectoryDeadlineStopsOwnerBeforeUnknown(t *testing.T) { - p, config := directoryPreparation(t, "directory-held") + e, config := directoryExecutor(t, "directory-held") ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) defer cancel() done := make(chan error, 1) - go func() { _, err := p.ListWorkspaceDirectory(ctx, "file", 4); done <- err }() + go func() { _, err := e.ListWorkspaceDirectory(ctx, "file", 4); done <- err }() waitPreparationFile(t, filepath.Join(config.StateDir, "directory-admitted")) if err := <-done; err != agent.ErrWorkspaceReadUncertain { t.Fatal(err) } - if p.session.process.Context().Err() == nil { + if e.base.process.Context().Err() == nil { t.Fatal("uncertain deadline returned before owner cancellation") } - if _, err := p.Start(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { - t.Fatal("unknown owner accepted a new Start") + if _, err := e.StartTurn(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { + t.Fatal("unknown owner accepted a new Turn") } } diff --git a/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go index d1c1f9417..b0143e694 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go @@ -13,7 +13,6 @@ import ( "testing" "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) @@ -21,14 +20,6 @@ import ( // Run only inside a separately qualified outer placement, with its pinned native // dependencies. This fixture does not create isolation or public admission. func TestLiveClaudeWorkspaceFactory(t *testing.T) { - testLiveClaudeWorkspace(t, false) -} - -func TestLiveClaudePreparedWorkspace(t *testing.T) { - testLiveClaudeWorkspace(t, true) -} - -func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { configFile := os.Getenv("OAC_TEST_CLAUDE_WORKSPACE_LIVE_CONFIG") if configFile == "" { t.Skip("requires explicit qualified placement and real provider configuration") @@ -72,20 +63,16 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { heartbeat := filepath.Join(config.Workspace.Directory, "heartbeat.txt") artifact := filepath.Join(config.Workspace.Directory, "value.txt") type evidence struct { - Reads []liveWorkspaceRead `json:"reads,omitempty"` - RunID string `json:"run_id"` - Events []proto.Envelope `json:"events"` - Done proto.DonePayload `json:"done"` - Failure string `json:"failure,omitempty"` - Cancelled bool `json:"cancelled"` - CancelMS int64 `json:"cancel_ms,omitempty"` - BridgePID int `json:"bridge_pid"` - Terminals int `json:"terminals"` - Heartbeats []string `json:"heartbeats,omitempty"` - PreparedPID int `json:"prepared_pid,omitempty"` - NativeBefore string `json:"native_before,omitempty"` - NativeAfter string `json:"native_after,omitempty"` - StartContextCancelled bool `json:"start_context_cancelled,omitempty"` + Reads []liveWorkspaceRead `json:"reads,omitempty"` + RunID string `json:"run_id"` + Events []proto.Envelope `json:"events"` + Done proto.DonePayload `json:"done"` + Failure string `json:"failure,omitempty"` + Cancelled bool `json:"cancelled"` + CancelMS int64 `json:"cancel_ms,omitempty"` + BridgePID int `json:"bridge_pid"` + Terminals int `json:"terminals"` + Heartbeats []string `json:"heartbeats,omitempty"` } writeEvidence := func(name string, proof evidence) { t.Helper() @@ -107,39 +94,7 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { req.ObserveMessages = true req.AgentOptions = map[string]any{"model": "MiniMax-M3", "system_prompt": "Follow the exact verification instructions using the requested native tools. Preserve conversation facts. No other files, network operations or background work."} proof := evidence{RunID: req.RunID} - var running agent.Session - var owner agent.PreparedCancellation - if explicitPreparation { - preparation := req - preparation.RunID, preparation.Input = "", nil - var resource agent.Prepared - resource, err = NewPreparationFactory(config)(ctx, preparation) - if err == nil { - defer resource.Close() - owner = resource.(agent.PreparedCancellation) - proof.PreparedPID = resource.(*prepared).session.process.Cmd.Process.Pid - proof.NativeBefore = liveWorkspaceNativeIdentity(t, proof.PreparedPID) - before, _ := os.ReadFile(artifact) - time.Sleep(500 * time.Millisecond) - after, _ := os.ReadFile(artifact) - if !bytes.Equal(before, after) || liveWorkspaceNativeIdentity(t, proof.PreparedPID) != proof.NativeBefore || len(out) != 0 { - t.Fatal("prepared resource changed before initial input") - } - proof.Reads = append(proof.Reads, liveWorkspaceReads(t, ctx, resource.(agent.WorkspaceReader), config.Workspace.Directory, "prepared", "read-binary.bin", "read-empty.bin", "read-large.bin")...) - operation, stopOperation := context.WithCancel(ctx) - running, err = resource.Start(operation, req.RunID, req.Input, out) - stopOperation() - proof.StartContextCancelled = true - if err == nil { - proof.NativeAfter = liveWorkspaceNativeIdentity(t, proof.PreparedPID) - if proof.NativeBefore != proof.NativeAfter || resource.Close() != nil { - t.Fatal("Start replaced the native process or Close affected its transfer") - } - } - } - } else { - running, err = startSingleTurn(ctx, config, req, out) - } + running, err := startSingleTurn(ctx, config, req, out) if err != nil { if name == "missing-history" && running == nil && strings.Contains(err.Error(), "history_unavailable") { proof.Failure = err.Error() @@ -152,13 +107,6 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { defer s.Cancel(context.Background()) proof.BridgePID = s.process.Cmd.Process.Pid proof.Reads = append(proof.Reads, liveWorkspaceReads(t, ctx, s, config.Workspace.Directory, "active", "read-binary.bin", "read-empty.bin", "read-large.bin")...) - if explicitPreparation && proof.BridgePID != proof.PreparedPID { - t.Fatal("Start replaced the prepared bridge") - } - cancelOwned, outcome := s.Cancel, s.CancellationOutcome - if owner != nil { - cancelOwned, outcome = owner.Cancel, owner.CancellationOutcome - } ticker := time.NewTicker(80 * time.Millisecond) defer ticker.Stop() for out != nil { @@ -170,7 +118,7 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { if cancelOnEffect && !proof.Cancelled && len(value) > 0 && string(value) != "0" && string(value) != "1" { proof.Reads = append(proof.Reads, liveWorkspaceReads(t, ctx, s, config.Workspace.Directory, "effect", "value.txt")...) started := time.Now() - if err := cancelOwned(ctx); err != nil { + if err := s.Cancel(ctx); err != nil { t.Fatal("factory cancellation failed", err) } proof.CancelMS = time.Since(started).Milliseconds() @@ -201,7 +149,7 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { if len(a) == 0 || !bytes.Equal(a, b) { t.Fatal("native command effects continued after Cancel") } - settled, _ := json.Marshal(outcome()) + settled, _ := json.Marshal(s.CancellationOutcome()) done, _ := json.Marshal(proof.Done) if !bytes.Equal(settled, done) { t.Fatal("cancellation outcome differs from terminal Done") @@ -278,18 +226,3 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { t.Fatal(err) } } - -func liveWorkspaceNativeIdentity(t *testing.T, bridgePID int) string { - t.Helper() - raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/task/%d/children", bridgePID, bridgePID)) - children := strings.Fields(string(raw)) - if err != nil || len(children) != 1 { - t.Fatal("expected exactly one retained native child", err) - } - status, err := os.ReadFile("/proc/" + children[0] + "/stat") - fields := strings.Fields(string(status)[strings.LastIndex(string(status), ")")+1:]) - if err != nil || len(fields) < 20 { - t.Fatal("native process identity unavailable", err) - } - return children[0] + ":" + fields[19] -} diff --git a/apps/daemon/internal/agent/claudesdk/workspace_read.go b/apps/daemon/internal/agent/claudesdk/workspace_read.go index 9d8f4a568..ec5ea4a56 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_read.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_read.go @@ -39,23 +39,8 @@ type workspaceReadEvent struct { Error string `json:"error"` } -var _ agent.WorkspaceReader = (*prepared)(nil) var _ agent.WorkspaceReader = (*session)(nil) -func (p *prepared) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { - p.mu.Lock() - if p.closed || p.binding != nil { - p.mu.Unlock() - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnavailable - } - read, err := p.session.admitWorkspaceRead(ctx, path, maxBytes) - p.mu.Unlock() - if err != nil { - return agent.WorkspaceReadResult{}, err - } - return p.session.awaitWorkspaceRead(ctx, read) -} - func (s *session) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { if s.owner != nil { return s.owner.base.ReadWorkspaceFile(ctx, path, maxBytes) diff --git a/apps/daemon/internal/agent/claudesdk/workspace_read_test.go b/apps/daemon/internal/agent/claudesdk/workspace_read_test.go index 083c2387a..72d32a943 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_read_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_read_test.go @@ -78,32 +78,32 @@ func runWorkspaceReadHelper(scanner *bufio.Scanner, state, mode string) { } } -func readPreparation(t *testing.T, mode string) (*prepared, Config) { +func readExecutor(t *testing.T, mode string) (*executor, Config) { t.Helper() config := preparationFixture(t, mode) - resource, err := NewPreparationFactory(config)(t.Context(), preparationRequest()) + resource, err := NewExecutorFactory(config)(t.Context(), preparationRequest()) if err != nil { t.Fatal(err) } - p := resource.(*prepared) - t.Cleanup(func() { _ = p.Cancel(context.Background()) }) - return p, config + e := resource.(*executor) + t.Cleanup(func() { _ = e.Close(context.Background()) }) + return e, config } -func TestWorkspaceReadPreparedBoundsAndBinary(t *testing.T) { - p, _ := readPreparation(t, "read-normal") +func TestWorkspaceReadBoundsAndBinary(t *testing.T) { + e, _ := readExecutor(t, "read-normal") for _, path := range []string{"", "/absolute", "../escape", "a//b", "a/./b", "a\\b", "a\x00b", strings.Repeat("界", 3000)} { - if _, err := p.ReadWorkspaceFile(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { + if _, err := e.ReadWorkspaceFile(t.Context(), path, 4); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { t.Fatalf("accepted %q: %v", path, err) } } for _, limit := range []int{0, -1, workspaceReadMaxBytes + 1} { - if _, err := p.ReadWorkspaceFile(t.Context(), "file", limit); err != agent.ErrWorkspaceReadInvalid { + if _, err := e.ReadWorkspaceFile(t.Context(), "file", limit); err != agent.ErrWorkspaceReadInvalid { t.Fatal(limit, err) } } for _, path := range []string{"file", "prefix", "empty"} { - result, err := p.ReadWorkspaceFile(t.Context(), path, workspaceReadMaxBytes) + result, err := e.ReadWorkspaceFile(t.Context(), path, workspaceReadMaxBytes) expected := bytes.Repeat([]byte{0, 255, 1, 128}, workspaceReadMaxBytes/4) if path == "empty" { expected = nil @@ -112,36 +112,30 @@ func TestWorkspaceReadPreparedBoundsAndBinary(t *testing.T) { t.Fatal(path, err, len(result.Data), result.Truncated) } } - if _, err := p.ReadWorkspaceFile(t.Context(), "invalid", 4); err != agent.ErrWorkspaceReadInvalid { + if _, err := e.ReadWorkspaceFile(t.Context(), "invalid", 4); err != agent.ErrWorkspaceReadInvalid { t.Fatal(err) } - if _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); err != nil { + if _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); err != nil { t.Fatal(err) } } -func TestWorkspaceReadDetachAndTransfer(t *testing.T) { - p, config := readPreparation(t, "read-held") +func TestWorkspaceReadDetachAndTurnStart(t *testing.T) { + e, config := readExecutor(t, "read-held") ctx, detach := context.WithCancel(t.Context()) defer detach() result := make(chan error, 1) - go func() { _, err := p.ReadWorkspaceFile(ctx, "file", 4); result <- err }() + go func() { _, err := e.ReadWorkspaceFile(ctx, "file", 4); result <- err }() waitPreparationFile(t, filepath.Join(config.StateDir, "read-admitted")) detach() - if _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { + if _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadBusy { t.Fatal(err) } out := make(chan proto.Envelope, 16) - running, err := p.Start(t.Context(), "run", proto.TextInput("hello"), out) + running, err := e.StartTurn(t.Context(), "run", proto.TextInput("hello"), out) if err != nil { t.Fatal(err) } - if p.Close() != nil { - t.Fatal("transferred Close failed") - } - if _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUnavailable { - t.Fatal(err) - } select { case err := <-result: t.Fatal("caller detach discarded native wait", err) @@ -162,24 +156,24 @@ func TestWorkspaceReadDetachAndTransfer(t *testing.T) { func TestWorkspaceReadUnknownAndRelease(t *testing.T) { for _, path := range []string{"uncertain", "wrong-id", "bad-base64", "oversize", "extra"} { t.Run(path, func(t *testing.T) { - p, _ := readPreparation(t, "read-normal") - result, err := p.ReadWorkspaceFile(t.Context(), path, 4) + e, _ := readExecutor(t, "read-normal") + result, err := e.ReadWorkspaceFile(t.Context(), path, 4) if err != agent.ErrWorkspaceReadUncertain || len(result.Data) != 0 { t.Fatal(result, err) } - if _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { + if _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); err != agent.ErrWorkspaceReadUncertain && err != agent.ErrWorkspaceReadUnavailable { t.Fatal(err) } }) } for _, mode := range []string{"read-held", "read-exit"} { t.Run(mode, func(t *testing.T) { - p, config := readPreparation(t, mode) + e, config := readExecutor(t, mode) done := make(chan error, 1) - go func() { _, err := p.ReadWorkspaceFile(t.Context(), "file", 4); done <- err }() + go func() { _, err := e.ReadWorkspaceFile(t.Context(), "file", 4); done <- err }() waitPreparationFile(t, filepath.Join(config.StateDir, "read-admitted")) if mode == "read-held" { - if err := p.Close(); err != nil { + if err := e.Close(t.Context()); err != nil { t.Fatal(err) } } @@ -196,19 +190,19 @@ func TestWorkspaceReadUnknownAndRelease(t *testing.T) { } func TestWorkspaceReadDeadlineStopsOwnerBeforeUnknown(t *testing.T) { - p, config := readPreparation(t, "read-held") + e, config := readExecutor(t, "read-held") ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) defer cancel() done := make(chan error, 1) - go func() { _, err := p.ReadWorkspaceFile(ctx, "file", 4); done <- err }() + go func() { _, err := e.ReadWorkspaceFile(ctx, "file", 4); done <- err }() waitPreparationFile(t, filepath.Join(config.StateDir, "read-admitted")) if err := <-done; err != agent.ErrWorkspaceReadUncertain { t.Fatal(err) } - if p.session.process.Context().Err() == nil { + if e.base.process.Context().Err() == nil { t.Fatal("uncertain deadline returned before owner cancellation") } - if _, err := p.Start(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { - t.Fatal("unknown owner accepted a new Start") + if _, err := e.StartTurn(t.Context(), "late", proto.TextInput("hello"), make(chan proto.Envelope, 8)); err == nil { + t.Fatal("unknown owner accepted a new Turn") } } diff --git a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go index 357eb8127..ec627a49e 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go @@ -3,6 +3,7 @@ package claudesdk import ( + "context" "encoding/json" "os" "path/filepath" @@ -20,7 +21,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T req.ObserveMessages = true schema := `{"type":"object","properties":{"n":{"const":9007199254740992}}}` req.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: json.RawMessage(schema)}} - p, err := NewPreparationFactory(config)(t.Context(), req) + e, err := NewExecutorFactory(config)(t.Context(), req) if mode == "structured-missing" { if err == nil || !strings.Contains(err.Error(), "workspace structured output") { t.Fatal("unqualified bundle admitted", err) @@ -33,7 +34,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T if err != nil { t.Fatal(err) } - defer p.Close() + defer e.Close(context.Background()) req.ExecutionControls.OutputFormat.Schema[0] = ' ' var frozen startRequest if err := json.Unmarshal(waitPreparationFile(t, filepath.Join(config.StateDir, "prepare.json")), &frozen); err != nil { @@ -43,7 +44,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T t.Fatal("prepared native schema changed with caller memory") } out := make(chan proto.Envelope, 16) - if _, err := p.Start(t.Context(), "run", proto.TextInput("hello"), out); err != nil { + if _, err := e.StartTurn(t.Context(), "run", proto.TextInput("hello"), out); err != nil { t.Fatal(err) } for event := range out { diff --git a/apps/daemon/internal/agent/codex/contracts.go b/apps/daemon/internal/agent/codex/contracts.go index a6f045a89..1304b87e5 100644 --- a/apps/daemon/internal/agent/codex/contracts.go +++ b/apps/daemon/internal/agent/codex/contracts.go @@ -14,12 +14,7 @@ var ( _ agent.WorkspaceReader = (*Session)(nil) _ agent.WorkspaceDirectoryLister = (*Session)(nil) _ agent.WorkspaceWriter = (*Session)(nil) - _ agent.Prepared = (*Prepared)(nil) - _ agent.PreparedCancellation = (*Prepared)(nil) _ agent.WorkspaceReader = (*Executor)(nil) _ agent.WorkspaceDirectoryLister = (*Executor)(nil) _ agent.WorkspaceWriter = (*Executor)(nil) - _ agent.WorkspaceReader = (*Prepared)(nil) - _ agent.WorkspaceDirectoryLister = (*Prepared)(nil) - _ agent.WorkspaceWriter = (*Prepared)(nil) ) diff --git a/apps/daemon/internal/agent/codex/declaration.go b/apps/daemon/internal/agent/codex/declaration.go index 8e5aa004b..14d3277a0 100644 --- a/apps/daemon/internal/agent/codex/declaration.go +++ b/apps/daemon/internal/agent/codex/declaration.go @@ -61,18 +61,9 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, i caps := &runtime.Info.Capabilities caps.NativeSessionRecovery = proto.CapabilityFromBool(SupportsNativeSessionRecovery(version)) caps.LocalEnvironment = proto.CapabilityFromBool(SupportsLocalEnvironment(version)) + caps.WorkspaceReadPreparation = caps.LocalEnvironment caps.MCPHTTPRequired = proto.CapabilityFromBool(SupportsNativeSessionRecovery(version)) runtime.Executor = NewExecutorFactory() - if caps.LocalEnvironment.IsSupported() { - runtime.WorkspaceReadPreparation = true - runtime.Preparation = func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - prepared, err := Prepare(ctx, req) - if prepared == nil { - return nil, err - } - return prepared, err - } - } fmt.Fprintf(options.Stdout, "Codex preflight ok (%s)\n", version) return runtime } diff --git a/apps/daemon/internal/agent/codex/declaration_test.go b/apps/daemon/internal/agent/codex/declaration_test.go index 8f5c7e757..87f94299c 100644 --- a/apps/daemon/internal/agent/codex/declaration_test.go +++ b/apps/daemon/internal/agent/codex/declaration_test.go @@ -15,7 +15,7 @@ func TestMCPRequiredDiscoveryRequiresPinnedNative(t *testing.T) { for _, version := range []string{"codex-cli 0.153.4", "codex-cli 0.153.3", "codex-cli 0.154.0"} { runtime := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard}, Declaration.Info, func(context.Context, string) (string, error) { return version, nil }) - if !runtime.Info.Available || runtime.Executor == nil || (runtime.Preparation != nil) != SupportsLocalEnvironment(version) { + if !runtime.Info.Available || runtime.Executor == nil || runtime.Info.Capabilities.WorkspaceReadPreparation.IsSupported() != SupportsLocalEnvironment(version) { t.Fatalf("factories: %+v", runtime) } if runtime.Info.Capabilities.MCPHTTPRequired.IsSupported() != (version == "codex-cli 0.153.4") { @@ -49,7 +49,7 @@ func TestDeclaredCapabilityBaseline(t *testing.T) { func TestUnavailableRuntimeHasNoExecutionFactories(t *testing.T) { runtime := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard}, Declaration.Info, func(context.Context, string) (string, error) { return "", errors.New("missing") }) - if runtime.Info.Available || runtime.Executor != nil || runtime.Preparation != nil { + if runtime.Info.Available || runtime.Executor != nil { t.Fatalf("unavailable runtime: %+v", runtime) } } diff --git a/apps/daemon/internal/agent/codex/executor_test.go b/apps/daemon/internal/agent/codex/executor_test.go index f864d5899..1f1696d6e 100644 --- a/apps/daemon/internal/agent/codex/executor_test.go +++ b/apps/daemon/internal/agent/codex/executor_test.go @@ -2,8 +2,10 @@ package codex import ( "context" + "encoding/json" "errors" "os" + "strings" "sync" "sync/atomic" "testing" @@ -17,22 +19,32 @@ import ( func executorFixture(t *testing.T, mode string) (*Executor, string) { t.Helper() req, cfg, root := preparationFixture(t) + e, err := testExecutor(t, mode, req, cfg) + if err != nil { + t.Fatal(err) + } + return e, root +} + +// testExecutor prepares through the production factory and closes the owner at cleanup. +func testExecutor(t *testing.T, mode string, req proto.PromptRequestPayload, cfg sessionConfig) (*Executor, error) { + t.Helper() t.Setenv("OAC_TEST_EXECUTOR_MODE", mode) ownerCtx, cancelOwner := context.WithCancel(context.Background()) t.Cleanup(cancelOwner) e, err := newExecutor(ownerCtx, req, cfg) - if err != nil { - t.Fatal(err) + if e != nil { + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := e.Close(ctx); err != nil { + t.Error(err) + } + }) } - t.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := e.Close(ctx); err != nil { - t.Error(err) - } - }) - return e, root + return e, err } + func awaitExecutorTurn(t *testing.T, turn agent.Turn, out <-chan proto.Envelope) agent.TurnSettlement { t.Helper() ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) @@ -52,6 +64,21 @@ func awaitExecutorTurn(t *testing.T, turn agent.Turn, out <-chan proto.Envelope) } return settlement } + +// settledFrames waits for a Turn to settle and returns its complete output. +func settledFrames(t *testing.T, turn agent.Turn, out <-chan proto.Envelope) []proto.Envelope { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + if _, err := turn.AwaitSettlement(ctx); errors.Is(err, context.DeadlineExceeded) { + t.Fatal("Turn did not settle") + } + var frames []proto.Envelope + for frame := range out { + frames = append(frames, frame) + } + return frames +} func TestExecutorNormalTurnsKeepProcessAndThread(t *testing.T) { e, root := executorFixture(t, "complete") var previous agent.Turn @@ -84,6 +111,95 @@ func TestExecutorNormalTurnsKeepProcessAndThread(t *testing.T) { t.Fatal("normal completion closed executor") } } +func TestExecutorFreezesPreparedConfiguration(t *testing.T) { + for _, resume := range []bool{false, true} { + t.Run(map[bool]string{false: "new", true: "resumed"}[resume], func(t *testing.T) { + req, cfg, root := preparationFixture(t) + expectedThread := "thread/start" + if resume { + req.AgentSessionID, expectedThread = "fixture-native-thread", "thread/resume" + } + e, err := testExecutor(t, "complete", req, cfg) + if err != nil { + t.Fatal(err) + } + assertPreparationOnly(t, root) + cwd := e.prepared.plan.Cwd + // Caller-owned data cannot revise the prepared native configuration. + req.AgentOptions["model"] = "different-model" + req.AgentSessionID = "different-thread" + copy(req.FunctionTools[0].Parameters, strings.ReplaceAll(string(req.FunctionTools[0].Parameters), "integer", "boolean")) + out := make(chan proto.Envelope, 20) + turn, err := e.StartTurn(t.Context(), "actual-run", proto.TextInput("actual prompt"), out) + if err != nil { + t.Fatal(err) + } + awaitExecutorTurn(t, turn, out) + counts := map[string]int{} + for _, frame := range preparationFrames(t, root) { + counts[frame.Method]++ + var params struct { + Model string `json:"model"` + ThreadID string `json:"threadId"` + DynamicTools []dynamicFunctionTool `json:"dynamicTools"` + Cwd string `json:"cwd"` + Environments json.RawMessage `json:"environments"` + } + if err := json.Unmarshal(frame.Params, ¶ms); err != nil { + t.Fatal(err) + } + if frame.Method == "thread/start" && (params.Model != "fixture-model" || len(params.DynamicTools) != 1 || + !strings.Contains(string(params.DynamicTools[0].InputSchema), "integer") || params.Cwd != cwd) { + t.Fatal("prepared configuration changed", string(frame.Params)) + } + if frame.Method == "thread/resume" && params.ThreadID != "fixture-native-thread" { + t.Fatal("prepared resume changed") + } + if len(params.Environments) != 0 { + t.Fatal("prepared environment changed") + } + } + if counts["initialize"] != 1 || counts["environment/status"] != 2 || counts[expectedThread] != 1 || counts["turn/start"] != 1 { + t.Fatal("unexpected native setup/start count", counts) + } + }) + } +} + +func TestExecutorUnavailableOwnerStartsNoTurn(t *testing.T) { + for _, reason := range []string{"closed", "owner cancelled", "rpc exited"} { + t.Run(reason, func(t *testing.T) { + req, cfg, root := preparationFixture(t) + owner, cancelOwner := context.WithCancel(context.Background()) + defer cancelOwner() + e, err := newExecutor(owner, req, cfg) + if err != nil { + t.Fatal(err) + } + defer e.Close(context.Background()) + switch reason { + case "closed": + err = e.Close(t.Context()) + case "owner cancelled": + cancelOwner() + case "rpc exited": + err = e.prepared.session.rpc.Close() + } + if err != nil { + t.Fatal(err) + } + out := make(chan proto.Envelope, 1) + if turn, err := e.StartTurn(t.Context(), "late-run", proto.TextInput("must not start"), out); turn != nil || err == nil { + t.Fatal("unavailable executor started a Turn", err) + } + if len(out) != 0 { + t.Fatal("rejected start emitted output") + } + assertPreparationOnly(t, root) + }) + } +} + func TestExecutorCancellationSettlesThenReuses(t *testing.T) { e, root := executorFixture(t, "complete") out := make(chan proto.Envelope, 20) diff --git a/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go b/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go index 20b42094b..c57e3297c 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go @@ -1,13 +1,11 @@ package codex import ( - "context" "encoding/json" "os" "path/filepath" "strings" "testing" - "time" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -155,9 +153,9 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { path := filepath.Join(root, "native-config.json") writeMCPHTTPConfigResponse(t, path, response) t.Setenv("OAC_TEST_PREPARATION_MCP_CONFIG", path) - p, err := newPreparation(t.Context(), req, cfg) + e, err := testExecutor(t, "complete", req, cfg) if strings.HasPrefix(mode, "reject") { - if err == nil || p != nil { + if err == nil || e != nil { t.Fatal("ambient MCP configuration admitted") } assertPreparationOnly(t, root) @@ -167,19 +165,18 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { if err != nil { t.Fatal(err) } - defer p.Close() assertPreparationOnly(t, root) home, err := allocCodexHome(req.AgentStateKey) if err != nil { t.Fatal(err) } - started, err := p.Start(t.Context(), "actual-run", proto.TextInput("actual prompt"), make(chan proto.Envelope, 8)) + out := make(chan proto.Envelope, 20) + turn, err := e.StartTurn(t.Context(), "actual-run", proto.TextInput("actual prompt"), out) if err != nil { t.Fatal(err) } - s := started.(*Session) - defer s.Cancel(context.Background()) - frames := waitPreparationMethod(t, root, "turn/start") + awaitExecutorTurn(t, turn, out) + frames := preparationFrames(t, root) checked, statuses := false, 0 for _, frame := range frames { if frame.Method == "environment/status" { @@ -204,12 +201,6 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { t.Fatal("preflight started discovery") } } - _ = s.Cancel(context.Background()) - select { - case <-s.waitDone: - case <-time.After(4 * time.Second): - t.Fatal("native fixture did not release") - } }) } } diff --git a/apps/daemon/internal/agent/codex/mcp_required_test.go b/apps/daemon/internal/agent/codex/mcp_required_test.go index 18084cc08..4dc500470 100644 --- a/apps/daemon/internal/agent/codex/mcp_required_test.go +++ b/apps/daemon/internal/agent/codex/mcp_required_test.go @@ -1,13 +1,13 @@ package codex import ( - "context" "os" "path/filepath" "strings" "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -34,18 +34,20 @@ func TestRequiredMCPWaitsForNativeThreadAndNeverRestartsFailedResume(t *testing. t.Setenv("OAC_TEST_PREPARATION_MCP_CONFIG", config) gate := filepath.Join(root, "required-initialization") t.Setenv("OAC_TEST_PREPARATION_THREAD_GATE", gate) - p, err := newPreparation(t.Context(), req, cfg) + e, err := testExecutor(t, "complete", req, cfg) if err != nil { t.Fatal(err) } - defer p.Close() out := make(chan proto.Envelope, 16) - started, err := p.Start(t.Context(), "required-run", proto.TextInput("actual prompt"), out) - if err != nil { - t.Fatal(err) + type started struct { + turn agent.Turn + err error } - s := started.(*Session) - defer s.Cancel(context.Background()) + result := make(chan started, 1) + go func() { + turn, err := e.StartTurn(t.Context(), "required-run", proto.TextInput("actual prompt"), out) + result <- started{turn, err} + }() waitPreparationMethod(t, root, method) time.Sleep(100 * time.Millisecond) assertNoTurn := func() { @@ -68,19 +70,23 @@ func TestRequiredMCPWaitsForNativeThreadAndNeverRestartsFailedResume(t *testing. if err := os.WriteFile(gate, []byte(state), 0o600); err != nil { t.Fatal(err) } - if state == "ready" { - waitPreparationMethod(t, root, "turn/start") - return - } + var start started select { - case <-s.waitDone: + case start = <-result: case <-time.After(4 * time.Second): - t.Fatal("failed initialization did not terminate") + t.Fatal("native initialization did not finish") + } + if (start.err == nil) != (state == "ready") { + t.Fatal("native initialization outcome changed", start.err) + } + frames := settledFrames(t, start.turn, out) + if state == "ready" { + return } assertNoTurn() failed := false - for len(out) > 0 { - failed = (<-out).Type == proto.TypeError || failed + for _, frame := range frames { + failed = frame.Type == proto.TypeError || failed } if !failed { t.Fatal("native initialization failure was not reported") diff --git a/apps/daemon/internal/agent/codex/preparation.go b/apps/daemon/internal/agent/codex/preparation.go index 263178522..0098920c0 100644 --- a/apps/daemon/internal/agent/codex/preparation.go +++ b/apps/daemon/internal/agent/codex/preparation.go @@ -11,15 +11,6 @@ import ( obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) -// Prepare connects the native harness without creating a thread or starting model -// work. req supplies configuration and a stable state key, but no RunID or Prompt. -// owner owns the entire harness lifetime, including the eventual Session; it must -// not be a disposable readiness-request context. Initialization/readiness use their -// existing operation-local deadlines. Close an unused preparation explicitly. -func Prepare(owner context.Context, req proto.PromptRequestPayload) (*Prepared, error) { - return newPreparation(owner, req, defaultSessionConfig()) -} - func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg sessionConfig) (*Prepared, error) { if req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil { return nil, errors.New("codex: structured output is not qualified") diff --git a/apps/daemon/internal/agent/codex/preparation_close_test.go b/apps/daemon/internal/agent/codex/preparation_close_test.go index 4abb0ce68..70cc5d769 100644 --- a/apps/daemon/internal/agent/codex/preparation_close_test.go +++ b/apps/daemon/internal/agent/codex/preparation_close_test.go @@ -43,3 +43,32 @@ func TestPreparedCloseWaitsForOwnerCleanup(t *testing.T) { } waitPreparedRelease(t, p, root) } + +func TestPreparedSessionCancellationDuringReadiness(t *testing.T) { + req, cfg, root := preparationFixture(t) + t.Setenv("OAC_TEST_PREPARATION_BLOCK", "1") + owner, cancel := context.WithCancel(t.Context()) + defer cancel() + result := make(chan error, 1) + go func() { + p, err := newPreparation(owner, req, cfg) + if p != nil { + _ = p.Close() + } + result <- err + }() + waitPreparationMethod(t, root, "environment/status") + cancel() + select { + case err := <-result: + if err == nil { + t.Fatal("cancelled readiness succeeded") + } + case <-time.After(4 * time.Second): + t.Fatal("cancelled readiness did not finish") + } + if len(preparedCatalogs(t, root)) != 0 { + t.Fatal("failed readiness leaked model catalog") + } + assertPreparationOnly(t, root) +} diff --git a/apps/daemon/internal/agent/codex/preparation_helpers_test.go b/apps/daemon/internal/agent/codex/preparation_helpers_test.go index 52c5f07ea..2749dc255 100644 --- a/apps/daemon/internal/agent/codex/preparation_helpers_test.go +++ b/apps/daemon/internal/agent/codex/preparation_helpers_test.go @@ -28,7 +28,6 @@ func preparationFixture(t *testing.T) (proto.PromptRequestPayload, sessionConfig t.Setenv("OAC_TEST_PREPARATION_FRAMES", filepath.Join(root, "frames.jsonl")) t.Setenv("OAC_TEST_PREPARATION_STATUS", filepath.Join(root, "environment-status")) t.Setenv("OAC_TEST_PREPARATION_BLOCK", "") - t.Setenv("OAC_TEST_PREPARATION_OBSERVE", "") for _, key := range []string{"CODEX_EXEC_SERVER_URL", "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL", "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID", "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"} { t.Setenv(key, "") } @@ -251,14 +250,6 @@ func TestPreparationFakeCodexProcess(t *testing.T) { } if frame.Method == "turn/start" { _ = output.Encode(map[string]any{"jsonrpc": "2.0", "method": "turn/started", "params": map[string]any{"threadId": "fixture-native-thread", "turn": map[string]string{"id": "fixture-native-turn"}}}) - if os.Getenv("OAC_TEST_PREPARATION_OBSERVE") == "1" { - for _, raw := range []string{ - `{"method":"item/completed","params":{"threadId":"fixture-native-thread","turnId":"fixture-native-turn","item":{"type":"agentMessage","id":"message","text":"observed partial answer"}}}`, - `{"method":"thread/tokenUsage/updated","params":{"threadId":"fixture-native-thread","turnId":"fixture-native-turn","tokenUsage":{"total":{"inputTokens":30,"cachedInputTokens":4,"outputTokens":10,"reasoningOutputTokens":2,"totalTokens":40}}}}`, - } { - _ = output.Encode(json.RawMessage(raw)) - } - } } } _ = log.Close() diff --git a/apps/daemon/internal/agent/codex/prepared.go b/apps/daemon/internal/agent/codex/prepared.go index 0049ea8e1..d069e9311 100644 --- a/apps/daemon/internal/agent/codex/prepared.go +++ b/apps/daemon/internal/agent/codex/prepared.go @@ -1,16 +1,8 @@ package codex -import ( - "context" - "errors" - "strings" - "sync" +import "sync" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// Prepared owns a connected native resource until Start transfers it to a Session. +// Prepared owns a connected native resource until an Executor takes it. // It observes owner cancellation and RPC exit, not continuous executor readiness. type Prepared struct { mu sync.Mutex @@ -18,64 +10,19 @@ type Prepared struct { plan SessionPlan resumeID string requireExistingNativeSession bool - claimed bool - closed bool started bool transferred chan struct{} } -var _ agent.PreparedCancellation = (*Prepared)(nil) - -// Start consumes the preparation once. ctx bounds only this start operation; -// cancellation after return does not cancel the transferred Session. The original -// owner context remains its lifetime context. On success the Session owns out. -func (p *Prepared) Start(ctx context.Context, runID string, prompt proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - if out == nil || strings.TrimSpace(runID) == "" || prompt.Validate() != nil { - return nil, errors.New("codex: start requires a run identity, prompt and output channel") - } - p.mu.Lock() - if p.claimed || p.closed { - p.mu.Unlock() - return nil, errors.New("codex: preparation is no longer available") - } - p.claimed = true - p.mu.Unlock() - - transferred := false - defer func() { - if !transferred { - _ = p.Close() - } - }() - p.mu.Lock() - defer p.mu.Unlock() - if p.closed || ctx.Err() != nil || p.session.cancelCtx.Err() != nil || !p.session.rpc.Alive() { - return nil, errors.New("codex: prepared harness is no longer available") - } - s := p.session - s.runID, s.out = runID, out - if s.observeSubagentIdentities { - s.startSubagentObservations() - } - s.registerHandlers() - p.started = true - close(p.transferred) - transferred = true - req := proto.PromptRequestPayload{RunID: runID, Input: prompt, AgentSessionID: p.resumeID, RequireExistingNativeSession: p.requireExistingNativeSession} - go s.run(p.plan, req) - return s, nil -} - // Close waits for unused teardown and plan cleanup, including another caller's -// ongoing Close. After successful Start it is inert; use -// the returned Session's cancellation path to release the transferred resource. +// ongoing Close. After an Executor takes the resource it is inert; use +// Executor.Close to release it. func (p *Prepared) Close() error { p.mu.Lock() if p.started { p.mu.Unlock() return nil } - p.closed = true p.mu.Unlock() p.session.cancelFn() err := p.session.rpc.Close() @@ -85,31 +32,6 @@ func (p *Prepared) Close() error { return err } -// Cancel fences Start and cancels the resource even after transfer. -func (p *Prepared) Cancel(ctx context.Context) error { - p.mu.Lock() - p.closed = true - started := p.started - p.mu.Unlock() - if started { - if err := p.session.Cancel(ctx); err != nil { - return err - } - select { - case <-p.session.waitDone: - return nil - case <-ctx.Done(): - return ctx.Err() - } - } - return p.Close() -} - -// CancellationOutcome returns observed state, not a guarantee of final output or quiescence. -func (p *Prepared) CancellationOutcome() proto.DonePayload { - return p.session.CancellationOutcome() -} - func (p *Prepared) watchOwner() { select { case <-p.session.cancelCtx.Done(): diff --git a/apps/daemon/internal/agent/codex/prepared_cancel_test.go b/apps/daemon/internal/agent/codex/prepared_cancel_test.go deleted file mode 100644 index 3cc919e44..000000000 --- a/apps/daemon/internal/agent/codex/prepared_cancel_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package codex - -import ( - "context" - "errors" - "reflect" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestPreparedCancelUnusedWaitsForCleanup(t *testing.T) { - req, cfg, root := preparationFixture(t) - req.AgentSessionID = "requested-but-unobserved-thread" - p, err := newPreparation(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - entered, release := make(chan struct{}), make(chan struct{}) - allowCleanup := sync.OnceFunc(func() { close(release) }) - defer allowCleanup() - cleanup := p.plan.Cleanup - var cleanups atomic.Int32 - p.plan.Cleanup = sync.OnceFunc(func() { - cleanups.Add(1) - close(entered) - <-release - cleanup() - }) - finished := make(chan error, 4) - for range 4 { - go func() { finished <- p.Cancel(context.Background()) }() - } - select { - case <-entered: - case <-time.After(4 * time.Second): - t.Fatal("cancellation did not begin cleanup") - } - out := make(chan proto.Envelope, 8) - if s, err := p.Start(t.Context(), "late", proto.TextInput("must not execute"), out); err == nil || s != nil { - t.Fatal("cancellation did not fence Start") - } - select { - case <-finished: - t.Fatal("cancellation returned before unused cleanup") - default: - } - allowCleanup() - for range 4 { - select { - case err := <-finished: - if err != nil { - t.Fatal(err) - } - case <-time.After(4 * time.Second): - t.Fatal("repeated cancellation did not finish") - } - } - if cleanups.Load() != 1 { - t.Fatal("cleanup ran more than once") - } - waitPreparedRelease(t, p, root) - assertPreparationOnly(t, root) - assertUnstartedCancellation(t, p) -} - -func assertUnstartedCancellation(t *testing.T, p *Prepared) { - t.Helper() - got := p.CancellationOutcome() - if got.Content != "" || len(got.Metadata) != 0 || !reflect.DeepEqual(got.Usage, proto.Usage{}) { - t.Fatalf("unobserved result was invented: %+v", got) - } -} - -func TestPreparedCancelTransferredPreservesObservedOutcome(t *testing.T) { - req, cfg, root := preparationFixture(t) - t.Setenv("OAC_TEST_PREPARATION_OBSERVE", "1") - p, err := newPreparation(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - defer p.Cancel(context.Background()) - started, err := p.Start(t.Context(), "run", proto.TextInput("prompt"), make(chan proto.Envelope, 16)) - if err != nil { - t.Fatal(err) - } - s := started.(*Session) - deadline := time.Now().Add(4 * time.Second) - for { - got := p.CancellationOutcome() - if got.Content == "observed partial answer" && got.Usage.Tokens != nil { - break - } - if time.Now().After(deadline) { - t.Fatal("native output and Usage were not observed") - } - time.Sleep(time.Millisecond) - } - if err := p.Close(); err != nil || !s.rpc.Alive() { - t.Fatal("Close cancelled transferred Session", err) - } - var calls sync.WaitGroup - for range 4 { - calls.Go(func() { - if err := p.Cancel(context.Background()); err != nil { - t.Error(err) - } - }) - } - calls.Wait() - select { - case <-s.waitDone: - case <-time.After(4 * time.Second): - t.Fatal("transferred Session did not finish") - } - waitPreparedRelease(t, p, root) - got := p.CancellationOutcome() - want := proto.TokenUsage{InputTokens: 30, CachedInputTokens: 4, OutputTokens: 10, ReasoningOutputTokens: 2, TotalTokens: 40} - if got.Content != "observed partial answer" || got.Metadata[proto.DoneMetaAgentSessionID] != "fixture-native-thread" || got.Usage.Tokens == nil || *got.Usage.Tokens != want { - t.Fatalf("observed outcome lost: %+v", got) - } - if !reflect.DeepEqual(got, s.CancellationOutcome()) { - t.Fatal("preparation and Session exposed different outcomes") - } - interrupts := 0 - for _, frame := range preparationFrames(t, root) { - if frame.Method == "turn/interrupt" { - interrupts++ - } - } - if interrupts != 1 { - t.Fatal("native cancellation was missing or repeated", interrupts) - } -} - -func TestPreparedCancelTransferredWaitsForCleanup(t *testing.T) { - req, cfg, root := preparationFixture(t) - p, err := newPreparation(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - defer p.Cancel(context.Background()) - entered, release := make(chan struct{}), make(chan struct{}) - allowCleanup := sync.OnceFunc(func() { close(release) }) - defer allowCleanup() - cleanup := p.session.cleanup - p.session.cleanup = sync.OnceFunc(func() { close(entered); <-release; cleanup() }) - p.plan.Cleanup = p.session.cleanup - out := make(chan proto.Envelope, 16) - if _, err := p.Start(t.Context(), "run", proto.TextInput("prompt"), out); err != nil { - t.Fatal(err) - } - waitPreparationMethod(t, root, "turn/start") - finished := make(chan error, 1) - go func() { finished <- p.Cancel(context.Background()) }() - select { - case <-entered: - case <-time.After(4 * time.Second): - t.Fatal("transferred cancellation did not begin cleanup") - } - for range out { - } - select { - case <-finished: - t.Fatal("cancellation returned after output closure but before local cleanup") - case <-p.session.waitDone: - t.Fatal("Session finished before local cleanup") - default: - } - ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) - defer cancel() - if err := p.Cancel(ctx); !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("blocked cleanup ignored caller deadline: %v", err) - } - allowCleanup() - select { - case err := <-finished: - if err != nil { - t.Fatal(err) - } - case <-time.After(4 * time.Second): - t.Fatal("cancellation did not finish after local cleanup") - } - waitPreparedRelease(t, p, root) - if err := p.Cancel(t.Context()); err != nil { - t.Fatalf("settled cleanup could not be retried: %v", err) - } -} - -func TestPreparedCancelRacingTransfer(t *testing.T) { - for range 8 { - req, cfg, root := preparationFixture(t) - p, err := newPreparation(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - begin := make(chan struct{}) - var calls sync.WaitGroup - calls.Go(func() { - <-begin - _, _ = p.Start(t.Context(), "run", proto.TextInput("prompt"), make(chan proto.Envelope, 16)) - }) - calls.Go(func() { <-begin; _ = p.Cancel(context.Background()) }) - calls.Go(func() { <-begin; _ = p.Close() }) - close(begin) - calls.Wait() - if err := p.Cancel(context.Background()); err != nil { - t.Fatal(err) - } - if p.started { - select { - case <-p.session.waitDone: - case <-time.After(4 * time.Second): - t.Fatal("racing Session was retained") - } - } else { - assertPreparationOnly(t, root) - assertUnstartedCancellation(t, p) - } - waitPreparedRelease(t, p, root) - if s, err := p.Start(t.Context(), "again", proto.TextInput("must not execute"), make(chan proto.Envelope, 8)); err == nil || s != nil { - t.Fatal("cancelled preparation started again") - } - } -} diff --git a/apps/daemon/internal/agent/codex/prepared_test.go b/apps/daemon/internal/agent/codex/prepared_test.go deleted file mode 100644 index 89d9ab0bf..000000000 --- a/apps/daemon/internal/agent/codex/prepared_test.go +++ /dev/null @@ -1,235 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "strings" - "sync" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestPreparedSessionTransfersSameResourceOnce(t *testing.T) { - for _, resume := range []bool{false, true} { - t.Run(map[bool]string{false: "new", true: "resumed"}[resume], func(t *testing.T) { - req, cfg, root := preparationFixture(t) - if resume { - req.AgentSessionID = "fixture-native-thread" - } - p, err := newPreparation(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - defer p.Close() - assertPreparationOnly(t, root) - if len(preparedCatalogs(t, root)) != 1 { - t.Fatal("preparation did not retain its model catalog") - } - pid := p.session.rpc.cmd.Process.Pid - cfgPreparedCwd := p.plan.Cwd - // Caller-owned data cannot revise the prepared native configuration. - req.AgentOptions["model"] = "different-model" - req.AgentSessionID = "different-thread" - copy(req.FunctionTools[0].Parameters, strings.ReplaceAll(string(req.FunctionTools[0].Parameters), "integer", "boolean")) - out := make(chan proto.Envelope, 8) - startCtx, stopStart := context.WithCancel(t.Context()) - started, err := p.Start(startCtx, "actual-run", proto.TextInput("actual prompt"), out) - stopStart() - if err != nil { - t.Fatal(err) - } - session := started.(*Session) - defer session.Cancel(context.Background()) - if session.rpc != p.session.rpc || session.rpc.cmd.Process.Pid != pid { - t.Fatal("start replaced the prepared native resource") - } - if err := p.Close(); err != nil || !session.rpc.Alive() { - t.Fatal("close cancelled transferred resource", err) - } - if again, err := p.Start(t.Context(), "second", proto.TextInput("second prompt"), out); err == nil || again != nil { - t.Fatal("preparation started twice", err) - } - frames := waitPreparationMethod(t, root, "turn/start") - counts := map[string]int{} - for _, frame := range frames { - counts[frame.Method]++ - if frame.PID != pid { - t.Fatal("preparation and start used different children") - } - var params struct { - Model string `json:"model"` - ThreadID string `json:"threadId"` - DynamicTools []dynamicFunctionTool `json:"dynamicTools"` - Cwd string `json:"cwd"` - Environments json.RawMessage `json:"environments"` - } - if err := json.Unmarshal(frame.Params, ¶ms); err != nil { - t.Fatal(err) - } - if frame.Method == "thread/start" { - if params.Model != "fixture-model" || len(params.DynamicTools) != 1 || !strings.Contains(string(params.DynamicTools[0].InputSchema), "integer") { - t.Fatal("prepared configuration changed", string(frame.Params)) - } - } - if frame.Method == "thread/resume" && params.ThreadID != "fixture-native-thread" { - t.Fatal("prepared resume changed") - } - if len(params.Environments) != 0 || (frame.Method == "thread/start" && params.Cwd != cfgPreparedCwd) || p.plan.Cwd != cfgPreparedCwd { - t.Fatal("prepared environment changed") - } - } - expectedThread := "thread/start" - if resume { - expectedThread = "thread/resume" - } - if counts["initialize"] != 1 || counts["environment/status"] != 2 || counts[expectedThread] != 1 || counts["turn/start"] != 1 { - t.Fatal("unexpected native setup/start count", counts) - } - if err := session.Cancel(context.Background()); err != nil { - t.Fatal(err) - } - select { - case <-session.waitDone: - case <-time.After(4 * time.Second): - t.Fatal("started session did not release") - } - waitPreparedRelease(t, p, root) - }) - } -} - -func TestPreparedSessionAbandonmentAndFailedStart(t *testing.T) { - for _, reason := range []string{"close", "owner cancelled", "rpc exited", "start cancelled"} { - t.Run(reason, func(t *testing.T) { - req, cfg, root := preparationFixture(t) - owner, cancelOwner := context.WithCancel(t.Context()) - defer cancelOwner() - p, err := newPreparation(owner, req, cfg) - if err != nil { - t.Fatal(err) - } - defer p.Close() - startCtx := t.Context() - switch reason { - case "close": - if err := p.Close(); err != nil { - t.Fatal(err) - } - case "owner cancelled": - cancelOwner() - case "rpc exited": - if err := p.session.rpc.Close(); err != nil { - t.Fatal(err) - } - case "start cancelled": - var cancel context.CancelFunc - startCtx, cancel = context.WithCancel(t.Context()) - cancel() - } - if reason == "owner cancelled" || reason == "rpc exited" || reason == "close" { - // Release must happen without a later Start driving cleanup. - waitPreparedRelease(t, p, root) - } - out := make(chan proto.Envelope, 8) - if started, err := p.Start(startCtx, "late-run", proto.TextInput("must not start"), out); err == nil || started != nil { - t.Fatal("abandoned preparation started", err) - } - waitPreparedRelease(t, p, root) - assertPreparationOnly(t, root) - if len(out) != 0 { - t.Fatal("failed preparation emitted run output") - } - count := 0 - for _, frame := range preparationFrames(t, root) { - if frame.Method == "environment/status" { - count++ - } - } - if count != 2 { - t.Fatal("failed start reconnected the native environment", count) - } - }) - } -} - -func TestPreparedSessionConcurrentStartAndClose(t *testing.T) { - req, cfg, root := preparationFixture(t) - p, err := newPreparation(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - defer p.Close() - begin := make(chan struct{}) - var wg sync.WaitGroup - started := make(chan *Session, 8) - for range 8 { - wg.Add(1) - go func() { - defer wg.Done() - <-begin - s, err := p.Start(t.Context(), "run", proto.TextInput("prompt"), make(chan proto.Envelope, 8)) - if err == nil { - started <- s.(*Session) - } - }() - } - wg.Add(1) - go func() { defer wg.Done(); <-begin; _ = p.Close() }() - close(begin) - wg.Wait() - close(started) - count := 0 - for session := range started { - count++ - _ = session.Cancel(context.Background()) - select { - case <-session.waitDone: - case <-time.After(4 * time.Second): - t.Fatal("concurrent start retained a session") - } - } - if count > 1 { - t.Fatal("multiple ownership transfers", count) - } - waitPreparedRelease(t, p, root) - turns := 0 - for _, frame := range preparationFrames(t, root) { - if frame.Method == "turn/start" { - turns++ - } - } - if turns > 1 { - t.Fatal("multiple native Turns", turns) - } -} - -func TestPreparedSessionCancellationDuringReadiness(t *testing.T) { - req, cfg, root := preparationFixture(t) - t.Setenv("OAC_TEST_PREPARATION_BLOCK", "1") - owner, cancel := context.WithCancel(t.Context()) - defer cancel() - result := make(chan error, 1) - go func() { - p, err := newPreparation(owner, req, cfg) - if p != nil { - _ = p.Close() - } - result <- err - }() - waitPreparationMethod(t, root, "environment/status") - cancel() - select { - case err := <-result: - if err == nil { - t.Fatal("cancelled readiness succeeded") - } - case <-time.After(4 * time.Second): - t.Fatal("cancelled readiness did not finish") - } - if len(preparedCatalogs(t, root)) != 0 { - t.Fatal("failed readiness leaked model catalog") - } - assertPreparationOnly(t, root) -} diff --git a/apps/daemon/internal/agent/codex/recovery_test.go b/apps/daemon/internal/agent/codex/recovery_test.go index 78aad7c16..ebabf06b4 100644 --- a/apps/daemon/internal/agent/codex/recovery_test.go +++ b/apps/daemon/internal/agent/codex/recovery_test.go @@ -160,26 +160,20 @@ func TestPreparedRecoveryCannotStartWithoutExistingHistory(t *testing.T) { req.DisableExecutionEnvironment = false req.LocalEnvironment = &proto.LocalEnvironment{ID: uuid.NewString(), WorkspaceDirectory: "/workspace", NetworkAccess: "enabled", CapabilitySources: &agentcapabilities.Input{}, WorkspaceRoot: cwd} } - p, err := newPreparation(t.Context(), req, cfg) + e, err := testExecutor(t, "complete", req, cfg) if err != nil { t.Fatal(err) } - defer p.Close() - if p.plan.Cwd != cwd { - t.Fatalf("cwd = %q, want %q", p.plan.Cwd, cwd) + if e.prepared.plan.Cwd != cwd { + t.Fatalf("cwd = %q, want %q", e.prepared.plan.Cwd, cwd) } assertPreparationOnly(t, root) out := make(chan proto.Envelope, 16) - session, err := p.Start(t.Context(), "recovery-run", proto.TextInput("continue"), out) - if err != nil { - t.Fatal(err) - } - defer session.Cancel(context.Background()) - select { - case <-p.session.waitDone: - case <-time.After(4 * time.Second): - t.Fatal("recovery did not terminate") + turn, err := e.StartTurn(t.Context(), "recovery-run", proto.TextInput("continue"), out) + if err == nil { + t.Fatal("missing history started a Turn") } + settledFrames(t, turn, out) found := false for _, frame := range preparationFrames(t, root) { if frame.Method == "thread/list" { diff --git a/apps/daemon/internal/agent/codex/session_run.go b/apps/daemon/internal/agent/codex/session_run.go index b9f26c933..3394acf90 100644 --- a/apps/daemon/internal/agent/codex/session_run.go +++ b/apps/daemon/internal/agent/codex/session_run.go @@ -9,28 +9,6 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func (s *Session) run(plan SessionPlan, req proto.PromptRequestPayload) { - defer close(s.waitDone) - defer s.stopFunctionCalls() - defer s.cleanup() - defer s.closeRunOutput() - - if err := s.startNative(s.cancelCtx, plan, req); err != nil { - s.emitTerminal(err.Error(), true) - return - } - - // Block until terminal handlers close the RPC child or cancellation arrives. - select { - case <-s.rpc.Done(): - if !s.cancelled.Load() && s.cancelCtx.Err() == nil { - s.emitTerminal("codex: connection closed before the run completed", true) - } - case <-s.cancelCtx.Done(): - _ = s.rpc.Close() - } -} - func (s *Session) startNative(ctx context.Context, plan SessionPlan, req proto.PromptRequestPayload) error { if s.currentThreadID() == "" { if err := s.resolveThread(req, plan); err != nil { diff --git a/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go b/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go index 7345d5dae..6dc663ea0 100644 --- a/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go +++ b/apps/daemon/internal/agent/codex/session_steering_lifecycle_test.go @@ -83,9 +83,10 @@ func TestBlockedSteeringWriteEndsRunWithTerminalFrames(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() out := make(chan proto.Envelope, 8) + turnCtx, cancelTurn := context.WithCancel(ctx) s := &Session{ - runID: "run", rpc: client.JSONRPCClient, cancelCtx: ctx, out: out, resolvedModel: "synthetic", - cfg: sessionConfig{logger: obslog.Bg()}, waitDone: make(chan struct{}), cleanup: func() {}, + executor: &Executor{}, runID: "run", rpc: client.JSONRPCClient, cancelCtx: turnCtx, cancelFn: cancelTurn, out: out, resolvedModel: "synthetic", + cfg: sessionConfig{logger: obslog.Bg()}, waitDone: make(chan struct{}), outputDone: make(chan struct{}), cleanup: func() {}, bufs: NewItemBuffers(), } s.registerHandlers() @@ -119,7 +120,8 @@ func TestBlockedSteeringWriteEndsRunWithTerminalFrames(t *testing.T) { } ready <- nil }() - go s.run(SessionPlan{Model: "synthetic"}, proto.PromptRequestPayload{Input: proto.TextInput("first")}) + // Executor.StartTurn starts and settles each Turn through these two steps. + go s.settleExecutorTurn(s.startNative(ctx, SessionPlan{Model: "synthetic"}, proto.PromptRequestPayload{Input: proto.TextInput("first")})) if err := <-ready; err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/unsupported.go b/apps/daemon/internal/agent/codex/unsupported.go index a2a092928..3da26fb7f 100644 --- a/apps/daemon/internal/agent/codex/unsupported.go +++ b/apps/daemon/internal/agent/codex/unsupported.go @@ -28,15 +28,3 @@ func (s *Session) ListWorkspaceDirectory(context.Context, string, int) (agent.Wo func (s *Session) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported } - -func (s *Prepared) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *Prepared) ListWorkspaceDirectory(context.Context, string, int) (agent.WorkspaceDirectoryResult, error) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *Prepared) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} diff --git a/apps/daemon/internal/agent/codex/unsupported_test.go b/apps/daemon/internal/agent/codex/unsupported_test.go index 0f0936197..fe2fe3616 100644 --- a/apps/daemon/internal/agent/codex/unsupported_test.go +++ b/apps/daemon/internal/agent/codex/unsupported_test.go @@ -24,21 +24,21 @@ func TestUnsupportedExtensionsHaveNoNativeEffects(t *testing.T) { t.Fatal("unsupported error disclosed input") } } - for _, owner := range []agent.WorkspaceReader{(*Executor)(nil), (*Session)(nil), (*Prepared)(nil)} { + for _, owner := range []agent.WorkspaceReader{(*Executor)(nil), (*Session)(nil)} { result, err := owner.ReadWorkspaceFile(ctx, secret, 1) check(err) if len(result.Data) != 0 || result.Truncated { t.Fatal("unsupported read fabricated data") } } - for _, owner := range []agent.WorkspaceDirectoryLister{(*Executor)(nil), (*Session)(nil), (*Prepared)(nil)} { + for _, owner := range []agent.WorkspaceDirectoryLister{(*Executor)(nil), (*Session)(nil)} { result, err := owner.ListWorkspaceDirectory(ctx, secret, 1) check(err) if len(result.Entries) != 0 || result.Truncated { t.Fatal("unsupported listing fabricated entries") } } - for _, owner := range []agent.WorkspaceWriter{(*Executor)(nil), (*Session)(nil), (*Prepared)(nil)} { + for _, owner := range []agent.WorkspaceWriter{(*Executor)(nil), (*Session)(nil)} { result, err := owner.WriteWorkspaceFile(ctx, secret, []byte(secret)) check(err) if result.SizeBytes != 0 { diff --git a/apps/daemon/internal/agent/configuration_test.go b/apps/daemon/internal/agent/configuration_test.go index 19d5b8e22..2d36d96da 100644 --- a/apps/daemon/internal/agent/configuration_test.go +++ b/apps/daemon/internal/agent/configuration_test.go @@ -22,36 +22,21 @@ func TestEveryRegistryEntryPreparesTheBoundModelConfiguration(t *testing.T) { calls++ return nil, expected }) - registry.RegisterPreparation("fixture", true, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - calls++ - return nil, expected - }) configuration.Providers[0].Protocol = "anthropic" executor, _ := registry.ResolveExecutor("fixture") - preparation, _ := registry.ResolvePreparation("fixture") - entries := []func(proto.PromptRequestPayload) error{ - func(req proto.PromptRequestPayload) error { _, err := executor(t.Context(), req); return err }, - func(req proto.PromptRequestPayload) error { _, err := preparation(t.Context(), req); return err }, - } - for _, entry := range entries { - for _, options := range []map[string]any{ - {"model": nil}, - {"model": "fixture", "model_provider": map[string]any{"protocol": "anthropic", "base_url": "https://provider.example", "api_key": "private-sentinel"}}, - {"model_provider": map[string]any{"protocol": "responses", "base_url": "https://provider.example", "api_key": "private-sentinel"}}, - {"harness_config": map[string]any{"unknown": "private-sentinel"}}, - } { - before := calls - err := entry(proto.PromptRequestPayload{AgentOptions: options}) - if err == nil || errors.Is(err, expected) || calls != before || strings.Contains(err.Error(), "private-sentinel") { - t.Fatal("invalid configuration reached native entry or leaked values") - } - } - if err := entry(proto.PromptRequestPayload{AgentOptions: map[string]any{"model": "fixture", "model_provider": map[string]any{"protocol": "responses", "base_url": "https://provider.example", "api_key": "private-sentinel"}}}); !errors.Is(err, expected) { - t.Fatal("bound declaration was lost or mutated", err) + for _, options := range []map[string]any{ + {"model": nil}, + {"model": "fixture", "model_provider": map[string]any{"protocol": "anthropic", "base_url": "https://provider.example", "api_key": "private-sentinel"}}, + {"model_provider": map[string]any{"protocol": "responses", "base_url": "https://provider.example", "api_key": "private-sentinel"}}, + {"harness_config": map[string]any{"unknown": "private-sentinel"}}, + } { + _, err := executor(t.Context(), proto.PromptRequestPayload{AgentOptions: options}) + if err == nil || errors.Is(err, expected) || calls != 0 || strings.Contains(err.Error(), "private-sentinel") { + t.Fatal("invalid configuration reached native entry or leaked values") } } - if calls != 2 { - t.Fatal("unexpected native calls", calls) + if _, err := executor(t.Context(), proto.PromptRequestPayload{AgentOptions: map[string]any{"model": "fixture", "model_provider": map[string]any{"protocol": "responses", "base_url": "https://provider.example", "api_key": "private-sentinel"}}}); !errors.Is(err, expected) || calls != 1 { + t.Fatal("bound declaration was lost or mutated", err) } } diff --git a/apps/daemon/internal/agent/contract_declarations_test.go b/apps/daemon/internal/agent/contract_declarations_test.go index 386cecee6..7915f9df6 100644 --- a/apps/daemon/internal/agent/contract_declarations_test.go +++ b/apps/daemon/internal/agent/contract_declarations_test.go @@ -22,11 +22,9 @@ func TestPublicHarnessContractDeclarations(t *testing.T) { "DurableSteerer": {"session"}, "Steerer": {"session"}, "FunctionResultSubmitter": {"session"}, - "WorkspaceReader": {"executor", "prepared", "session"}, - "WorkspaceDirectoryLister": {"executor", "prepared", "session"}, - "WorkspaceWriter": {"executor", "prepared", "session"}, - "Prepared": {"prepared"}, - "PreparedCancellation": {"prepared"}, + "WorkspaceReader": {"executor", "session"}, + "WorkspaceDirectoryLister": {"executor", "session"}, + "WorkspaceWriter": {"executor", "session"}, } files, err := filepath.Glob("*.go") if err != nil { diff --git a/apps/daemon/internal/agent/harness.go b/apps/daemon/internal/agent/harness.go index 3a798043b..23f8d37e7 100644 --- a/apps/daemon/internal/agent/harness.go +++ b/apps/daemon/internal/agent/harness.go @@ -14,8 +14,8 @@ // Registration: each adapter exports one Declaration. The Runtime discovers the // static declaration list and installs each resulting Runtime through Register. // Availability and factory selection belong to the adapter. RegisterKind resets -// the factories, so Register installs it first. Preparation capabilities are -// derived from the declared factories. +// the factories, so Register installs it first. RegisterExecutor derives the +// Preparation capability; the adapter declares WorkspaceReadPreparation. // // Runtime registration and Core service qualification remain separate. A public // Harness also needs a profile in services/core/internal/engine; advertising @@ -51,10 +51,8 @@ type DiscoveryOptions struct { // Runtime binds one discovered descriptor to its native factories. type Runtime struct { - Info proto.SupportedAgentKind - Preparation PreparationFactory - Executor ExecutorFactory - WorkspaceReadPreparation bool + Info proto.SupportedAgentKind + Executor ExecutorFactory } // Register installs a discovered Runtime with its declaration's configuration. @@ -62,21 +60,18 @@ func (r *Registry) Register(declaration Declaration, runtime Runtime) { if runtime.Info.Kind != declaration.Info.Kind { panic("agent.Registry.Register: discovery kind differs from declaration") } - if !runtime.Info.Available && (runtime.Executor != nil || runtime.Preparation != nil) { + if !runtime.Info.Available && runtime.Executor != nil { panic("agent.Registry.Register: unavailable runtime has factories") } r.RegisterKind(runtime.Info, declaration.Configuration) if runtime.Executor != nil { r.RegisterExecutor(runtime.Info.Kind, runtime.Executor) } - if runtime.Preparation != nil { - r.RegisterPreparation(runtime.Info.Kind, runtime.WorkspaceReadPreparation, runtime.Preparation) - } } // Model configuration has one shared contract, authored in // internal/harnessconfig/harness.go. RegisterKind requires that declaration; -// RegisterExecutor and RegisterPreparation inherit it. Every registered entry +// RegisterExecutor inherits it. Every registered entry // validates model, provider and native parameters before calling native code. // The declaration belongs to the adapter and is also consumed by Core. Keep // adapter field rules and rendering private. That shared contract owns frozen @@ -121,8 +116,8 @@ type TurnSettlement struct { Reason string } -// Session is the cancellation and outcome surface shared by Turn and -// PreparedCancellation. Every owner exposes observed state. +// Session is the cancellation and outcome surface of a Turn. Every owner +// exposes observed state. // For Executor-owned Turns, AwaitSettlement and Executor.Close define settlement // and resource retirement; Cancel alone does not transfer resource ownership. type Session interface { @@ -187,34 +182,6 @@ type WorkspaceWriter interface { WriteWorkspaceFile(context.Context, string, []byte) (WorkspaceWriteResult, error) } -// Separate preparation for qualified workspace access. - -// Prepared owns native resources until Start returns a non-nil Session. The -// preparation owner context spans the eventual Session; Start's context is local -// to that operation. A nil Session leaves preparation cleanup with the caller. -type Prepared interface { - // Start transfers output ownership only when it returns a non-nil Session. - // A nil Session leaves the caller as the sole owner of closing out, and the - // implementation must not retain or write to it after Start returns. - Start(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (Session, error) - // Close retains unused ownership on error; callers may retry settlement. - Close() error -} - -// PreparedCancellation is required for executable preparations and follows the -// same native resource across Start. Read-only preparations need only Prepared. -type PreparedCancellation interface { - Prepared - Session - // Cancel returns after local cleanup and all output writes have stopped. - // An error retains ownership so callers can retry this exact object serially. - Cancel(context.Context) error -} - -// A factory may return both a resource and an error when construction failed but -// cleanup remains unconfirmed. The caller must retain and close that resource. -type PreparationFactory func(context.Context, proto.PromptRequestPayload) (Prepared, error) - // Kind registration. // RegisterKind installs the heartbeat descriptor and model configuration for an @@ -235,10 +202,8 @@ func (r *Registry) RegisterKind(info proto.SupportedAgentKind, configuration har r.mu.Lock() defer r.mu.Unlock() r.configurations[kind] = configuration - delete(r.preparers, kind) delete(r.executors, kind) info.Capabilities.Preparation = proto.CapabilityUnsupported - info.Capabilities.WorkspaceReadPreparation = proto.CapabilityUnsupported r.kinds[kind] = info } @@ -264,26 +229,3 @@ func (r *Registry) RegisterExecutor(kind string, factory ExecutorFactory) { info.Capabilities.Preparation = proto.CapabilitySupported r.kinds[kind] = info } - -// RegisterPreparation installs a separate execution-only path. -func (r *Registry) RegisterPreparation(kind string, workspaceRead bool, prepare PreparationFactory) { - r.mu.Lock() - defer r.mu.Unlock() - info, exists := r.kinds[kind] - if !exists || prepare == nil { - panic("agent.Registry.RegisterPreparation: registered kind and factory required") - } - configuration, declared := r.configurations[kind] - if !declared { - panic("agent.Registry.RegisterPreparation: configuration required") - } - r.preparers[kind] = func(ctx context.Context, req proto.PromptRequestPayload) (Prepared, error) { - if _, err := configuration.Prepare(req.AgentOptions); err != nil { - return nil, err - } - return prepare(ctx, req) - } - info.Capabilities.Preparation = proto.CapabilitySupported - info.Capabilities.WorkspaceReadPreparation = proto.CapabilityFromBool(workspaceRead) - r.kinds[kind] = info -} diff --git a/apps/daemon/internal/agent/mcode/contracts.go b/apps/daemon/internal/agent/mcode/contracts.go index 025ef1890..3f01ff20b 100644 --- a/apps/daemon/internal/agent/mcode/contracts.go +++ b/apps/daemon/internal/agent/mcode/contracts.go @@ -14,12 +14,7 @@ var ( _ agent.WorkspaceReader = (*Session)(nil) _ agent.WorkspaceDirectoryLister = (*Session)(nil) _ agent.WorkspaceWriter = (*Session)(nil) - _ agent.Prepared = (*prepared)(nil) - _ agent.PreparedCancellation = (*prepared)(nil) _ agent.WorkspaceReader = (*executor)(nil) _ agent.WorkspaceDirectoryLister = (*executor)(nil) _ agent.WorkspaceWriter = (*executor)(nil) - _ agent.WorkspaceReader = (*prepared)(nil) - _ agent.WorkspaceDirectoryLister = (*prepared)(nil) - _ agent.WorkspaceWriter = (*prepared)(nil) ) diff --git a/apps/daemon/internal/agent/mcode/declaration.go b/apps/daemon/internal/agent/mcode/declaration.go index 5216634f5..bd44257ea 100644 --- a/apps/daemon/internal/agent/mcode/declaration.go +++ b/apps/daemon/internal/agent/mcode/declaration.go @@ -75,10 +75,6 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, r if runtime.Info.Available { runtime.Executor = NewExecutorFactory(workspace) } - if workspace != nil { - runtime.Preparation = NewPreparationFactory(*workspace) - runtime.WorkspaceReadPreparation = true - } fmt.Fprintf(options.Stdout, "mcode preflight ok (%s)\n", version) return runtime } diff --git a/apps/daemon/internal/agent/mcode/declaration_test.go b/apps/daemon/internal/agent/mcode/declaration_test.go index d39aada63..b60d8b348 100644 --- a/apps/daemon/internal/agent/mcode/declaration_test.go +++ b/apps/daemon/internal/agent/mcode/declaration_test.go @@ -19,7 +19,7 @@ func TestMCodeExecutionOptInIsVersionBound(t *testing.T) { t.Setenv("OAC_RUNTIME_MCODE_AGENTS_API", tc.enabled) rc := agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard} runtime := discoverWithCheck(t.Context(), rc, Declaration.Info, func(context.Context, string) (string, error) { return tc.version, nil }) - if runtime.Executor == nil || runtime.Preparation != nil { + if runtime.Executor == nil || runtime.Info.Capabilities.WorkspaceReadPreparation.IsSupported() { t.Fatalf("factories: %+v", runtime) } info := runtime.Info diff --git a/apps/daemon/internal/agent/mcode/discovery_workspace.go b/apps/daemon/internal/agent/mcode/discovery_workspace.go index 4438aa643..11d7a6d55 100644 --- a/apps/daemon/internal/agent/mcode/discovery_workspace.go +++ b/apps/daemon/internal/agent/mcode/discovery_workspace.go @@ -71,7 +71,6 @@ func discoverWorkspace(parent context.Context, options agent.DiscoveryOptions, r caps := &runtime.Info.Capabilities caps.EnvironmentNone = proto.CapabilityUnsupported - caps.Preparation, caps.LocalEnvironment = proto.CapabilitySupported, proto.CapabilitySupported - caps.WorkspaceReadPreparation = proto.CapabilitySupported + caps.LocalEnvironment, caps.WorkspaceReadPreparation = proto.CapabilitySupported, proto.CapabilitySupported return &c } diff --git a/apps/daemon/internal/agent/mcode/environment_mcp_test.go b/apps/daemon/internal/agent/mcode/environment_mcp_test.go index 564227bdb..5ca8565f9 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp_test.go @@ -32,11 +32,11 @@ func TestEnvironmentMCPUsesFixedLauncherForNewAndLoadedSessions(t *testing.T) { } t.Setenv("USER_SELECTED", "must-not-resolve-from-daemon") t.Setenv("MODEL_SECRET", "must-not-forward") - resource, err := NewPreparationFactory(c)(t.Context(), req) + resource, err := NewExecutorFactory(&c)(t.Context(), req) if err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = resource.Close() }) + t.Cleanup(func() { _ = resource.Close(context.Background()) }) raw, err := os.ReadFile(record + ".session") if err != nil { t.Fatal(err) @@ -116,18 +116,18 @@ func TestEnvironmentMCPCancelSettlesPendingObservationBeforeDone(t *testing.T) { if err := os.WriteFile(c.Binary, []byte(strings.Replace(string(script), "HELPER=prepared", "HELPER=prepared-mcp-cancel", 1)), 0700); err != nil { t.Fatal(err) } - resource, err := NewPreparationFactory(c)(t.Context(), req) + resource, err := NewExecutorFactory(&c)(t.Context(), req) if err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() out := make(chan proto.Envelope, 16) - session, err := resource.Start(ctx, "run", proto.TextInput("invoke and wait"), out) + session, err := resource.StartTurn(ctx, "run", proto.TextInput("invoke and wait"), out) if err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = resource.(*prepared).Cancel(context.Background()) }) + t.Cleanup(func() { _ = resource.Close(context.Background()) }) select { case event := <-out: var call proto.ToolCallPayload @@ -190,11 +190,11 @@ func TestEnvironmentHTTPMCPUsesEphemeralACPConfiguration(t *testing.T) { item.BearerToken = &value } req.LocalEnvironment.MCP = []proto.EnvironmentMCP{item} - resource, err := NewPreparationFactory(c)(t.Context(), req) + resource, err := NewExecutorFactory(&c)(t.Context(), req) if err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = resource.Close() }) + t.Cleanup(func() { _ = resource.Close(context.Background()) }) raw, err := os.ReadFile(record + ".session") if err != nil { t.Fatal(err) @@ -219,7 +219,7 @@ func TestEnvironmentHTTPMCPUsesEphemeralACPConfiguration(t *testing.T) { } else if len(server.Headers) != 0 { t.Fatal("anonymous MCP inherited credentials") } - dataDir := resource.(*prepared).executor.opts.DataDir + dataDir := resource.(*executor).opts.DataDir for _, name := range []string{"config.yaml", "mcp.json", "workspace-profile.json"} { body, err := os.ReadFile(filepath.Join(dataDir, name)) if err != nil && !os.IsNotExist(err) { diff --git a/apps/daemon/internal/agent/mcode/executor_test.go b/apps/daemon/internal/agent/mcode/executor_test.go index 979958263..59a1f5d60 100644 --- a/apps/daemon/internal/agent/mcode/executor_test.go +++ b/apps/daemon/internal/agent/mcode/executor_test.go @@ -13,6 +13,26 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) +func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload, string) { + t.Helper() + r := testRequest(t) + r.RunID, r.Input = "", nil + r.DisableExecutionEnvironment = false + r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "enabled", WorkspaceRoot: t.TempDir()} + record := filepath.Join(t.TempDir(), "calls") + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + binary := filepath.Join(t.TempDir(), "native") + quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } + script := "#!/bin/sh\nexport OAC_TEST_MCODE_HELPER=prepared\nexport OAC_TEST_MCODE_RECORD=" + quote(record) + "\nexec " + quote(exe) + " -test.run=^TestMCodeProcess$ -- \"$@\"\n" + if err := os.WriteFile(binary, []byte(script), 0700); err != nil { + t.Fatal(err) + } + return WorkspaceConfig{Binary: binary, Node: "/usr/bin/node", Bridge: "/opt/bridge.mjs", Directory: r.LocalEnvironment.WorkspaceRoot, Network: "enabled", Scratch: t.TempDir()}, r, record +} + func executorFixture(t *testing.T, scenario string, workspace bool) (*executor, string) { t.Helper() config, req, record := workspaceFixture(t) diff --git a/apps/daemon/internal/agent/mcode/preparation.go b/apps/daemon/internal/agent/mcode/preparation.go deleted file mode 100644 index f4b00ed1d..000000000 --- a/apps/daemon/internal/agent/mcode/preparation.go +++ /dev/null @@ -1,86 +0,0 @@ -package mcode - -import ( - "context" - "fmt" - "sync" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// Prepared retains its single admission API over the same native Executor. -// Runtime's Session lifecycle uses Executor directly. -type prepared struct { - mu sync.Mutex - executor *executor - session *Session - started, closed bool -} - -func NewPreparationFactory(config WorkspaceConfig) agent.PreparationFactory { - factory := NewExecutorFactory(&config) - return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - value, err := factory(ctx, req) - if err != nil { - if value != nil { - return &prepared{executor: value.(*executor)}, err - } - return nil, err - } - return &prepared{executor: value.(*executor)}, nil - } -} - -func (p *prepared) Start(ctx context.Context, runID string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - p.mu.Lock() - defer p.mu.Unlock() - if p.closed || p.started { - return nil, fmt.Errorf("mcode: preparation is no longer available") - } - turn, err := p.executor.StartTurn(ctx, runID, input, out) - if turn != nil { - p.started, p.session = true, turn.(*Session) - } - return turn, err -} - -func (p *prepared) Close() error { - p.mu.Lock() - started := p.started - if !started { - p.closed = true - } - p.mu.Unlock() - if started { - return nil - } - return p.executor.Close(context.Background()) -} - -func (p *prepared) Cancel(ctx context.Context) error { - p.mu.Lock() - p.closed = true - current, started := p.session, p.started - p.mu.Unlock() - if started { - if err := current.Cancel(ctx); err != nil { - // The single-use Prepared owns disposal as well as cancellation. - if closeErr := p.executor.Close(ctx); closeErr != nil { - return closeErr - } - return nil - } - } - return p.executor.Close(ctx) -} - -func (p *prepared) CancellationOutcome() proto.DonePayload { - p.mu.Lock() - s := p.session - p.mu.Unlock() - if s == nil { - return proto.DonePayload{} - } - return s.CancellationOutcome() -} diff --git a/apps/daemon/internal/agent/mcode/preparation_test.go b/apps/daemon/internal/agent/mcode/preparation_test.go deleted file mode 100644 index 92b9a2aa0..000000000 --- a/apps/daemon/internal/agent/mcode/preparation_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package mcode - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload, string) { - t.Helper() - r := testRequest(t) - r.RunID, r.Input = "", nil - r.DisableExecutionEnvironment = false - r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "enabled", WorkspaceRoot: t.TempDir()} - record := filepath.Join(t.TempDir(), "calls") - exe, err := os.Executable() - if err != nil { - t.Fatal(err) - } - binary := filepath.Join(t.TempDir(), "native") - quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } - script := "#!/bin/sh\nexport OAC_TEST_MCODE_HELPER=prepared\nexport OAC_TEST_MCODE_RECORD=" + quote(record) + "\nexec " + quote(exe) + " -test.run=^TestMCodeProcess$ -- \"$@\"\n" - if err := os.WriteFile(binary, []byte(script), 0700); err != nil { - t.Fatal(err) - } - return WorkspaceConfig{Binary: binary, Node: "/usr/bin/node", Bridge: "/opt/bridge.mjs", Directory: r.LocalEnvironment.WorkspaceRoot, Network: "enabled", Scratch: t.TempDir()}, r, record -} - -func TestPreparedWorkspaceHasOneInputAndOutputOwner(t *testing.T) { - c, r, record := workspaceFixture(t) - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) - defer cancel() - resource, err := NewPreparationFactory(c)(ctx, r) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - t.Cleanup(func() { _ = p.Cancel(context.Background()) }) - raw, err := os.ReadFile(record) - if err != nil || strings.Contains(string(raw), "session/prompt") { - t.Fatalf("preparation consumed input: %q %v", raw, err) - } - if p.executor.opts.Dir != r.LocalEnvironment.WorkspaceRoot || p.executor.opts.DataDir == r.LocalEnvironment.WorkspaceRoot { - t.Fatal("native cwd must use the workspace without moving Session state") - } - out := make(chan proto.Envelope) - var wg sync.WaitGroup - winners := make(chan bool, 8) - for range 8 { - wg.Add(1) - go func() { - defer wg.Done() - _, err := p.Start(ctx, "run", proto.TextInput("input"), out) - winners <- err == nil - }() - } - wg.Wait() - close(winners) - n := 0 - for winner := range winners { - if winner { - n++ - } - } - if n != 1 { - t.Fatalf("owners=%d", n) - } - if err := p.Close(); err != nil { - t.Fatal(err) - } - deltas, done := 0, 0 - for e := range out { - if e.Type == proto.TypeError { - t.Fatalf("execution: %s", e.Payload) - } - if e.Type == proto.TypeDelta { - deltas++ - } - if e.Type == proto.TypeDone { - done++ - select { - case <-p.executor.connection.exited: - t.Fatal("successful Turn disposed the reusable native owner") - default: - } - var d proto.DonePayload - _ = json.Unmarshal(e.Payload, &d) - if d.Metadata[proto.DoneMetaAgentSessionID] != "native-1" { - t.Fatal("native identity lost") - } - } - } - if deltas != 100 || done != 1 { - t.Fatalf("deltas=%d done=%d", deltas, done) - } - raw, _ = os.ReadFile(record) - if strings.Count(string(raw), "session/prompt") != 1 { - t.Fatalf("inputs=%s", raw) - } -} - -func TestPreparedWorkspaceCloseBeforeStart(t *testing.T) { - c, r, _ := workspaceFixture(t) - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - resource, err := NewPreparationFactory(c)(ctx, r) - if err != nil { - t.Fatal(err) - } - if err = resource.Close(); err != nil { - t.Fatal(err) - } - if _, err = resource.Start(ctx, "run", proto.TextInput("input"), make(chan proto.Envelope)); err == nil { - t.Fatal("released preparation started") - } - if err = resource.Close(); err != nil { - t.Fatal(err) - } -} - -func TestPreparedSubagentsReleaseUnusedOwner(t *testing.T) { - for _, method := range []string{"close", "cancel"} { - t.Run(method, func(t *testing.T) { - c, r, record := workspaceFixture(t) - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - resource, err := NewPreparationFactory(c)(ctx, r) - if err != nil { - t.Fatal(err) - } - p := resource.(*prepared) - // The fixture only implements preparation. Enable the owner's child - // branch after initialization to verify unused owners never enter it. - p.executor.req.DisableSubagents = false - ended := make(chan error, 1) - go func() { - if method == "close" { - ended <- p.Close() - } else { - ended <- p.Cancel(ctx) - } - }() - select { - case err := <-ended: - if err != nil { - t.Fatal(err) - } - case <-ctx.Done(): - p.executor.connection.process.Cancel() - t.Fatal("unused owner did not close") - } - raw, err := os.ReadFile(record) - if err != nil || strings.Contains(string(raw), "session/prompt") || strings.Contains(string(raw), "delegation/stop") { - t.Fatalf("unused preparation executed work: %q %v", raw, err) - } - }) - } -} diff --git a/apps/daemon/internal/agent/mcode/unsupported.go b/apps/daemon/internal/agent/mcode/unsupported.go index a92be890d..e221bd595 100644 --- a/apps/daemon/internal/agent/mcode/unsupported.go +++ b/apps/daemon/internal/agent/mcode/unsupported.go @@ -34,15 +34,3 @@ func (s *Session) ListWorkspaceDirectory(context.Context, string, int) (agent.Wo func (s *Session) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported } - -func (s *prepared) ReadWorkspaceFile(context.Context, string, int) (agent.WorkspaceReadResult, error) { - return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *prepared) ListWorkspaceDirectory(context.Context, string, int) (agent.WorkspaceDirectoryResult, error) { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnsupported -} - -func (s *prepared) WriteWorkspaceFile(context.Context, string, []byte) (agent.WorkspaceWriteResult, error) { - return agent.WorkspaceWriteResult{}, agent.ErrWorkspaceWriteUnsupported -} diff --git a/apps/daemon/internal/agent/mcode/unsupported_test.go b/apps/daemon/internal/agent/mcode/unsupported_test.go index a059b11ef..eb0609bb6 100644 --- a/apps/daemon/internal/agent/mcode/unsupported_test.go +++ b/apps/daemon/internal/agent/mcode/unsupported_test.go @@ -27,21 +27,21 @@ func TestUnsupportedExtensionsHaveNoNativeEffects(t *testing.T) { } var turn *Session check(turn.SubmitFunctionResult(ctx, proto.FunctionResultPayload{CallID: secret, DeliveryID: secret})) - for _, owner := range []agent.WorkspaceReader{(*executor)(nil), (*Session)(nil), (*prepared)(nil)} { + for _, owner := range []agent.WorkspaceReader{(*executor)(nil), (*Session)(nil)} { result, err := owner.ReadWorkspaceFile(ctx, secret, 1) check(err) if len(result.Data) != 0 || result.Truncated { t.Fatal("unsupported read fabricated data") } } - for _, owner := range []agent.WorkspaceDirectoryLister{(*executor)(nil), (*Session)(nil), (*prepared)(nil)} { + for _, owner := range []agent.WorkspaceDirectoryLister{(*executor)(nil), (*Session)(nil)} { result, err := owner.ListWorkspaceDirectory(ctx, secret, 1) check(err) if len(result.Entries) != 0 || result.Truncated { t.Fatal("unsupported listing fabricated entries") } } - for _, owner := range []agent.WorkspaceWriter{(*executor)(nil), (*Session)(nil), (*prepared)(nil)} { + for _, owner := range []agent.WorkspaceWriter{(*executor)(nil), (*Session)(nil)} { result, err := owner.WriteWorkspaceFile(ctx, secret, []byte(secret)) check(err) if result.SizeBytes != 0 { diff --git a/apps/daemon/internal/agent/registry.go b/apps/daemon/internal/agent/registry.go index f6b02c0c9..c6998e918 100644 --- a/apps/daemon/internal/agent/registry.go +++ b/apps/daemon/internal/agent/registry.go @@ -13,7 +13,6 @@ import ( // factories for each agent_kind. Safe for concurrent use. type Registry struct { mu sync.RWMutex - preparers map[string]PreparationFactory executors map[string]ExecutorFactory kinds map[string]proto.SupportedAgentKind configurations map[string]harnessconfig.Configuration @@ -21,7 +20,6 @@ type Registry struct { func NewRegistry() *Registry { return &Registry{ - preparers: make(map[string]PreparationFactory), executors: make(map[string]ExecutorFactory), kinds: make(map[string]proto.SupportedAgentKind), configurations: make(map[string]harnessconfig.Configuration), @@ -58,13 +56,3 @@ func (r *Registry) ResolveExecutor(kind string) (ExecutorFactory, error) { } return factory, nil } - -func (r *Registry) ResolvePreparation(kind string) (PreparationFactory, error) { - r.mu.RLock() - defer r.mu.RUnlock() - f := r.preparers[kind] - if f == nil { - return nil, fmt.Errorf("agent: preparation unavailable for %q", kind) - } - return f, nil -} diff --git a/apps/daemon/internal/agent/registry_test.go b/apps/daemon/internal/agent/registry_test.go index 9720ab654..3d484d6dc 100644 --- a/apps/daemon/internal/agent/registry_test.go +++ b/apps/daemon/internal/agent/registry_test.go @@ -36,24 +36,16 @@ func TestRegistryRegisterPanicsOnEmptyKind(t *testing.T) { func TestRegistryRegisterRejectsFactoriesForUnavailableRuntime(t *testing.T) { info := proto.SupportedAgentKind{Kind: "k", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported})} executor := func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return nil, nil } - preparation := func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return nil, nil } - for name, runtime := range map[string]agent.Runtime{ - "executor": {Info: info, Executor: executor}, - "preparation": {Info: info, Preparation: preparation}, - } { - t.Run(name, func(t *testing.T) { - registry := agent.NewRegistry() - defer func() { - if recover() == nil { - t.Fatal("unavailable runtime registered factories") - } - if len(registry.SupportedAgentKinds()) != 0 { - t.Fatal("rejected runtime changed registry") - } - }() - registry.Register(agent.Declaration{Info: info}, runtime) - }) - } + registry := agent.NewRegistry() + defer func() { + if recover() == nil { + t.Fatal("unavailable runtime registered factories") + } + if len(registry.SupportedAgentKinds()) != 0 { + t.Fatal("rejected runtime changed registry") + } + }() + registry.Register(agent.Declaration{Info: info}, agent.Runtime{Info: info, Executor: executor}) } func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { diff --git a/apps/daemon/internal/cli/preparation_test.go b/apps/daemon/internal/cli/preparation_test.go deleted file mode 100644 index 9518ae1b1..000000000 --- a/apps/daemon/internal/cli/preparation_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package cli - -import ( - "context" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" - "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" -) - -func TestPreparationRegistrationFollowsNativeSupport(t *testing.T) { - for _, supported := range []bool{false, true} { - reg := agent.NewRegistry() - info := proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilityFromBool(supported)})} - runtime := agent.Runtime{Info: info} - if supported { - runtime.Preparation = func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return nil, nil } - runtime.WorkspaceReadPreparation = true - } - registerAgentKinds(reg, agentCLIDiscovery{{declaration: agent.Declaration{Info: info}, runtime: runtime}}) - - _, err := reg.ResolvePreparation("codex") - if (err == nil) != supported { - t.Fatal("unverified native version advertised preparation") - } - if !supported { - continue - } - for _, info := range reg.SupportedAgentKinds() { - if info.Kind == "codex" && (!info.Capabilities.Preparation.IsSupported() || !info.Capabilities.WorkspaceReadPreparation.IsSupported()) { - t.Fatal("heartbeat registry lost preparation") - } - } - reg.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}) - if _, err := reg.ResolvePreparation("codex"); err == nil { - t.Fatal("kind replacement retained stale preparation") - } - } -} diff --git a/apps/daemon/internal/dispatch/executor.go b/apps/daemon/internal/dispatch/executor.go index 38de76298..0e47e8c20 100644 --- a/apps/daemon/internal/dispatch/executor.go +++ b/apps/daemon/internal/dispatch/executor.go @@ -153,7 +153,7 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, p.status = proto.PreparationStatusPayload{Handle: uuid.NewString(), ExecutorID: owner.id, Revision: 1, State: state, Reused: reused, ExpiresAt: p.deadline.UnixMilli()} owner.admission = p r.preparations[p.status.Handle], r.preparationRequests[env.ID] = p, p - p.timer = time.AfterFunc(r.preparationTimeout, func() { r.releasePreparation(p, "expired", "", true, true) }) + p.timer = time.AfterFunc(r.preparationTimeout, func() { r.releasePreparation(p, "expired", "", true) }) if !reused { r.shutdownWG.Add(1) } diff --git a/apps/daemon/internal/dispatch/local_directory.go b/apps/daemon/internal/dispatch/local_directory.go deleted file mode 100644 index 02c1a4fa4..000000000 --- a/apps/daemon/internal/dispatch/local_directory.go +++ /dev/null @@ -1,20 +0,0 @@ -package dispatch - -import ( - "context" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -type localDirectoryPreparation struct{} - -func prepareLocalDirectory(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - return localDirectoryPreparation{}, nil -} - -func (localDirectoryPreparation) Start(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Session, error) { - return nil, agent.ErrWorkspaceReadUnsupported -} - -func (localDirectoryPreparation) Close() error { return nil } diff --git a/apps/daemon/internal/dispatch/local_directory_test.go b/apps/daemon/internal/dispatch/local_directory_test.go index 6c79a12cd..b7329c2f5 100644 --- a/apps/daemon/internal/dispatch/local_directory_test.go +++ b/apps/daemon/internal/dispatch/local_directory_test.go @@ -29,8 +29,8 @@ func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing } var harnessCalls atomic.Int32 reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported})}, harnessconfig.Configuration{}) - reg.RegisterPreparation("native", true, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported})}, harnessconfig.Configuration{}) + reg.RegisterExecutor("native", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { harnessCalls.Add(1) return nil, errors.New("must not prepare a harness") }) @@ -108,8 +108,8 @@ func TestLocalDirectoryKeepsNotDirectorySeparateFromFailures(t *testing.T) { t.Fatal(err) } reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported})}, harnessconfig.Configuration{}) - reg.RegisterPreparation("native", true, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + reg.RegisterKind(proto.SupportedAgentKind{Kind: "native", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported})}, harnessconfig.Configuration{}) + reg.RegisterExecutor("native", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return nil, errors.New("must not prepare a harness") }) sender := &recSender{} diff --git a/apps/daemon/internal/dispatch/preparation.go b/apps/daemon/internal/dispatch/preparation.go index e705953ee..3316c282f 100644 --- a/apps/daemon/internal/dispatch/preparation.go +++ b/apps/daemon/internal/dispatch/preparation.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" ) @@ -30,7 +29,6 @@ type preparationState struct { timer *time.Timer ctx context.Context cancel context.CancelFunc - prepared agent.Prepared environmentID string busy bool owns bool @@ -52,14 +50,14 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) if !available { return r.rejectPreparation(env, "resource_unavailable") } - prepare, err := r.registry.ResolvePreparation(req.AgentKind) - if err != nil || !caps.Preparation.IsSupported() { + if !caps.Preparation.IsSupported() { return r.rejectPreparation(env, "unsupported_preparation") } - if req.WorkspaceReadOnly && (!caps.WorkspaceReadPreparation.IsSupported() || !proto.ValidWorkspaceReadPreparation(req)) { + if !caps.WorkspaceReadPreparation.IsSupported() || !proto.ValidWorkspaceReadPreparation(req) { return r.rejectPreparation(env, "unsupported_read_preparation") } - if req, err = r.localWorkspace.Configure(req); err != nil { + req, err := r.localWorkspace.Configure(req) + if err != nil { return r.rejectPreparation(env, "invalid_configuration") } if req.RunID != "" || len(req.Input) != 0 || req.EnvironmentID() == "" || strings.TrimSpace(req.AgentStateKey) == "" { @@ -68,9 +66,6 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) if validateExecutionEnvironment(req, caps) != nil || (len(req.FunctionTools) > 0 && !caps.FunctionTools.IsSupported()) { return r.rejectPreparation(env, "unsupported_configuration") } - if req.LocalEnvironment != nil && req.WorkspaceReadOnly { - prepare = prepareLocalDirectory - } encoded, err := json.Marshal(req) if err != nil { return r.rejectPreparation(env, "invalid_configuration") @@ -81,7 +76,7 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) r.mu.Unlock() return ErrRouterClosed } - if r.runtimePreparation != nil || ((r.workspaceWrite != nil || r.workspaceExport != nil) && !req.WorkspaceReadOnly) { + if r.runtimePreparation != nil { r.mu.Unlock() return r.rejectPreparation(env, "resource_unavailable") } @@ -107,60 +102,44 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) return r.rejectPreparation(env, "preparation_capacity") } owner, cancel := context.WithCancel(context.WithoutCancel(ctx)) - p := &preparationState{capabilities: caps, requestID: env.ID, trace: env.Trace, fingerprint: fingerprint, ctx: owner, cancel: cancel, environmentID: req.EnvironmentID(), workspaceReadOnly: req.WorkspaceReadOnly, busy: true, owns: true, deadline: time.Now().Add(r.preparationTimeout)} + p := &preparationState{capabilities: caps, requestID: env.ID, trace: env.Trace, fingerprint: fingerprint, ctx: owner, cancel: cancel, environmentID: req.EnvironmentID(), workspaceReadOnly: true, busy: true, owns: true, deadline: time.Now().Add(r.preparationTimeout)} p.status = proto.PreparationStatusPayload{Handle: uuid.NewString(), Revision: 1, State: "preparing", ExpiresAt: p.deadline.UnixMilli()} r.preparations[p.status.Handle], r.preparationRequests[p.requestID] = p, p - p.timer = time.AfterFunc(r.preparationTimeout, func() { r.releasePreparation(p, "expired", "", true, true) }) + p.timer = time.AfterFunc(r.preparationTimeout, func() { r.releasePreparation(p, "expired", "", true) }) r.shutdownWG.Add(1) r.mu.Unlock() - go r.prepareExecution(p, req, prepare) + go r.prepareExecution(p) return nil } -func (r *Router) prepareExecution(p *preparationState, req proto.PromptRequestPayload, prepare agent.PreparationFactory) { +// prepareExecution readies a read-only preparation without starting a Harness. +func (r *Router) prepareExecution(p *preparationState) { defer r.shutdownWG.Done() if !r.sendPreparation(p.requestID, p.trace, proto.PreparationStatusPayload{Handle: p.status.Handle, Revision: 1, State: "preparing", ExpiresAt: p.deadline.UnixMilli()}) { - r.releasePreparation(p, "failed", "status_delivery_failed", false, false) - } - var prepared agent.Prepared - var err error - if p.ctx.Err() == nil { - prepared, err = prepare(p.ctx, req) - } else { - err = p.ctx.Err() - } - if err == nil && prepared != nil && !p.workspaceReadOnly { - if _, ok := prepared.(agent.PreparedCancellation); !ok { - err = errors.New("executable preparation requires cross-transfer cancellation") - } + r.releasePreparation(p, "failed", "status_delivery_failed", false) } r.mu.Lock() p.busy = false - p.prepared = prepared - ready := err == nil && prepared != nil && p.status.State == "preparing" && p.ctx.Err() == nil && !r.closed + ready := p.status.State == "preparing" && p.ctx.Err() == nil && !r.closed if ready { p.status.State, p.status.Revision = "ready", p.status.Revision+1 - } else if p.status.State == "preparing" { - p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "preparation_failed", p.status.Revision+1 - } - status := p.status - if !ready { - p.busy = true + } else { + if p.status.State == "preparing" { + p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "preparation_failed", p.status.Revision+1 + } + // A release or shutdown during readiness left ownership to this return. + p.owns = false p.cancel() p.timer.Stop() } + status := p.status r.mu.Unlock() if !ready { - r.closePreparationResource(p) - if p.workspaceReadOnly { - return - } - r.mu.Lock() - status = p.status - r.mu.Unlock() + r.publishPreparation(p, status) + return } - if !r.sendPreparation(p.requestID, p.trace, status) && ready { - r.releasePreparation(p, "failed", "status_delivery_failed", false, false) + if !r.sendPreparation(p.requestID, p.trace, status) { + r.releasePreparation(p, "failed", "status_delivery_failed", false) } } @@ -176,11 +155,11 @@ func (r *Router) handleExecutionRelease(_ context.Context, env proto.Envelope) e if !valid { return r.rejectPreparation(env, "unknown_preparation") } - r.releasePreparation(p, "released", "", true, true) + r.releasePreparation(p, "released", "", true) return nil } -func (r *Router) releasePreparation(p *preparationState, state, code string, publish, retryHandoff bool) { +func (r *Router) releasePreparation(p *preparationState, state, code string, publish bool) { r.mu.Lock() if r.closed || r.suspension != nil { r.mu.Unlock() @@ -191,48 +170,21 @@ func (r *Router) releasePreparation(p *preparationState, state, code string, pub r.abandonExecutorAdmission(p, state, code, publish) return } - if p.handoff != nil { - if active := r.sessions[p.status.RunID]; active != nil && active.preparedHandoff == p.handoff { - if state == "expired" && p.handoff.published { - r.mu.Unlock() - return - } - switch p.status.State { - case "preparing", "ready", "starting", "started": - p.status.State, p.status.ErrorCode, p.status.Revision = state, code, p.status.Revision+1 - p.timer.Stop() - } - r.claimPreparedReleaseLocked(active, true, "", retryHandoff) - status := p.status - r.mu.Unlock() - if publish { - r.publishPreparation(p, status) - } - return - } - } - closeResource := false switch p.status.State { - case "preparing", "ready", "starting": + case "preparing", "ready": p.status.State, p.status.ErrorCode, p.status.Revision = state, code, p.status.Revision+1 p.cancel() p.timer.Stop() } - if p.owns && !p.busy { - if p.workspaceReadOnly && state == "released" && p.status.ErrorCode == "cleanup_unconfirmed" { - p.status.State, p.status.ErrorCode, p.status.Revision = state, "", p.status.Revision+1 - } - p.busy = true - closeResource = true - r.shutdownWG.Add(1) + // A busy preparation drops ownership and reports when readiness returns. + // Dropping ownership here always reports it. + report := !p.busy && (publish || p.owns) + if !p.busy { + p.owns = false } status := p.status - settled := !p.owns && !p.busy r.mu.Unlock() - if closeResource { - go func() { defer r.shutdownWG.Done(); r.closePreparationResource(p) }() - } - if publish && (!p.workspaceReadOnly || settled) { + if report { r.publishPreparation(p, status) } } @@ -258,11 +210,9 @@ func (r *Router) prunePreparationsLocked() { func (r *Router) publishPreparation(p *preparationState, status proto.PreparationStatusPayload) { r.mu.Lock() - if p.workspaceReadOnly { - if status.Revision != p.status.Revision || (p.owns && (p.busy || status.State == "released" || status.State == "expired") && status.State != "preparing" && status.State != "ready") { - r.mu.Unlock() - return - } + if p.workspaceReadOnly && status.Revision != p.status.Revision { + r.mu.Unlock() + return } if r.closed || r.suspension != nil { r.mu.Unlock() @@ -274,7 +224,7 @@ func (r *Router) publishPreparation(p *preparationState, status proto.Preparatio defer r.shutdownWG.Done() // A failed terminal notification must not restart incomplete cleanup. if !r.sendPreparation(p.requestID, p.trace, status) && (!p.workspaceReadOnly || status.State == "preparing" || status.State == "ready") { - r.releasePreparation(p, "failed", "status_delivery_failed", false, false) + r.releasePreparation(p, "failed", "status_delivery_failed", false) } }() } diff --git a/apps/daemon/internal/dispatch/preparation_cancel_test.go b/apps/daemon/internal/dispatch/preparation_cancel_test.go index 0a5f96c96..5804650cb 100644 --- a/apps/daemon/internal/dispatch/preparation_cancel_test.go +++ b/apps/daemon/internal/dispatch/preparation_cancel_test.go @@ -28,20 +28,6 @@ func (p *cancellationPreparation) Cancel(ctx context.Context) error { func (p *cancellationPreparation) CancellationOutcome() proto.DonePayload { return p.outcome } -type nonCancellablePreparation struct { - closed chan struct{} - once sync.Once -} - -func (*nonCancellablePreparation) Start(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Session, error) { - return nil, errors.New("must not start") -} - -func (p *nonCancellablePreparation) Close() error { - p.once.Do(func() { close(p.closed) }) - return nil -} - func startCancellationPreparation(t *testing.T, r *dispatch.Router, sender *recSender) proto.PreparationStatusPayload { t.Helper() if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())); err != nil { @@ -114,7 +100,7 @@ func TestPreparedCancellationWaitsForOutputAndCleanup(t *testing.T) { <-cleanupReturn return nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender.recSender) <-startEntered if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})); err != nil { @@ -174,7 +160,7 @@ func TestPreparedCancellationBeforeTransferPreservesUnknownOutcome(t *testing.T) if boundary == "expiry" { timeout = 100 * time.Millisecond } - r := preparationRouter(t, sender, timeout, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, timeout, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) ready := startCancellationPreparation(t, r, sender) <-entered _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})) @@ -231,7 +217,7 @@ func TestPreparedCancellationFailuresRemainConservative(t *testing.T) { } return session.Cancel(ctx) } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender.recSender) <-entered _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})) @@ -264,27 +250,6 @@ func TestPreparedCancellationFailuresRemainConservative(t *testing.T) { } } -func TestExecutablePreparationRequiresCrossTransferCancellation(t *testing.T) { - sender := &recSender{} - p := &nonCancellablePreparation{closed: make(chan struct{})} - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())); err != nil { - t.Fatal(err) - } - waitPreparationStatus(t, sender, "request", "failed", "") - select { - case <-p.closed: - case <-time.After(time.Second): - t.Fatal("unsupported executable preparation was not closed") - } - for _, frame := range sender.snapshot() { - var status proto.PreparationStatusPayload - if frame.Type == proto.TypePreparationStatus && frame.DecodePayload(&status) == nil && status.State == "ready" { - t.Fatal("unsupported executable preparation became ready") - } - } -} - func TestPreparedCancellationTimeoutKeepsCapacityUntilStartReturns(t *testing.T) { sender := &recSender{} entered, allowReturn := make(chan struct{}), make(chan struct{}) @@ -296,7 +261,7 @@ func TestPreparedCancellationTimeoutKeepsCapacityUntilStartReturns(t *testing.T) return nil, ctx.Err() } var count atomic.Int32 - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { if count.Add(1) == 1 { return p, nil } diff --git a/apps/daemon/internal/dispatch/preparation_cleanup.go b/apps/daemon/internal/dispatch/preparation_cleanup.go index 2a51c0e8c..97b77dbc6 100644 --- a/apps/daemon/internal/dispatch/preparation_cleanup.go +++ b/apps/daemon/internal/dispatch/preparation_cleanup.go @@ -1,35 +1,6 @@ package dispatch -func (r *Router) closePreparationResource(p *preparationState) error { - // Only the operation that owns busy calls this; other paths cancel its owner. - var err error - if p.prepared != nil { - err = p.prepared.Close() - } - r.mu.Lock() - p.busy, p.closeErr = false, err - if err == nil { - p.prepared, p.owns = nil, false - } - if p.workspaceReadOnly { - p.status.Revision++ - if err != nil { - p.status.State, p.status.ErrorCode = "failed", "cleanup_unconfirmed" - } - } - status := p.status - r.mu.Unlock() - if p.workspaceReadOnly { - r.publishPreparation(p, status) - } - if err != nil { - r.log.Warn("preparation cleanup incomplete", "handle", p.status.Handle) - } - return err -} - -func (r *Router) closePendingPreparationsLocked() []*preparationState { - var closeNow []*preparationState +func (r *Router) closePendingPreparationsLocked() { for _, p := range r.preparations { p.timer.Stop() if p.executor != nil { @@ -38,19 +9,14 @@ func (r *Router) closePendingPreparationsLocked() []*preparationState { if !p.owns { continue } - if p.handoff != nil { - continue - } p.cancel() switch p.status.State { - case "preparing", "ready", "starting": + case "preparing", "ready": p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "connection_closed", p.status.Revision+1 } + // A busy preparation drops ownership when readiness returns. if !p.busy { - p.busy = true - r.shutdownWG.Add(1) - closeNow = append(closeNow, p) + p.owns = false } } - return closeNow } diff --git a/apps/daemon/internal/dispatch/preparation_cleanup_test.go b/apps/daemon/internal/dispatch/preparation_cleanup_test.go index 20a45212b..b88d21b73 100644 --- a/apps/daemon/internal/dispatch/preparation_cleanup_test.go +++ b/apps/daemon/internal/dispatch/preparation_cleanup_test.go @@ -43,7 +43,7 @@ func TestPreparedCancellationDoesNotAcknowledgeFailedCleanup(t *testing.T) { return nil, context.Canceled } sender := &recSender{} - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) ready := startCancellationPreparation(t, r, sender) <-entered _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})) @@ -74,7 +74,7 @@ func TestShutdownRetriesFailedPreparedCancellationOnSameTarget(t *testing.T) { } return session.Cancel(ctx) } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender) waitPreparationStatus(t, sender, "request", "started", "") if err := r.Shutdown(t.Context()); !errors.Is(err, want) { @@ -105,7 +105,7 @@ func TestShutdownRetriesFailedPreparationCleanup(t *testing.T) { return nil }} sender := &recSender{} - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) waitPreparationStatus(t, sender, "request", "ready", "") if err := r.Shutdown(t.Context()); !errors.Is(err, want) { @@ -126,7 +126,7 @@ func TestShutdownTimeoutAndConcurrentRetryWaitForCleanup(t *testing.T) { unblock := func() { once.Do(func() { close(release) }) } p := &retryablePreparation{close: func(int32) error { close(entered); <-release; return nil }} sender := &recSender{} - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) t.Cleanup(unblock) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) waitPreparationStatus(t, sender, "request", "ready", "") @@ -176,7 +176,7 @@ func TestPublishedPreparedRunRetainsRetryAfterHandleRetirement(t *testing.T) { return session.Cancel(ctx) } var factories atomic.Int32 - r := preparationRouter(t, sender, 200*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, sender, 200*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { if factories.Add(1) == 1 { return p, nil } diff --git a/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go b/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go index 9da6a2d2e..0ab84ce26 100644 --- a/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go +++ b/apps/daemon/internal/dispatch/preparation_executor_fixture_test.go @@ -2,30 +2,39 @@ package dispatch_test import ( "context" - "errors" "sync" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) +// preparedFixture is a disposable fault-injection resource; Start transfers it +// to one Session. A cancellablePreparation follows that Session across Start. +type preparedFixture interface { + Start(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Session, error) + Close() error +} + +type cancellablePreparation interface { + preparedFixture + agent.Session +} + +type preparationFactory func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) + // Old fault-injection fixtures model disposable executors, not reusable native implementations. -func preparationExecutorFixture(factory agent.PreparationFactory) agent.ExecutorFactory { +func preparationExecutorFixture(factory preparationFactory) agent.ExecutorFactory { return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { prepared, err := factory(ctx, req) if prepared == nil { return nil, err } - owner := &preparationExecutor{prepared: prepared} - if _, ok := prepared.(agent.PreparedCancellation); !ok { - return owner, errors.New("fixture has no cancellation target") - } - return owner, err + return &preparationExecutor{prepared: prepared}, err } } type preparationExecutor struct { - prepared agent.Prepared + prepared preparedFixture mu sync.Mutex cancelled bool } @@ -39,7 +48,7 @@ func (e *preparationExecutor) Close(ctx context.Context) error { var err error if resource, ok := e.prepared.(*retryablePreparation); ok { err = resource.Close() - } else if cancel, ok := e.prepared.(agent.PreparedCancellation); ok { + } else if cancel, ok := e.prepared.(cancellablePreparation); ok { err = cancel.Cancel(ctx) } else { err = e.prepared.Close() @@ -79,7 +88,7 @@ type preparationTurn struct { func (t *preparationTurn) Cancel(ctx context.Context) error { return t.owner.Close(ctx) } func (t *preparationTurn) CancellationOutcome() proto.DonePayload { - if target, ok := t.owner.prepared.(agent.PreparedCancellation); ok { + if target, ok := t.owner.prepared.(cancellablePreparation); ok { return target.CancellationOutcome() } return proto.DonePayload{} diff --git a/apps/daemon/internal/dispatch/preparation_start.go b/apps/daemon/internal/dispatch/preparation_start.go index 5b96f6369..8b0818b4c 100644 --- a/apps/daemon/internal/dispatch/preparation_start.go +++ b/apps/daemon/internal/dispatch/preparation_start.go @@ -61,7 +61,7 @@ func (r *Router) handleExecutionStart(_ context.Context, env proto.Envelope) err } if !time.Now().Before(p.deadline) { r.mu.Unlock() - r.releasePreparation(p, "expired", "", true, true) + r.releasePreparation(p, "expired", "", true) return nil } if r.sessions[input.RunID] != nil { diff --git a/apps/daemon/internal/dispatch/preparation_test.go b/apps/daemon/internal/dispatch/preparation_test.go index 427470709..eca2ea195 100644 --- a/apps/daemon/internal/dispatch/preparation_test.go +++ b/apps/daemon/internal/dispatch/preparation_test.go @@ -116,11 +116,10 @@ func preparationRequest() proto.ExecutionPreparePayload { return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: "agents-api-" + preparationSessionID, LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, NetworkAccess: "enabled", WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}}} } -func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Duration, factory agent.PreparationFactory) *dispatch.Router { +func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Duration, factory preparationFactory) *dispatch.Router { t.Helper() reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}) - reg.RegisterPreparation("prepared", true, factory) + reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilitySupported, WorkspaceReadPreparation: proto.CapabilitySupported, FunctionTools: proto.CapabilitySupported, Steering: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, harnessconfig.Configuration{}) reg.RegisterExecutor("prepared", preparationExecutorFixture(factory)) r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, PreparationTimeout: timeout, LocalWorkspace: preparationWorkspace(t)}) if err != nil { @@ -165,7 +164,7 @@ func TestPreparationReleaseDuringBlockedFactory(t *testing.T) { sender := &recSender{} p := &controlledPreparation{closed: make(chan struct{})} entered, allowReturn := make(chan context.Context, 1), make(chan struct{}) - r := preparationRouter(t, sender, time.Minute, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, sender, time.Minute, func(ctx context.Context, req proto.PromptRequestPayload) (preparedFixture, error) { if req.RunID != "" || len(req.Input) != 0 { t.Error("run input reached preparation") } @@ -217,7 +216,7 @@ func TestPreparationSingleTransferAndReleaseDoesNotCancelRun(t *testing.T) { gotSession <- s return s, nil } - r := preparationRouter(t, sender, 80*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, 80*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) prepare := mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest()) if err := r.Handle(t.Context(), prepare); err != nil { t.Fatal(err) @@ -274,7 +273,7 @@ func TestPreparationCancelDuringStartClosesLateSession(t *testing.T) { close(cancelEntered) return (<-lateSession).Cancel(ctx) } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) ready := waitPreparationStatus(t, sender, "request", "ready", "") _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "real-run", Input: proto.TextInput("input")})) @@ -311,7 +310,7 @@ func TestPreparationCancelDuringStartClosesLateSession(t *testing.T) { func TestPreparationExpiryAndOldHandleCannotStartReplacement(t *testing.T) { sender := &recSender{} created := make(chan *controlledPreparation, 2) - r := preparationRouter(t, sender, 60*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, sender, 60*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { p := &controlledPreparation{closed: make(chan struct{})} created <- p return p, nil @@ -349,7 +348,7 @@ func (s failReadySender) Send(ctx context.Context, env proto.Envelope) error { func TestPreparationFailedReadyDeliveryAbandonsAdmission(t *testing.T) { created := make(chan struct{}) p := &controlledPreparation{closed: make(chan struct{})} - r := preparationRouter(t, failReadySender{&recSender{}}, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, failReadySender{&recSender{}}, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { close(created) return p, nil }) @@ -375,7 +374,7 @@ func TestPreparationRejectsInputAndProductConfiguration(t *testing.T) { "missing environment": func(p *proto.PromptRequestPayload) { p.LocalEnvironment = nil }, } { t.Run(name, func(t *testing.T) { - r := preparationRouter(t, &recSender{}, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, &recSender{}, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { t.Error("invalid preparation reached native factory") return nil, errors.New("invalid") }) diff --git a/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go b/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go index b00b647e9..9e86b0168 100644 --- a/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go +++ b/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go @@ -89,7 +89,7 @@ func TestPreparedHandoffReleaseWaitsForMutationReceipt(t *testing.T) { session.out = out return session, nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender.recSender) waitPreparationStatus(t, sender.recSender, "request", "started", "") @@ -203,7 +203,7 @@ func TestPreparedHandoffRouterShutdownWaitsForReceiptAttempt(t *testing.T) { session.out = out return session, nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender.recSender) waitPreparationStatus(t, sender.recSender, "request", "started", "") @@ -253,7 +253,7 @@ func TestPreparedHandoffEarlyDonePublishesAfterStarted(t *testing.T) { return nil, ctx.Err() } } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender) <-emitted if hasFrame(sender, proto.TypeDone, "run") || session.cancels() != 0 { diff --git a/apps/daemon/internal/dispatch/prepared_handoff_test.go b/apps/daemon/internal/dispatch/prepared_handoff_test.go index 37c77c149..f366b649f 100644 --- a/apps/daemon/internal/dispatch/prepared_handoff_test.go +++ b/apps/daemon/internal/dispatch/prepared_handoff_test.go @@ -36,7 +36,7 @@ func TestPreparedHandoffDrainsBurstBeforeStartReturns(t *testing.T) { return nil, ctx.Err() } } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender) select { case <-sent: @@ -118,7 +118,7 @@ func TestPreparedHandoffAbortBeforeStartAdmissionSkipsNativeStart(t *testing.T) return nil, errors.New("unexpected Start") } p.cancel = func(context.Context) error { return p.Close() } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) ready := startCancellationPreparation(t, r, sender.recSender) select { case <-sender.entered: @@ -163,7 +163,7 @@ func TestPreparedHandoffDuplicateStartDoesNotReexecuteDuringPublication(t *testi session.out = out return session, nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) ready := startCancellationPreparation(t, r, sender.recSender) select { case <-sender.entered: @@ -214,7 +214,7 @@ func TestPreparedHandoffUnsupportedFunctionReleasesOperationBarrier(t *testing.T session.out = out return session, nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender) waitPreparationStatus(t, sender, "request", "started", "") result := mustEnv(t, proto.TypeFunctionResult, "run", proto.FunctionResultPayload{CallID: "unsupported", Success: true, Content: functionResultContent("answer"), DeliveryID: "unsupported"}) @@ -241,7 +241,7 @@ func TestPreparedHandoffEarlyDoneStillAllowsExplicitAbort(t *testing.T) { return nil, context.Canceled } p.cancel = func(context.Context) error { unblock(); return nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) defer unblock() startCancellationPreparation(t, r, sender) waitFor(t, func() bool { return r.SteeringClosedForTest("run") }, "early Done release claim") @@ -265,7 +265,7 @@ func TestPreparedHandoffExpiresDuringStartedPublication(t *testing.T) { session.out = out return session, nil } - r := preparationRouter(t, sender, 100*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, 100*time.Millisecond, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) startCancellationPreparation(t, r, sender.recSender) <-sender.entered time.Sleep(250 * time.Millisecond) @@ -305,7 +305,7 @@ func TestPreparedHandoffEarlyDoneDetachesPublishedPreparation(t *testing.T) { } return session.Cancel(ctx) } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) ready := startCancellationPreparation(t, r, sender) waitFor(t, func() bool { return r.SteeringClosedForTest("run") }, "early Done claim") unblock() diff --git a/apps/daemon/internal/dispatch/router_test.go b/apps/daemon/internal/dispatch/router_test.go index ed2d90368..607e95535 100644 --- a/apps/daemon/internal/dispatch/router_test.go +++ b/apps/daemon/internal/dispatch/router_test.go @@ -127,7 +127,7 @@ func newHarness(t *testing.T) *harness { // sessionExecutor runs each Turn through start on a disposable Executor. func sessionExecutor(start func(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Session, error)) agent.ExecutorFactory { - return preparationExecutorFixture(func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + return preparationExecutorFixture(func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return &controlledPreparation{start: start}, nil }) } diff --git a/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go b/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go index 1b9954af5..f6bb85f2a 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go +++ b/apps/daemon/internal/dispatch/runtime_preparation_execution_test.go @@ -9,7 +9,6 @@ import ( "testing" "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -30,7 +29,7 @@ func TestRuntimePreparationUnavailablePreventsNativeExecutor(t *testing.T) { } sender := &recSender{} var calls atomic.Int32 - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { calls.Add(1) return nil, errors.New("native factory must not be reached") }) diff --git a/apps/daemon/internal/dispatch/shutdown.go b/apps/daemon/internal/dispatch/shutdown.go index 10ec3eb69..b09de4c70 100644 --- a/apps/daemon/internal/dispatch/shutdown.go +++ b/apps/daemon/internal/dispatch/shutdown.go @@ -37,7 +37,7 @@ func (r *Router) Shutdown(ctx context.Context) error { // Prepared release claims exist before this signal can interrupt output. close(r.shutdownCh) } - preparations := r.closePendingPreparationsLocked() + r.closePendingPreparationsLocked() executors := r.closeIdleExecutorsLocked() attempt := &shutdownAttempt{done: make(chan struct{})} r.shutdownAttempt = attempt @@ -46,9 +46,6 @@ func (r *Router) Shutdown(ctx context.Context) error { r.mu.Unlock() r.closeIdleExecutors(executors) - for _, p := range preparations { - go func() { defer r.shutdownWG.Done(); r.closePreparationResource(p) }() - } go r.runShutdownAttempt(attempt, victims) return waitShutdown(ctx, attempt) } diff --git a/apps/daemon/internal/dispatch/workspace_directory_test.go b/apps/daemon/internal/dispatch/workspace_directory_test.go index 89de922ed..1c584484f 100644 --- a/apps/daemon/internal/dispatch/workspace_directory_test.go +++ b/apps/daemon/internal/dispatch/workspace_directory_test.go @@ -18,7 +18,7 @@ func TestWorkspaceDirectoryRetainsEnvironmentAndTransferredOwner(t *testing.T) { p.start = func(ctx context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { return &fakeSession{out: out, ctx: ctx, closeOutOnCancel: true}, nil } - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { return p, nil }) for _, name := range []string{"file", "second"} { if err := os.WriteFile(filepath.Join(os.Getenv("OAC_RUNTIME_WORKSPACE"), name), []byte("abc"), 0600); err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/dispatch/workspace_preparation_failure_test.go b/apps/daemon/internal/dispatch/workspace_preparation_failure_test.go deleted file mode 100644 index fa48438e5..000000000 --- a/apps/daemon/internal/dispatch/workspace_preparation_failure_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package dispatch - -import ( - "context" - "errors" - "io" - "log/slog" - "sync" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -type workspaceFailureBoundary struct { - slog.Handler - once sync.Once - entered, resume chan struct{} -} - -func (h *workspaceFailureBoundary) Handle(ctx context.Context, record slog.Record) error { - h.once.Do(func() { close(h.entered); <-h.resume }) - return h.Handler.Handle(ctx, record) -} - -type workspaceCloseFunc struct { - agent.Prepared - close func() error -} - -func (p workspaceCloseFunc) Close() error { return p.close() } - -func TestReadConstructorFailureCannotPublishReleaseDuringCleanupRetry(t *testing.T) { - boundary := &workspaceFailureBoundary{Handler: slog.NewTextHandler(io.Discard, nil), entered: make(chan struct{}), resume: make(chan struct{})} - sender := make(workspaceStatusSender, 16) - r := &Router{sender: sender, shutdownCh: make(chan struct{}), log: slog.New(boundary)} - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - timer := time.NewTimer(time.Hour) - defer timer.Stop() - retryEntered, retryResume := make(chan struct{}), make(chan struct{}) - var resumeOnce sync.Once - defer resumeOnce.Do(func() { close(retryResume) }) - calls := 0 - resource := workspaceCloseFunc{close: func() error { - calls++ - if calls == 2 { - close(retryEntered) - <-retryResume - } - return errors.New("cleanup incomplete") - }} - p := &preparationState{workspaceReadOnly: true, owns: true, busy: true, - ctx: ctx, cancel: cancel, timer: timer, deadline: time.Now().Add(time.Hour), - status: proto.PreparationStatusPayload{Handle: "reader", Revision: 1, State: "preparing"}} - r.shutdownWG.Add(1) - prepared := make(chan struct{}) - go func() { - r.prepareExecution(p, proto.PromptRequestPayload{}, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - return resource, errors.New("construction failed") - }) - close(prepared) - }() - <-boundary.entered - r.releasePreparation(p, "released", "", true, true) - <-retryEntered - close(boundary.resume) - <-prepared - for len(sender) > 0 { - var status proto.PreparationStatusPayload - if (<-sender).DecodePayload(&status) == nil && status.State == "released" { - t.Error("constructor published release while retry cleanup was blocked") - } - } - resumeOnce.Do(func() { close(retryResume) }) - r.shutdownWG.Wait() - if !p.owns || p.busy || p.status.ErrorCode != "cleanup_unconfirmed" || calls != 2 { - t.Fatal("failed retry did not retain cleanup ownership") - } -} diff --git a/apps/daemon/internal/dispatch/workspace_preparation_status_test.go b/apps/daemon/internal/dispatch/workspace_preparation_status_test.go index ab8b4b98e..3128a407b 100644 --- a/apps/daemon/internal/dispatch/workspace_preparation_status_test.go +++ b/apps/daemon/internal/dispatch/workspace_preparation_status_test.go @@ -2,15 +2,10 @@ package dispatch import ( "context" - "errors" - "sync" - "sync/atomic" "testing" "time" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) type workspaceStatusSender chan proto.Envelope @@ -20,117 +15,23 @@ func (s workspaceStatusSender) Send(_ context.Context, envelope proto.Envelope) return nil } -type offlineWorkspaceStatusSender struct{} - -func (offlineWorkspaceStatusSender) Send(context.Context, proto.Envelope) error { - return errors.New("observer disconnected") -} - -type unsettledWorkspacePreparation struct { - agent.Prepared - calls atomic.Int32 - settled atomic.Bool -} - -func (p *unsettledWorkspacePreparation) Close() error { - // Bound a regression so a recursive retry cannot hang the test. - if p.calls.Add(1) >= 100 || p.settled.Load() { - return nil - } - return errors.New("cleanup incomplete") -} - -func TestReadPreparationOfflineStatusDoesNotRetryCleanup(t *testing.T) { - prepared := &unsettledWorkspacePreparation{} +func TestReadPreparationRetryCannotPublishStaleStatus(t *testing.T) { + sender := make(workspaceStatusSender, 4) ctx, cancel := context.WithCancel(context.Background()) defer cancel() timer := time.NewTimer(time.Hour) defer timer.Stop() - r := &Router{sender: offlineWorkspaceStatusSender{}, shutdownCh: make(chan struct{}), log: obslog.Bg()} - p := &preparationState{workspaceReadOnly: true, owns: true, prepared: prepared, - ctx: ctx, cancel: cancel, timer: timer, - status: proto.PreparationStatusPayload{Handle: "reader", Revision: 1, State: "ready"}} - for attempt := int32(1); attempt <= 2; attempt++ { - r.releasePreparation(p, "released", "", true, true) - r.shutdownWG.Wait() - if got := prepared.calls.Load(); got != attempt { - t.Fatalf("explicit release %d caused %d cleanup attempts", attempt, got) - } - if !p.owns || p.busy || p.prepared != prepared || p.status.ErrorCode != "cleanup_unconfirmed" { - t.Fatal("unconfirmed cleanup lost its resource ownership") - } - } - prepared.settled.Store(true) - r.releasePreparation(p, "released", "", true, true) - r.shutdownWG.Wait() - if prepared.calls.Load() != 3 || p.owns || p.prepared != nil || p.status.State != "released" { - t.Fatal("explicit retry did not settle resource ownership") - } -} - -func TestReadPreparationRetryCannotPublishStaleRelease(t *testing.T) { - sender := make(workspaceStatusSender, 4) r := &Router{sender: sender, shutdownCh: make(chan struct{})} - p := &preparationState{workspaceReadOnly: true, owns: true, busy: true, - status: proto.PreparationStatusPayload{Handle: "reader", Revision: 2, State: "released"}} - // A prepare retry captures this snapshot while Close is still running. + p := &preparationState{workspaceReadOnly: true, owns: true, ctx: ctx, cancel: cancel, timer: timer, + status: proto.PreparationStatusPayload{Handle: "reader", Revision: 2, State: "ready"}} + // A prepare retry captures this snapshot before the release settles. snapshot := p.status - // Close fails before the retry reaches publication. - p.busy = false - p.status = proto.PreparationStatusPayload{Handle: "reader", Revision: 3, State: "failed", ErrorCode: "cleanup_unconfirmed"} - r.publishPreparation(p, snapshot) - r.shutdownWG.Wait() - if len(sender) != 0 { - t.Fatal("stale release success escaped after failed Close") - } - r.publishPreparation(p, p.status) + r.releasePreparation(p, "released", "", true) r.shutdownWG.Wait() - var failed proto.PreparationStatusPayload - if len(sender) != 1 || (<-sender).DecodePayload(&failed) != nil || failed.State != "failed" { - t.Fatal("confirmed cleanup failure was suppressed") - } -} - -// Local read-only preparation uses no native factory. Keep the shared close -// settlement regression at its owner boundary instead of a retired remote fixture. -type blockingWorkspacePreparation struct { - agent.Prepared - entered, release chan struct{} -} - -func (p *blockingWorkspacePreparation) Close() error { - close(p.entered) - <-p.release - return nil -} - -func TestReadPreparationReleaseWaitsForClose(t *testing.T) { - sender := make(workspaceStatusSender, 4) - prepared := &blockingWorkspacePreparation{entered: make(chan struct{}), release: make(chan struct{})} - var release sync.Once - defer release.Do(func() { close(prepared.release) }) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - timer := time.NewTimer(time.Hour) - defer timer.Stop() - r := &Router{sender: sender, shutdownCh: make(chan struct{}), log: obslog.Bg()} - p := &preparationState{workspaceReadOnly: true, owns: true, prepared: prepared, - ctx: ctx, cancel: cancel, timer: timer, - status: proto.PreparationStatusPayload{Handle: "reader", Revision: 1, State: "ready"}} - r.releasePreparation(p, "released", "", true, true) - select { - case <-prepared.entered: - case <-time.After(time.Second): - t.Fatal("close did not start") - } - r.publishPreparation(p, p.status) - if len(sender) != 0 || !p.owns { - t.Fatal("release acknowledged before close settled") - } - release.Do(func() { close(prepared.release) }) + r.publishPreparation(p, snapshot) r.shutdownWG.Wait() - var status proto.PreparationStatusPayload - if p.owns || len(sender) != 1 || (<-sender).DecodePayload(&status) != nil || status.State != "released" { - t.Fatal("settled close did not release ownership and publish status") + var released proto.PreparationStatusPayload + if p.owns || len(sender) != 1 || (<-sender).DecodePayload(&released) != nil || released.State != "released" { + t.Fatal("stale ready snapshot escaped or settled release was not published") } } diff --git a/apps/daemon/internal/dispatch/workspace_read.go b/apps/daemon/internal/dispatch/workspace_read.go index 4fd1111a5..7511a84c5 100644 --- a/apps/daemon/internal/dispatch/workspace_read.go +++ b/apps/daemon/internal/dispatch/workspace_read.go @@ -67,7 +67,6 @@ func (r *Router) workspaceResourceLocked(request proto.WorkspaceReadPayload) (an !p.owns || p.busy || p.ctx.Err() != nil || !time.Now().Before(p.deadline) { return nil, "resource_unavailable" } - resource = p.prepared if p.executor != nil { resource = p.executor.native } diff --git a/apps/daemon/internal/localworkspace/runtime_initialization.go b/apps/daemon/internal/localworkspace/runtime_initialization.go index 1c8164fef..2db03a780 100644 --- a/apps/daemon/internal/localworkspace/runtime_initialization.go +++ b/apps/daemon/internal/localworkspace/runtime_initialization.go @@ -36,9 +36,6 @@ func (b *Binding) initializeRuntime(ctx context.Context, input proto.RuntimeInit if input.Action != "setup" && input.Action != "npm" && input.Action != "python" { return agentcapabilities.ErrInvalid } - if input.Network != "enabled" && input.Network != "disabled" { - return agentcapabilities.ErrInvalid - } directory, err := b.initializationCWD(input.CWD) if err != nil { return err diff --git a/apps/daemon/internal/localworkspace/runtime_initialization_test.go b/apps/daemon/internal/localworkspace/runtime_initialization_test.go index 19ed6e18d..e16e46787 100644 --- a/apps/daemon/internal/localworkspace/runtime_initialization_test.go +++ b/apps/daemon/internal/localworkspace/runtime_initialization_test.go @@ -74,7 +74,7 @@ func TestRuntimeInitializationPackageTargets(t *testing.T) { } packages, _ := PackageDirectory() for _, action := range []string{"npm", "python"} { - if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: action, Network: "enabled", Packages: []string{"name with spaces", "second"}}); err != nil { + if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: action, Packages: []string{"name with spaces", "second"}}); err != nil { t.Fatal(action, err) } raw, err := os.ReadFile(receipt) @@ -242,7 +242,7 @@ func TestRuntimeInitializationSetupUsesBashAndPhysicalWorkspace(t *testing.T) { if err := os.WriteFile(filepath.Join(b.workspace, "proof.sh"), []byte("printf skill-proof"), 0600); err != nil { t.Fatal(err) } - err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "setup", Network: "enabled", CWD: "/workspace/sub", Command: `. ../proof.sh > proof; printf '%s' "$VALUE" > value`}) + err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "setup", CWD: "/workspace/sub", Command: `. ../proof.sh > proof; printf '%s' "$VALUE" > value`}) if err != nil { t.Fatal(err) } @@ -261,13 +261,13 @@ func TestRuntimeInitializationMissingDependenciesAndInvalidRequests(t *testing.T if err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure"}); err != nil { t.Fatal(err) } - for _, input := range []proto.RuntimeInitialization{{Action: "setup", Network: "enabled", Command: "true"}, {Action: "npm", Network: "enabled", Packages: []string{"valid"}}, {Action: "python", Network: "enabled", Packages: []string{"valid"}}} { + for _, input := range []proto.RuntimeInitialization{{Action: "setup", Command: "true"}, {Action: "npm", Packages: []string{"valid"}}, {Action: "python", Packages: []string{"valid"}}} { var failed *InitializationFailure if err := b.initializeRuntime(t.Context(), input); !errors.As(err, &failed) || !strings.Contains(err.Error(), "requires") { t.Fatal("missing dependency was not explicit", input.Action, err) } } - for _, input := range []proto.RuntimeInitialization{{Action: "system"}, {Action: "setup", Network: "enabled", Command: "true", CWD: "/workspace/../outside"}, {Action: "npm", Network: "enabled", Packages: []string{"--unsafe"}}} { + for _, input := range []proto.RuntimeInitialization{{Action: "system"}, {Action: "setup", Command: "true", CWD: "/workspace/../outside"}, {Action: "npm", Packages: []string{"--unsafe"}}} { if !errors.Is(b.initializeRuntime(t.Context(), input), agentcapabilities.ErrInvalid) { t.Fatal("invalid request accepted", input.Action) } diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index 52a68f354..fe4440b5c 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -69,8 +69,7 @@ For example, the Codex adapter keeps its app-server and thread, the Claude adapt | `DurableSteerer` | Real implementation on every Turn | Distinguish a complete write from the native application receipt; keep retry identity | | `Steerer` | Explicit implementation or Unsupported | Additional non-durable active-Turn input | | `FunctionResultSubmitter` | Explicit implementation or Unsupported | Match native call and result identity and acknowledge application | -| `WorkspaceReader`, `WorkspaceDirectoryLister`, `WorkspaceWriter` | Explicit on Turn, Executor and Prepared owners | Use the authorized workspace, confirm access, commit or close, or return the operation's Unsupported error | -| `Prepared`, `PreparedCancellation` | Real implementation for an executable preparation | Keep resource and output ownership across Start, cancellation and unused cleanup | +| `WorkspaceReader`, `WorkspaceDirectoryLister`, `WorkspaceWriter` | Explicit on Turn and Executor owners | Use the authorized workspace, confirm access, commit or close, or return the operation's Unsupported error | | Neutral messages, images, MCP, structured output and Subagent observations | Explicit capability decisions | Keep each operation's protocol semantics; reject unsupported input before submission | Each adapter's `contracts.go` holds an individual compile-time assertion for each small interface. Do not embed a default implementation that makes future interfaces appear implemented. Adding a contract also requires a classification in the common completeness check and an explicit assertion in every public adapter; the check follows the authored Harness catalog. @@ -115,8 +114,8 @@ A Session owns one reusable Executor in its connected Runtime; a Turn owns one i - An error means settlement is unconfirmed and frees neither ownership nor capacity. Caller deadlines stop the wait, not the tracked cleanup. Retry the same cleanup target serially; a failed cleanup blocks replacement and keeps its resource slot. - `Executor.Close` confirms resource retirement independently of the Turn outcome: an immutable Turn error must not prevent closing the native transport once its work and output have stopped. - Include owned background work in settlement and keep the exact native cleanup target after a failure. Native termination belongs to the adapter; a bulk cleanup acknowledgement alone does not establish quiescence. -- Every `Session` declares `CancellationOutcome`. `Turn` and `PreparedCancellation` inherit it. The snapshot keeps observed native identity, Usage and output and remains readable after cancellation. Missing evidence stays unset; an empty `DonePayload` means nothing has been observed, not that cancellation succeeded or is unsupported. Reading the snapshot does not wait for settlement. -- `Session.Cancel` requests cancellation; output closure signals teardown. Executable `PreparedCancellation.Cancel` waits for local cleanup and output writes to stop. Turn settlement still requires `AwaitSettlement` and any required `Executor.Close`; neither a successful cancellation request nor its snapshot replaces those waits. +- Every `Session` declares `CancellationOutcome`. `Turn` inherits it. The snapshot keeps observed native identity, Usage and output and remains readable after cancellation. Missing evidence stays unset; an empty `DonePayload` means nothing has been observed, not that cancellation succeeded or is unsupported. Reading the snapshot does not wait for settlement. +- `Session.Cancel` requests cancellation; output closure signals teardown. Turn settlement still requires `AwaitSettlement` and any required `Executor.Close`; neither a successful cancellation request nor its snapshot replaces those waits. **What the Runtime does around a Turn.** One output consumer starts before native Start, drains the bounded 64-frame channel and keeps the terminal observation until Start publication, Turn settlement and admitted operation receipts finish. Natural completion never calls Cancel. Input and function admission close before settlement; operations already admitted hold their barrier through native receipts and outbound acknowledgement. The Runtime sends cancellation to the Turn before waiting on that barrier, because a written input may need a native interrupt to produce its receipt. It joins native settlement, any required confirmed Executor close, output drain and all admitted operations before an applied acknowledgement or reuse, and only then forwards Done or an applied cancellation receipt. A failed Close can report failure while keeping the same Run and outstanding operations for retry; a closed caller wait cannot manufacture an applied input receipt. The Runtime commits native continuity and releases the old Run's admission before publishing Done, since the receiver may start another Turn at once; a late terminal-send failure belongs to the old Run and cannot invalidate a successor that already owns the Executor. Connection shutdown owns transport-loss cleanup. The settlement wait is ten seconds and the receipt send budget five seconds; a timeout is not proof of quiescence. @@ -148,7 +147,7 @@ A Harness that supports the Subagent reads implements the [neutral observation c ## Register the adapter -Registration is static and requires a build. Export one `agent.Declaration` from `apps/daemon/internal/agent//declaration.go`, then add it to `harnessDeclarations` in [`cli/agent_discovery.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_discovery.go). The declaration contains the kind and complete capability descriptor, the shared model `Configuration` and a `Discover` function. Discovery receives the profile and diagnostic writers, owns native configuration and availability checks, and returns the installed `agent.Runtime` with its descriptor and its preparation and Executor factories. Return nil when the adapter is not configured; return an unavailable descriptor without factories when configured prerequisites fail. Keep version gates and factory-selection conditions inside the adapter. +Registration is static and requires a build. Export one `agent.Declaration` from `apps/daemon/internal/agent//declaration.go`, then add it to `harnessDeclarations` in [`cli/agent_discovery.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_discovery.go). The declaration contains the kind and complete capability descriptor, the shared model `Configuration` and a `Discover` function. Discovery receives the profile and diagnostic writers, owns native configuration and availability checks, and returns the installed `agent.Runtime` with its descriptor and Executor factory. Return nil when the adapter is not configured; return an unavailable descriptor without a factory when configured prerequisites fail. Keep version gates and factory-selection conditions inside the adapter. [`cli/agent_registration.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_registration.go) iterates the discovered runtimes and calls `Registry.Register` from `agent/harness.go`. It verifies that discovery retained the declared kind and registers the Runtime in this order: @@ -156,11 +155,10 @@ Registration is static and requires a build. Export one `agent.Declaration` from | --- | --- | --- | | 1 | `RegisterKind(proto.SupportedAgentKind, harnessconfig.Configuration)` | Kind, availability, version, `AgentKindCapabilities` and the model configuration declaration. It resets the other registrations, so call it first. | | 2 | `RegisterExecutor(kind, agent.ExecutorFactory)` | The Executor and Turn lifecycle used for execution; derives the `Preparation` capability | -| 3 | `RegisterPreparation(kind, workspaceRead, agent.PreparationFactory)` | Optional: separate read-only workspace preparation for qualified workspace operations | Every `proto.AgentKindCapabilities` field must be explicitly `proto.CapabilitySupported` or `proto.CapabilityUnsupported`, even for an unavailable Harness. `proto.CapabilityUnspecified` is invalid: zero values and omitted fields never mean Unsupported. An installation probe may set an individual field with `proto.CapabilityFromBool`; it must not populate unmentioned or future fields. Availability stays separate in `SupportedAgentKind.Available`. Registration validates the complete declaration before changing the registry, and the wire carries an explicit boolean for every field, so omitted and null fields are invalid. A new field requires a decision in every production declaration. Runtime consumers use `IsSupported()` and reject unsupported requests before native operations; an interface assertion verifies implementation, never support. Every declaration must match the behavior verified for that installation; the [Core–Runtime protocol](../../docs/runtime-protocol.md#capability-declarations) owns how declarations travel and are frozen. -The admission mapping is explicit. `Steering` controls non-durable `Steerer` input. `DurableInputReceipts` controls `DurableSteerer` input and also requires the Turn settlement contract; neither implies the other, and Core's public text profile requires both. Workspace declarations describe the authorized resource owner, including the common Runtime workspace implementation. Runtime registration does not grant Core qualification; the service profile does. +The admission mapping is explicit. `Steering` controls non-durable `Steerer` input. `DurableInputReceipts` controls `DurableSteerer` input and also requires the Turn settlement contract; neither implies the other, and Core's public text profile requires both. Workspace declarations describe the authorized resource owner, including the common Runtime workspace implementation. `WorkspaceReadPreparation` admits `execution_prepare` with `workspace_read_only`. The Runtime readies that preparation itself and serves its reads from the bound local workspace directory without calling the Executor factory, so an adapter declares it only when this kind's Environment workspace is that local directory. Registration does not derive it. Runtime registration does not grant Core qualification; the service profile does. The runnable test-only example [`testdata/onboarding/main.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/testdata/onboarding/main.go) registers a text-only synthetic Harness. It shows a Session-owned Executor, fresh Turns, durable steering, cancellation and history binding, and is never shipped. @@ -197,7 +195,7 @@ Run the `engine` and `execution` tests for omission, policy, combination and err ## Native model configuration -[`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) owns the shared configuration declaration and pure preparation contract. Each adapter supplies one `Configuration`, in `internal/harnessconfig/`, to Core's composition and to the Runtime's `RegisterKind`. The preparation and Executor paths both validate through that declaration before native side effects. The wire object is `proto.HarnessConfig`. [Model execution](./model-execution.md#native-model-parameters) lists each Harness's accepted fields. +[`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) owns the shared configuration declaration and pure preparation contract. Each adapter supplies one `Configuration`, in `internal/harnessconfig/`, to Core's composition and to the Runtime's `RegisterKind`. The Executor path validates through that declaration before native side effects. The wire object is `proto.HarnessConfig`. [Model execution](./model-execution.md#native-model-parameters) lists each Harness's accepted fields. A supplied `model` must be a nonempty string, and an explicit `model_provider` requires it. The native-owned connection path may omit both; explicit null is invalid. An explicitly empty declaration accepts no provider or nonempty native parameters and advertises no provider support. Unknown protocol formats and duplicate protocol declarations fail at registration. @@ -229,7 +227,7 @@ Keep provider keys in private operator files, never in commits or logs. Existing | Boundary | Tests | | --- | --- | -| Codex reuse, cancellation and unconfirmed cleanup | `codex/executor_test.go`, `terminal_cleanup_test.go`, `prepared_cancel_test.go` | +| Codex reuse, cancellation and unconfirmed cleanup | `codex/executor_test.go`, `terminal_cleanup_test.go` | | Codex input receipts and strict recovery | `codex/function_write_receipt_test.go`, `function_receipt_test.go`, `resume_test.go`, `recovery_test.go` | | Claude input ownership, cancellation and preparation cleanup | `claudesdk/executor_test.go`, `cancellation_test.go`, `preparation_test.go` | | MiniMax cancellation retirement, failed Start and cleanup retry | `mcode/executor_test.go`, `executor_backpressure_test.go` | diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 0a952cf17..57732314d 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "将原生 Harness 添加到 OpenAgentCore" source: contracts/agents-api/harness-onboarding.md -source_hash: 29b19e3f79eda12fcf888bf0551ea36a37df4f9eda8245fb98163978a3695aee +source_hash: b74f627ac8d518e0b780f827b8ccc6d268056d33c151b9e9f51866d93faf05d2 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code)。**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。本文档定义 Runtime–Harness 协议:适配器接口及其生命周期义务、注册、Core 资格认定和验收。[Harness capabilities](harness-capabilities.md) 记录了当前每个 Harness 支持的功能。 @@ -71,8 +71,7 @@ Environment 提供执行资源。受管 E2B、Docker 和 microsandbox 机器以 | `DurableSteerer` | 每个 Turn 上真实实现 | 区分完整写入与原生应用回执;保留重试身份 | | `Steerer` | 明确实现或 Unsupported | 额外的非持久化活动 Turn 输入 | | `FunctionResultSubmitter` | 明确实现或 Unsupported | 匹配原生调用和结果身份,并确认应用 | -| `WorkspaceReader`、`WorkspaceDirectoryLister`、`WorkspaceWriter` | 在 Turn、Executor 和 Prepared 所有者上明确实现 | 使用授权工作区,确认访问,提交或关闭,或者返回该操作的 Unsupported 错误 | -| `Prepared`、`PreparedCancellation` | 对可执行准备进行真实实现 | 在 Start、取消和未使用清理之间保持资源和输出的所有权 | +| `WorkspaceReader`、`WorkspaceDirectoryLister`、`WorkspaceWriter` | 在 Turn 和 Executor 所有者上明确实现 | 使用授权工作区,确认访问,提交或关闭,或者返回该操作的 Unsupported 错误 | | 中立消息、图像、MCP、结构化输出和 Subagent 观察 | 明确作出能力决策 | 保持每项操作的协议语义;在提交前拒绝不受支持的输入 | 每个适配器的 `contracts.go` 都包含针对每个小型接口的单项编译时断言。不要嵌入会让未来接口看起来已经实现的默认实现。添加契约时,还必须在通用完整性检查中进行分类,并在每个公共适配器中添加明确断言;该检查遵循已编写的 Harness 目录。 @@ -117,8 +116,8 @@ Session 在其已连接的 Runtime 中拥有一个可复用的 Executor;Turn - 错误表示结算尚未确认,既不释放所有权,也不释放容量。调用方截止时间只会停止等待,不会停止受跟踪的清理。必须串行重试同一个清理目标;清理失败会阻止替换并保留其资源槽位。 - `Executor.Close` 独立于 Turn 结果确认资源退役:不可变的 Turn 错误不得阻止在其工作和输出已经停止后关闭原生传输层。 - 结算必须包含所属的后台工作,并在失败后保留精确的原生清理目标。原生终止由适配器负责;仅有批量清理确认并不能证明已达到静默状态。 -- 每个 `Session` 都要声明 `CancellationOutcome`。`Turn` 和 `PreparedCancellation` 继承该声明。快照保留已观察到的原生身份、Usage 和输出,并在取消后仍可读取。缺失的证据保持未设置;空的 `DonePayload` 表示未观察到任何内容,而不是表示取消成功或不受支持。读取快照不会等待结算。 -- `Session.Cancel` 请求取消;输出关闭表示拆卸开始。可执行准备中的 `PreparedCancellation.Cancel` 会等待本地清理和输出写入停止。Turn 结算仍需要 `AwaitSettlement` 和所需的任何 `Executor.Close`;取消请求成功或其快照都不能替代这些等待。 +- 每个 `Session` 都要声明 `CancellationOutcome`。`Turn` 继承该声明。快照保留已观察到的原生身份、Usage 和输出,并在取消后仍可读取。缺失的证据保持未设置;空的 `DonePayload` 表示未观察到任何内容,而不是表示取消成功或不受支持。读取快照不会等待结算。 +- `Session.Cancel` 请求取消;输出关闭表示拆卸开始。Turn 结算仍需要 `AwaitSettlement` 和所需的任何 `Executor.Close`;取消请求成功或其快照都不能替代这些等待。 **Runtime 在 Turn 前后执行的工作。** 一个输出消费者会在原生 Start 之前启动,耗尽有界的 64 帧通道,并将终态观察保留到 Start 发布、Turn 结算和已准入操作回执完成为止。正常完成绝不调用 Cancel。输入和函数准入会在结算前关闭;已准入的操作会持有其屏障,直至原生回执和出站确认完成。Runtime 会在等待该屏障之前向 Turn 发送取消,因为已写入的输入可能需要原生中断才能生成回执。Runtime 会汇合原生结算、所需的已确认 Executor 关闭、输出耗尽和所有已准入操作,然后应用确认或执行复用,之后才会转发 Done 或已应用的取消回执。Close 失败可以报告失败,同时保留同一 Run 和未完成操作以供重试;已关闭的调用方等待无法凭空生成已应用输入回执。Runtime 会在发布 Done 前提交原生连续性状态并释放旧 Run 的准入,因为接收方可能立即启动另一个 Turn;迟到的终态发送失败属于旧 Run,不能使已拥有 Executor 的后继对象失效。连接关闭负责传输丢失清理。结算等待时间为十秒,回执发送预算为五秒;超时不能证明已达到静默状态。 @@ -150,7 +149,7 @@ MCP、公共函数、延迟函数发现、结构化输出、图像输入、详 ## 注册适配器 {#register-the-adapter} -注册是静态的,并且需要构建。从 `apps/daemon/internal/agent//declaration.go` 导出一个 `agent.Declaration`,然后将其添加到 [`cli/agent_discovery.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_discovery.go) 的 `harnessDeclarations` 中。声明包含 kind、完整能力描述符、共享模型 `Configuration` 和 `Discover` 函数。发现过程接收 profile 和诊断写入器,负责原生配置和可用性检查,并返回已安装的 `agent.Runtime` 及其描述符、准备工厂和 Executor 工厂。未配置适配器时返回 nil;已配置的前置条件失败时,返回不带任何工厂的不可用描述符。将版本门控和工厂选择条件保留在适配器内部。 +注册是静态的,并且需要构建。从 `apps/daemon/internal/agent//declaration.go` 导出一个 `agent.Declaration`,然后将其添加到 [`cli/agent_discovery.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_discovery.go) 的 `harnessDeclarations` 中。声明包含 kind、完整能力描述符、共享模型 `Configuration` 和 `Discover` 函数。发现过程接收 profile 和诊断写入器,负责原生配置和可用性检查,并返回已安装的 `agent.Runtime` 及其描述符和 Executor 工厂。未配置适配器时返回 nil;已配置的前置条件失败时,返回不带工厂的不可用描述符。将版本门控和工厂选择条件保留在适配器内部。 [`cli/agent_registration.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_registration.go) 遍历已发现的 Runtime,并调用 `agent/harness.go` 中的 `Registry.Register`。它验证发现过程是否保留了声明的 kind,并按以下顺序注册该 Runtime: @@ -158,11 +157,10 @@ MCP、公共函数、延迟函数发现、结构化输出、图像输入、详 | --- | --- | --- | | 1 | `RegisterKind(proto.SupportedAgentKind, harnessconfig.Configuration)` | Kind、可用性、版本、`AgentKindCapabilities` 和模型配置声明。它会重置其他注册项,因此必须首先调用。 | | 2 | `RegisterExecutor(kind, agent.ExecutorFactory)` | 执行所用的 Executor 和 Turn 生命周期;据此派生 `Preparation` 能力 | -| 3 | `RegisterPreparation(kind, workspaceRead, agent.PreparationFactory)` | 可选:针对已认定合格的工作区操作的独立只读工作区准备 | 每个 `proto.AgentKindCapabilities` 字段都必须显式设为 `proto.CapabilitySupported` 或 `proto.CapabilityUnsupported`,即使 Harness 不可用也是如此。`proto.CapabilityUnspecified` 无效:零值和省略字段绝不表示 Unsupported。安装探测可以使用 `proto.CapabilityFromBool` 设置单个字段;但不得填充未提及字段或未来字段。可用性通过 `SupportedAgentKind.Available` 单独表示。注册会在更改 registry 之前验证完整声明;线协议会为每个字段携带显式布尔值,因此省略字段和 null 字段均无效。添加新字段时,每个生产声明都必须作出决定。Runtime 使用者应调用 `IsSupported()`,并在原生操作前拒绝不受支持的请求;接口断言用于验证实现,绝不表示支持。每个声明都必须与针对该安装验证的行为一致;[Core–Runtime protocol](../../../docs/zh/runtime-protocol.md#capability-declarations) 负责声明的传输方式和冻结方式。 -准入映射是显式的。`Steering` 控制非持久化 `Steerer` 输入。`DurableInputReceipts` 控制 `DurableSteerer` 输入,并且还要求 Turn 结算契约;二者互不隐含,而且 Core 的公共文本 profile 要求同时具备二者。工作区声明描述授权资源所有者,包括通用 Runtime 工作区实现。Runtime 注册不会授予 Core 资格;服务 profile 才会授予。 +准入映射是显式的。`Steering` 控制非持久化 `Steerer` 输入。`DurableInputReceipts` 控制 `DurableSteerer` 输入,并且还要求 Turn 结算契约;二者互不隐含,而且 Core 的公共文本 profile 要求同时具备二者。工作区声明描述授权资源所有者,包括通用 Runtime 工作区实现。`WorkspaceReadPreparation` 准入带 `workspace_read_only` 的 `execution_prepare`。Runtime 自行就绪该 preparation,并从绑定的本地工作区目录提供读取,不调用 Executor 工厂;因此仅当该 kind 的 Environment 工作区就是该本地目录时,adapter 才声明它。注册过程不会派生它。Runtime 注册不会授予 Core 资格;服务 profile 才会授予。 可运行的仅测试示例 [`testdata/onboarding/main.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/testdata/onboarding/main.go) 会注册一个仅支持文本的合成 Harness。它展示 Session 所有的 Executor、全新的 Turn、持久化引导、取消和历史绑定,并且绝不会发布。 @@ -199,7 +197,7 @@ profile 是纯逻辑:它使用现有的公共类型和协议类型,声明受 ## 原生模型配置 {#native-model-configuration} -[`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) 负责共享配置声明和纯准备契约。每个适配器在 `internal/harnessconfig/` 中提供一个 `Configuration`,供 Core 组合和 Runtime 的 `RegisterKind` 使用。准备路径和 Executor 路径都会在产生原生副作用之前通过该声明进行验证。线协议对象是 `proto.HarnessConfig`。[Model execution](model-execution.md#native-model-parameters) 列出了每个 Harness 接受的字段。 +[`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) 负责共享配置声明和纯准备契约。每个适配器在 `internal/harnessconfig/` 中提供一个 `Configuration`,供 Core 组合和 Runtime 的 `RegisterKind` 使用。Executor 路径会在产生原生副作用之前通过该声明进行验证。线协议对象是 `proto.HarnessConfig`。[Model execution](model-execution.md#native-model-parameters) 列出了每个 Harness 接受的字段。 提供的 `model` 必须是非空字符串,并且显式指定 `model_provider` 时必须提供它。原生所有权连接路径可以省略二者;显式 null 无效。显式为空的声明不接受任何 Provider 或非空原生参数,也不宣称支持 Provider。未知协议格式和重复协议声明会导致注册失败。 @@ -231,7 +229,7 @@ Environment 验收使用 `services/core/tests/official_environment_{templates,se | 边界 | 测试 | | --- | --- | -| Codex 复用、取消和未确认清理 | `codex/executor_test.go`、`terminal_cleanup_test.go`、`prepared_cancel_test.go` | +| Codex 复用、取消和未确认清理 | `codex/executor_test.go`、`terminal_cleanup_test.go` | | Codex 输入回执和严格恢复 | `codex/function_write_receipt_test.go`、`function_receipt_test.go`、`resume_test.go`、`recovery_test.go` | | Claude 输入所有权、取消和准备清理 | `claudesdk/executor_test.go`、`cancellation_test.go`、`preparation_test.go` | | MiniMax 取消退役、Start 失败和清理重试 | `mcode/executor_test.go`、`executor_backpressure_test.go` | diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index bc1d4859a..606a62031 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -181,7 +181,7 @@ Core stores accepted values in the Turn outcome as `engine_error_code` and `engi ## Workspace operations -A workspace read that needs no running Turn uses the read-only preparation profile: `execution_prepare` with `workspace_read_only`, which requires the `workspace_read_preparation` capability. It accepts only the bound Environment and resource identity; execution options, model and MCP credentials, native Session continuation and model or tool input are excluded, and the owner rejects `execution_start`. A Runtime may serve it from its bound local filesystem without starting a Harness process. The profile publishes `released` only after local close succeeds; a cleanup error keeps ownership and reports `cleanup_unconfirmed`. A failed factory returns its resource with the error while cleanup is unconfirmed, and wrappers keep both values. A successful cleanup retry publishes the confirmed release; a stale status snapshot never publishes success. A release request, HTTP disconnect or remote socket closure alone does not confirm cleanup. +A workspace read that needs no running Turn uses the read-only preparation profile: `execution_prepare` with `workspace_read_only`, which requires the `workspace_read_preparation` capability. It accepts only the bound Environment and resource identity; execution options, model and MCP credentials, native Session continuation and model or tool input are excluded, and the owner rejects `execution_start`. The Runtime serves it from its bound local workspace without starting a Harness process. The profile publishes `released` after the Runtime drops the preparation's ownership; a stale status snapshot never publishes success. A release request, HTTP disconnect or remote socket closure alone does not confirm the release. `workspace_read` targets an existing preparation handle, or the Run it was transferred to, on the same authenticated device connection, with the exact frozen Environment identity; callers cannot supply sockets, credentials or workspace roots. `operation: directory` lists one workspace-relative directory (an empty path selects the root) with mutually exclusive byte and entry limits. A result carries at most 1024 single-component UTF-8 names of at most 255 bytes each, the entry kind, regular-file sizes and explicit truncation, and is returned only after directory access and handle cleanup settle. There is no snapshot, recursion or pagination at this layer. Byte and directory reads share target checks, correlation, capacity and retained operation waits. diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index 11a7807a5..2278a9fd7 100644 --- a/docs/zh/runtime-protocol.md +++ b/docs/zh/runtime-protocol.md @@ -1,7 +1,7 @@ --- title: "Core–Runtime 协议" source: docs/runtime-protocol.md -source_hash: aec653711cfaff6123091c418ef1d7eb9dc8b9648cc4a70f9fe231895fd7ae9d +source_hash: d2edb668d82f40ccf9094b85fcba67852bd14bc226f982d8b4e8918015c79ef8 --- 此协议在 Runtime daemon 获取机器凭据后连接 Core 与 daemon,定义 daemon 连接上消息的含义和顺序。wire 类型、限制和验证器仅在 [`internal/agentdaemon/proto`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/internal/agentdaemon/proto) 中定义一次;Core 的 [gateway](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/runtimegateway) 与参考 Runtime 的 [dispatcher](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/apps/daemon/internal/dispatch) 都使用它们,因此无需同步第二套 payload schema。签发凭据和打开连接的 HTTP 路由见[机器连接 API](../../contracts/agents-api/zh/machine-api.md)。 @@ -183,7 +183,7 @@ Core 在 Turn outcome 中将接受的值保存为 `engine_error_code` 和 `engin ## 工作区操作 {#workspace-operations} -无需运行 Turn 的工作区读取使用只读 preparation profile:带 `workspace_read_only` 的 `execution_prepare`,要求 `workspace_read_preparation` 能力。仅接受绑定的 Environment 和 resource 身份;不包含 execution option、model 与 MCP 凭据、原生 Session continuation、model 或 tool 输入,owner 拒绝 `execution_start`。Runtime 可以从绑定的本地文件系统提供读取,不启动 Harness 进程。profile 仅在本地 close 成功后发布 `released`;清理错误保留所有权并报告 `cleanup_unconfirmed`。factory 失败且清理未确认时,将 resource 与 error 一起返回,wrapper 保留两者。清理重试成功后发布已确认释放;旧 status snapshot 不发布成功。release 请求、HTTP 断连或远端 socket 关闭本身都不确认清理。 +无需运行 Turn 的工作区读取使用只读 preparation profile:带 `workspace_read_only` 的 `execution_prepare`,要求 `workspace_read_preparation` 能力。仅接受绑定的 Environment 和 resource 身份;不包含 execution option、model 与 MCP 凭据、原生 Session continuation、model 或 tool 输入,owner 拒绝 `execution_start`。Runtime 从绑定的本地工作区提供读取,不启动 Harness 进程。profile 在 Runtime 放弃该 preparation 的所有权后发布 `released`;旧 status snapshot 不发布成功。release 请求、HTTP 断连或远端 socket 关闭本身都不确认释放。 `workspace_read` 在同一已认证设备连接上,针对现有 preparation handle 或它已转移给的 Run,使用精确冻结的 Environment 身份;调用方不能提供 socket、凭据或 workspace root。`operation: directory` 列出一个 workspace 相对目录(空路径选择根目录),字节与条目限制互斥。结果最多携带 1024 个单路径组件 UTF-8 名称,每个最多 255 字节,并包含 entry kind、普通文件大小和明确截断信息;仅在目录访问与 handle 清理结算后返回。此层没有快照、递归或分页。字节读取与目录读取共享目标检查、关联、容量和保留的操作等待。 diff --git a/internal/agentdaemon/proto/runtime_prepare.go b/internal/agentdaemon/proto/runtime_prepare.go index c4b78e3fc..bbb2549c6 100644 --- a/internal/agentdaemon/proto/runtime_prepare.go +++ b/internal/agentdaemon/proto/runtime_prepare.go @@ -29,7 +29,6 @@ type RuntimeInitialFile struct { type RuntimeInitialization struct { Action string `json:"action"` Env map[string]string `json:"env,omitempty"` - Network string `json:"network,omitempty"` Packages []string `json:"packages,omitempty"` Command string `json:"command,omitempty"` CWD string `json:"cwd,omitempty"` @@ -179,9 +178,6 @@ func validWorkspacePath(value string, allowRoot bool) bool { } func validRuntimeInitialization(p RuntimeInitialization) bool { - if p.Network != "" && p.Network != "enabled" && p.Network != "disabled" { - return false - } if p.CWD != "" && !validWorkspacePath(p.CWD, true) { return false } @@ -197,7 +193,7 @@ func validRuntimeInitialization(p RuntimeInitialization) bool { } return true case "npm", "python": - if p.Env != nil || p.Command != "" || p.CWD != "" || len(p.Packages) == 0 || len(p.Packages) > 1000 || p.Network == "" { + if p.Env != nil || p.Command != "" || p.CWD != "" || len(p.Packages) == 0 || len(p.Packages) > 1000 { return false } for _, value := range p.Packages { @@ -207,7 +203,7 @@ func validRuntimeInitialization(p RuntimeInitialization) bool { } return true case "setup": - return p.Env == nil && p.Packages == nil && p.Network != "" && p.Command != "" && utf8.ValidString(p.Command) && !strings.ContainsRune(p.Command, 0) + return p.Env == nil && p.Packages == nil && p.Command != "" && utf8.ValidString(p.Command) && !strings.ContainsRune(p.Command, 0) default: return false } diff --git a/internal/agentdaemon/proto/runtime_prepare_test.go b/internal/agentdaemon/proto/runtime_prepare_test.go index d7cb3359b..84519b778 100644 --- a/internal/agentdaemon/proto/runtime_prepare_test.go +++ b/internal/agentdaemon/proto/runtime_prepare_test.go @@ -176,10 +176,10 @@ func TestRuntimePreparationInitialActions(t *testing.T) { base := RuntimePreparePayload{Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString()} for _, initialization := range []RuntimeInitialization{ {Action: "configure", Env: map[string]string{"EXAMPLE": "value"}}, - {Action: "npm", Network: "disabled", Packages: []string{"typescript"}}, - {Action: "python", Network: "enabled", Packages: []string{"requests"}}, - {Action: "setup", Network: "enabled", Command: "echo done"}, - {Action: "setup", Network: "disabled", Command: "echo done", CWD: "/workspace/project"}, + {Action: "npm", Packages: []string{"typescript"}}, + {Action: "python", Packages: []string{"requests"}}, + {Action: "setup", Command: "echo done"}, + {Action: "setup", Command: "echo done", CWD: "/workspace/project"}, } { p := base p.Action, p.Initialization = "initialize", &initialization @@ -203,13 +203,12 @@ func TestRuntimePreparationInitialActions(t *testing.T) { {Action: "exec", Command: "echo done"}, {Action: "configure", Packages: []string{"git"}}, {Action: "configure", Env: map[string]string{"A=B": "value"}}, - {Action: "system", Network: "enabled", Packages: []string{"git"}}, - {Action: "npm", Network: "invalid", Packages: []string{"a"}}, - {Action: "python", Network: "enabled", Packages: []string{"--help"}}, - {Action: "setup", Network: "enabled", Command: "x", CWD: "/environment"}, - {Action: "setup", Network: "enabled", Command: "x", CWD: "/workspace/../private"}, - {Action: "setup", Network: "enabled", Command: "x", Env: map[string]string{}}, - {Action: "setup", Network: "enabled", Command: "x", Packages: []string{}}, + {Action: "system", Packages: []string{"git"}}, + {Action: "python", Packages: []string{"--help"}}, + {Action: "setup", Command: "x", CWD: "/environment"}, + {Action: "setup", Command: "x", CWD: "/workspace/../private"}, + {Action: "setup", Command: "x", Env: map[string]string{}}, + {Action: "setup", Command: "x", Packages: []string{}}, } { p := base p.Action, p.Initialization = "initialize", &initialization diff --git a/internal/agentdaemon/proto/version.go b/internal/agentdaemon/proto/version.go index 101fd8dd3..524883ee6 100644 --- a/internal/agentdaemon/proto/version.go +++ b/internal/agentdaemon/proto/version.go @@ -2,7 +2,7 @@ package proto // Version identifies the complete Core–Runtime wire contract. Change it when // removing or changing a payload or its semantics; deploy both endpoints together. -const Version = "0.12.0" +const Version = "0.13.0" // VersionCompatible accepts only this contract. Patch drift, prerelease suffixes // and malformed versions do not select an implicit compatibility path. diff --git a/services/core/internal/execution/runtime_setup.go b/services/core/internal/execution/runtime_setup.go index d463d8a02..ccfcb795f 100644 --- a/services/core/internal/execution/runtime_setup.go +++ b/services/core/internal/execution/runtime_setup.go @@ -54,17 +54,16 @@ func setupOperations(setup environmentconfig.Setup) []runtimeSetupOperation { for i, plugin := range setup.Plugins { result = append(result, runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: "plugin", Slot: i, Plugin: &plugin.Metadata}, Data: plugin.Archive}) } - // Provisioning network policy is distinct from the policy enforced for Turns. for _, packages := range []struct { action string values []string }{{"npm", setup.Packages.NPM}, {"python", setup.Packages.Python}} { if len(packages.values) > 0 { - result = append(result, initialize(proto.RuntimeInitialization{Action: packages.action, Network: "enabled", Packages: packages.values})) + result = append(result, initialize(proto.RuntimeInitialization{Action: packages.action, Packages: packages.values})) } } for i, command := range setup.Commands { - operation := initialize(proto.RuntimeInitialization{Action: "setup", Network: "enabled", Command: command.Command, CWD: command.CWD}) + operation := initialize(proto.RuntimeInitialization{Action: "setup", Command: command.Command, CWD: command.CWD}) operation.Index = i result = append(result, operation) } diff --git a/services/core/internal/runtimegateway/runtime_prepare_test.go b/services/core/internal/runtimegateway/runtime_prepare_test.go index b070cbb50..02834d87a 100644 --- a/services/core/internal/runtimegateway/runtime_prepare_test.go +++ b/services/core/internal/runtimegateway/runtime_prepare_test.go @@ -289,7 +289,7 @@ func TestRuntimeInitializationNoDataAndExitReceipt(t *testing.T) { t.Run(fmt.Sprint(exit), func(t *testing.T) { s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) defer s.Close("test") - request := proto.RuntimePreparePayload{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "setup", Network: "enabled", Command: "echo test"}} + request := proto.RuntimePreparePayload{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "setup", Command: "echo test"}} if _, err := s.PrepareRuntime(t.Context(), uuid.NewString(), request, []byte("forbidden")); err == nil { t.Fatal("initialization body accepted") }