From f65562372f7f10412153ccd0055eb144c215fc44 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 09:00:19 +0000 Subject: [PATCH 1/4] Add agent.RemoveSessionState to delete other Sessions' native state --- apps/daemon/internal/agent/runtime_paths.go | 36 ++++++++++++++++++ .../internal/agent/runtime_paths_test.go | 37 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/apps/daemon/internal/agent/runtime_paths.go b/apps/daemon/internal/agent/runtime_paths.go index ce61e9a89..c1fc2f06f 100644 --- a/apps/daemon/internal/agent/runtime_paths.go +++ b/apps/daemon/internal/agent/runtime_paths.go @@ -2,6 +2,7 @@ package agent import ( "fmt" + "os" "path/filepath" "strings" ) @@ -49,3 +50,38 @@ func safeRuntimePathPart(value string) string { } return value } + +// RemoveSessionState deletes the state directory of every key but keepKey under +// both roots, for every agent kind. It lists directories instead of tracking +// Sessions, so it also covers state left by earlier processes and checkpoints. +func RemoveSessionState(stateRoot, runtimeRoot, keepKey string) error { + keep := safeRuntimePathParts(keepKey) + if len(keep) == 0 { + return fmt.Errorf("agent: invalid agent state key %q", keepKey) + } + for _, root := range []string{stateRoot, runtimeRoot} { + if !filepath.IsAbs(root) || filepath.Clean(root) != root { + return fmt.Errorf("agent: state root must be a clean absolute directory") + } + kinds, err := os.ReadDir(filepath.Join(root, "runtime")) + if err != nil && !os.IsNotExist(err) { + return err + } + for _, kind := range kinds { + states := filepath.Join(root, "runtime", kind.Name(), "state") + entries, err := os.ReadDir(states) + if err != nil && !os.IsNotExist(err) { + return err + } + for _, entry := range entries { + if entry.Name() == keep[0] { + continue + } + if err := os.RemoveAll(filepath.Join(states, entry.Name())); err != nil { + return err + } + } + } + } + return nil +} diff --git a/apps/daemon/internal/agent/runtime_paths_test.go b/apps/daemon/internal/agent/runtime_paths_test.go index 7abd9ebd8..130d9b22f 100644 --- a/apps/daemon/internal/agent/runtime_paths_test.go +++ b/apps/daemon/internal/agent/runtime_paths_test.go @@ -1,6 +1,7 @@ package agent import ( + "os" "path/filepath" "testing" ) @@ -31,3 +32,39 @@ func TestStateDirRequiresAgentState(t *testing.T) { t.Fatalf("sanitized state = %q, %v; want %q", got, err, want) } } + +func TestRemoveSessionStateKeepsOnlyTheNamedKey(t *testing.T) { + stateRoot, runtimeRoot := t.TempDir(), t.TempDir() + var kept, removed []string + for _, root := range []string{stateRoot, runtimeRoot} { + for _, kind := range []string{"codex", "claude-sdk"} { + keep, err := StateDir(root, kind, "keep") + if err != nil { + t.Fatal(err) + } + other, _ := StateDir(root, kind, "other") + for _, dir := range []string{keep, other} { + if err := os.MkdirAll(filepath.Join(dir, "history"), 0o700); err != nil { + t.Fatal(err) + } + } + kept, removed = append(kept, keep), append(removed, other) + } + } + if err := RemoveSessionState(stateRoot, runtimeRoot, "keep"); err != nil { + t.Fatal(err) + } + for _, dir := range kept { + if _, err := os.Stat(dir); err != nil { + t.Fatalf("kept state removed: %v", err) + } + } + for _, dir := range removed { + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("other state kept: %s", dir) + } + } + if err := RemoveSessionState(t.TempDir(), t.TempDir(), "keep"); err != nil { + t.Fatalf("empty roots: %v", err) + } +} From 9aa1cc03b46a59bcacf4bf508efe1201cebaa156 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 09:00:19 +0000 Subject: [PATCH 2/4] Bind the Runtime to its Environment and serve one Session at a time Remove the Session from runtime_prepare, workspace_write and the capability identity (proto 0.26.0). A prepare for another Session closes an idle executor, removes the other Sessions' native state and then prepares; a busy executor answers resource_unavailable. With one Session per Runtime, the executor capacity limits are gone. --- .../agent/codex/preparation_router_test.go | 2 +- .../agent/mcode/tool_environment_test.go | 4 +- apps/daemon/internal/cli/connect_bootstrap.go | 2 +- .../cli/connect_environment_binding.go | 2 +- .../internal/cli/connect_suspend_test.go | 2 +- .../cli/suspend_control_linux_test.go | 2 +- apps/daemon/internal/dispatch/executor.go | 108 ++++++++++-------- .../daemon/internal/dispatch/executor_test.go | 54 +++------ .../internal/dispatch/functions_test.go | 7 +- .../internal/dispatch/local_directory_test.go | 9 +- .../dispatch/native_file_results_test.go | 2 +- .../internal/dispatch/preparation_test.go | 2 +- apps/daemon/internal/dispatch/router_test.go | 27 +---- .../internal/dispatch/runtime_preparation.go | 2 +- .../dispatch/runtime_preparation_test.go | 60 +++++----- .../daemon/internal/dispatch/steering_test.go | 41 ------- .../dispatch/workspace_export_test.go | 4 +- .../internal/dispatch/workspace_write.go | 2 +- .../internal/dispatch/workspace_write_test.go | 24 ++-- .../daemon/internal/localworkspace/binding.go | 31 +++-- .../internal/localworkspace/binding_test.go | 46 +++++++- .../internal/localworkspace/capabilities.go | 6 +- .../capability_preparation_test.go | 13 +-- .../localworkspace/capability_rebind_test.go | 2 +- .../internal/localworkspace/native_binding.go | 11 +- .../runtime_initialization_test.go | 2 +- .../localworkspace/snapshot_marker.go | 9 +- .../localworkspace/snapshot_marker_test.go | 8 +- apps/daemon/internal/localworkspace/write.go | 4 +- contracts/agents-api/environments.md | 4 +- contracts/agents-api/zh/environments.md | 6 +- docs/runtime-protocol.md | 7 +- docs/zh/runtime-protocol.md | 9 +- internal/agentcapabilities/manifest.go | 11 +- internal/agentcapabilities/selection_test.go | 13 +-- internal/agentdaemon/proto/runtime_prepare.go | 11 +- .../agentdaemon/proto/runtime_prepare_test.go | 10 +- internal/agentdaemon/proto/version.go | 2 +- internal/agentdaemon/proto/workspace_write.go | 11 +- .../execution/environment_file_write.go | 4 +- .../execution/runtime_capabilities_test.go | 4 +- .../execution/runtime_initialization.go | 2 +- .../core/internal/execution/runtime_setup.go | 2 +- .../internal/execution/runtime_setup_test.go | 4 +- .../runtimegateway/runtime_prepare_test.go | 14 +-- .../runtimegateway/workspace_write_test.go | 6 +- .../environment_initialization_test.go | 2 +- .../local_environment_file_write_test.go | 2 +- .../runtime_initialization_test.go | 2 +- 49 files changed, 275 insertions(+), 339 deletions(-) diff --git a/apps/daemon/internal/agent/codex/preparation_router_test.go b/apps/daemon/internal/agent/codex/preparation_router_test.go index d5b306cb9..3e06a5f9f 100644 --- a/apps/daemon/internal/agent/codex/preparation_router_test.go +++ b/apps/daemon/internal/agent/codex/preparation_router_test.go @@ -39,7 +39,7 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { if err := os.MkdirAll(workspace, 0700); err != nil { t.Fatal(err) } - binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, workspace, filepath.Join(t.TempDir(), "capabilities")) + binding, err := localworkspace.NewWithCapabilityDirectory(environment, workspace, filepath.Join(t.TempDir(), "capabilities")) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/mcode/tool_environment_test.go b/apps/daemon/internal/agent/mcode/tool_environment_test.go index 54a36fdaa..aa56bd02d 100644 --- a/apps/daemon/internal/agent/mcode/tool_environment_test.go +++ b/apps/daemon/internal/agent/mcode/tool_environment_test.go @@ -44,7 +44,7 @@ func TestWorkspaceCredentialsRemainInRuntimeSnapshotAcrossReconnect(t *testing.T req.AgentSessionID = "native-1" write(source, `{"MCP_TEST_TOKEN":"changed","TOOL_SECRET":"changed"}`) } - binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, config.Directory, capabilityDirectory) + binding, err := localworkspace.NewWithCapabilityDirectory(environment, config.Directory, capabilityDirectory) if err != nil { t.Fatal(err) } @@ -97,7 +97,7 @@ func TestWorkspaceCredentialsRemainInRuntimeSnapshotAcrossReconnect(t *testing.T if err := os.Remove(filepath.Join(initialization, "tool-env.json")); err != nil { t.Fatal(err) } - binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, config.Directory, capabilityDirectory) + binding, err := localworkspace.NewWithCapabilityDirectory(environment, config.Directory, capabilityDirectory) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/cli/connect_bootstrap.go b/apps/daemon/internal/cli/connect_bootstrap.go index 3be0f82f1..a27d50ab5 100644 --- a/apps/daemon/internal/cli/connect_bootstrap.go +++ b/apps/daemon/internal/cli/connect_bootstrap.go @@ -19,7 +19,7 @@ func bootstrapProfile(path string) (*auth.Profile, bool, error) { if err != nil { return nil, false, err } - if err = localworkspace.Bind(input.EnvironmentID, input.SessionID, input.Workspace, localworkspace.CapabilityDirectory); err != nil { + if err = localworkspace.Bind(input.EnvironmentID, input.Workspace, localworkspace.CapabilityDirectory); err != nil { return nil, false, errors.New("connect: Runtime workspace binding unavailable") } return &auth.Profile{ServerURL: input.CoreURL, RuntimeID: input.DeviceID, RunnerCredential: input.Credential}, input.SuspendControl, nil diff --git a/apps/daemon/internal/cli/connect_environment_binding.go b/apps/daemon/internal/cli/connect_environment_binding.go index f30472381..17657f053 100644 --- a/apps/daemon/internal/cli/connect_environment_binding.go +++ b/apps/daemon/internal/cli/connect_environment_binding.go @@ -62,7 +62,7 @@ func bindEnvironmentRuntime(remote string, bound machineapi.Enrollment, workspac if bound.WorkspaceDirectory != runtimebootstrap.PublicWorkspace && bound.WorkspaceDirectory != workspace { return errors.New("connect: enrollment does not match the Runtime workspace") } - if err := localworkspace.Bind(bound.EnvironmentID, bound.SessionID, workspace, capabilityDirectory); err != nil { + if err := localworkspace.Bind(bound.EnvironmentID, workspace, capabilityDirectory); err != nil { return errors.New("connect: local Runtime layout unavailable") } return saveEnvironmentBinding(root, environmentBinding{RemoteURL: remote, Enrollment: bound, LocalWorkspace: workspace, CapabilityDirectory: capabilityDirectory}) diff --git a/apps/daemon/internal/cli/connect_suspend_test.go b/apps/daemon/internal/cli/connect_suspend_test.go index 366279eef..a668d7370 100644 --- a/apps/daemon/internal/cli/connect_suspend_test.go +++ b/apps/daemon/internal/cli/connect_suspend_test.go @@ -342,7 +342,7 @@ func TestQuiesceRetainsExecutorAcrossReconnectAndShutdownConfirmsCleanup(t *test environment, session := uuid.NewString(), uuid.NewString() t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) t.Setenv("OAC_RUNTIME_STATE_DIRECTORY", t.TempDir()) - if err := localworkspace.Bind(environment, session, t.TempDir(), t.TempDir()); err != nil { + if err := localworkspace.Bind(environment, t.TempDir(), t.TempDir()); err != nil { t.Fatal(err) } t.Cleanup(localworkspace.Unbind) diff --git a/apps/daemon/internal/cli/suspend_control_linux_test.go b/apps/daemon/internal/cli/suspend_control_linux_test.go index 53d890936..02c8d2d25 100644 --- a/apps/daemon/internal/cli/suspend_control_linux_test.go +++ b/apps/daemon/internal/cli/suspend_control_linux_test.go @@ -22,7 +22,7 @@ func TestResumeControlRejectsStaleIdentityAndDiscardsPreParkSignals(t *testing.T } path := filepath.Join(dir, "control.json") environment := uuid.NewString() - if err := localworkspace.Bind(environment, uuid.NewString(), t.TempDir(), t.TempDir()); err != nil { + if err := localworkspace.Bind(environment, t.TempDir(), t.TempDir()); err != nil { t.Fatal(err) } t.Cleanup(localworkspace.Unbind) diff --git a/apps/daemon/internal/dispatch/executor.go b/apps/daemon/internal/dispatch/executor.go index 84e9c2eba..b04d9ba34 100644 --- a/apps/daemon/internal/dispatch/executor.go +++ b/apps/daemon/internal/dispatch/executor.go @@ -13,26 +13,24 @@ import ( "github.com/google/uuid" ) -const executorIdleCapacity = 16 - type executorState struct { - capabilities proto.AgentKindCapabilities - id, sessionID, environmentID, stateKey string - fingerprint [32]byte - native agent.Executor - nativeID string - ctx context.Context - cancel context.CancelFunc - turn proto.TurnSettings - admission *preparationState - run *sessionState - preparing bool - invalid bool - closeDone chan struct{} - closeReason string - closeErr error - timer *time.Timer - idleLease uint64 + capabilities proto.AgentKindCapabilities + id, sessionID, environmentID string + fingerprint [32]byte + native agent.Executor + nativeID string + ctx context.Context + cancel context.CancelFunc + turn proto.TurnSettings + admission *preparationState + run *sessionState + preparing bool + invalid bool + closeDone chan struct{} + closeReason string + closeErr error + timer *time.Timer + idleLease uint64 } // executorFingerprint covers the configuration a retained Executor fixes. The @@ -77,6 +75,9 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, return r.rejectPreparation(env, "invalid_configuration") } requestFingerprint := sha256.Sum256(encoded) + if code := r.switchSession(input.SessionID); code != "" { + return r.rejectPreparation(env, code) + } r.mu.Lock() if r.closed || r.suspension != nil { r.mu.Unlock() @@ -100,22 +101,12 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, r.mu.Unlock() return r.rejectPreparation(env, "preparation_capacity") } - active := 0 - for _, candidate := range r.executors { - if candidate.sessionID != input.SessionID && candidate.stateKey == req.AgentStateKey { - r.mu.Unlock() - return r.rejectPreparation(env, "session_binding_conflict") - } - } for _, owner := range r.executors { - if owner.preparing || owner.admission != nil || owner.run != nil || owner.invalid { - active++ + if owner.sessionID != input.SessionID { + r.mu.Unlock() + return r.rejectPreparation(env, "resource_unavailable") } } - if active >= preparationCapacity { - r.mu.Unlock() - return r.rejectPreparation(env, "preparation_capacity") - } owner := r.executors[input.SessionID] reused := owner != nil if owner != nil { @@ -142,12 +133,8 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, owner.idleLease++ owner.turn = req.Turn } else { - if len(r.executors) >= executorIdleCapacity+preparationCapacity { - r.mu.Unlock() - return r.rejectPreparation(env, "executor_capacity") - } ownerCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) - owner = &executorState{capabilities: caps, id: uuid.NewString(), sessionID: input.SessionID, environmentID: req.EnvironmentID(), stateKey: req.AgentStateKey, fingerprint: fingerprint, turn: req.Turn, ctx: ownerCtx, cancel: cancel, preparing: true, nativeID: req.AgentSessionID} + owner = &executorState{capabilities: caps, id: uuid.NewString(), sessionID: input.SessionID, environmentID: req.EnvironmentID(), fingerprint: fingerprint, turn: req.Turn, ctx: ownerCtx, cancel: cancel, preparing: true, nativeID: req.AgentSessionID} r.executors[input.SessionID] = owner r.log.Info("executor owner_created", "executor_id", owner.id, "session_id", owner.sessionID) } @@ -174,6 +161,40 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, return nil } +// switchSession makes the Runtime serve sessionID alone before a new executor +// is created. Other Sessions' executors that hold no work are closed, and only +// then is every other Session's native state removed. That includes state whose +// executor already expired or whose process restarted. It returns a rejection +// code when a Session still holds work or a close is unconfirmed. +func (r *Router) switchSession(sessionID string) string { + var others []*executorState + r.mu.Lock() + if r.executors[sessionID] != nil && len(r.executors) == 1 { + r.mu.Unlock() + return "" + } + for _, owner := range r.executors { + if owner.sessionID == sessionID { + continue + } + if owner.preparing || owner.admission != nil || owner.run != nil { + r.mu.Unlock() + return "resource_unavailable" + } + others = append(others, owner) + } + r.mu.Unlock() + for _, owner := range others { + if r.closeExecutor(owner) != nil { + return "executor_cleanup_unconfirmed" + } + } + if r.localWorkspace.RemoveOtherSessionState(sessionID) != nil { + return "executor_cleanup_unconfirmed" + } + return "" +} + func (r *Router) prepareExecutor(p *preparationState, req proto.PromptRequestPayload, factory agent.ExecutorFactory) { defer r.shutdownWG.Done() owner := p.executor @@ -266,19 +287,6 @@ func (r *Router) scheduleExecutorIdleLocked(owner *executorState) { if owner.timer != nil { owner.timer.Stop() } - idle := 0 - for _, candidate := range r.executors { - if candidate != owner && candidate.run == nil && candidate.admission == nil && !candidate.preparing { - idle++ - } - } - if idle >= executorIdleCapacity { - owner.invalid = true - owner.closeReason = "idle_capacity" - r.shutdownWG.Add(1) - go func() { defer r.shutdownWG.Done(); _ = r.closeExecutor(owner) }() - return - } owner.idleLease++ lease := owner.idleLease r.log.Info("executor owner_idle", "executor_id", owner.id, "session_id", owner.sessionID) diff --git a/apps/daemon/internal/dispatch/executor_test.go b/apps/daemon/internal/dispatch/executor_test.go index d4c36dea5..e162be265 100644 --- a/apps/daemon/internal/dispatch/executor_test.go +++ b/apps/daemon/internal/dispatch/executor_test.go @@ -5,7 +5,6 @@ import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" import ( "context" "errors" - "fmt" "sync" "sync/atomic" "testing" @@ -283,41 +282,6 @@ func poolRequest(id string) proto.ExecutionPreparePayload { return req } -func TestExecutorIdleAndActiveCapacitiesAreIndependent(t *testing.T) { - var mu sync.Mutex - var owners []*reusableExecutor - r, s := poolRouter(t, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { - e := &reusableExecutor{} - mu.Lock() - owners = append(owners, e) - mu.Unlock() - return e, nil - }) - for i := 0; i < 17; i++ { - id := fmt.Sprint("idle-", i) - ready := executorAdmission(t, r, s, id, poolRequest(id)) - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, id, proto.ExecutionReleasePayload{Handle: ready.Handle})); err != nil { - t.Fatal(err) - } - } - waitFor(t, func() bool { - mu.Lock() - defer mu.Unlock() - var closed int32 - for _, e := range owners { - closed += e.closes.Load() - } - return closed == 1 - }, "idle capacity eviction") - for i := 0; i < 4; i++ { - id := fmt.Sprint("active-", i) - executorAdmission(t, r, s, id, poolRequest(id)) - } - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "overflow", poolRequest("overflow"))); err == nil { - t.Fatal("active capacity exceeded") - } -} - func TestExecutorRejectsSessionStateScopeMismatch(t *testing.T) { e := &reusableExecutor{} r, s, calls := executorRouter(t, e, time.Minute) @@ -332,6 +296,24 @@ func TestExecutorRejectsSessionStateScopeMismatch(t *testing.T) { } } +func TestExecutorSwitchesSessionOnlyWhenOtherSessionIsIdle(t *testing.T) { + owner := &reusableExecutor{starts: make(chan *reusableTurn, 1)} + r, s, calls := executorRouter(t, owner, time.Minute) + first := executorAdmission(t, r, s, "first", executorRequest()) + next := noEnvironmentPreparation("next", proto.PromptRequestPayload{AgentKind: "reusable"}) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "busy", next)) + if got := waitPreparationStatus(t, s, "busy", "rejected", ""); got.ErrorCode != "resource_unavailable" || owner.closes.Load() != 0 || calls.Load() != 1 { + t.Fatalf("admitted Session was displaced: %+v closes=%d", got, owner.closes.Load()) + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "first", proto.ExecutionReleasePayload{Handle: first.Handle})); err != nil { + t.Fatal(err) + } + waitPreparationStatus(t, s, "first", "released", "") + executorAdmission(t, r, s, "switch", next) + if owner.closes.Load() != 1 || calls.Load() != 2 { + t.Fatalf("switch closes=%d prepares=%d, want 1 and 2", owner.closes.Load(), calls.Load()) + } +} func TestExecutorRejectsUnsupportedConfigurationBeforeFactory(t *testing.T) { for name, change := range map[string]func(*proto.PromptRequestPayload){ "no environment": func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = false }, diff --git a/apps/daemon/internal/dispatch/functions_test.go b/apps/daemon/internal/dispatch/functions_test.go index db0bc5d56..ff3be723a 100644 --- a/apps/daemon/internal/dispatch/functions_test.go +++ b/apps/daemon/internal/dispatch/functions_test.go @@ -49,8 +49,8 @@ func TestFunctionReceiptsScopeRetriesAndConflicts(t *testing.T) { t.Fatal(err) } defer router.Shutdown(context.Background()) - for _, id := range []string{"one", "two"} { - startRun(t, router, sender, id, proto.PromptRequestPayload{AgentKind: "function-test", FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}}) + { + startRun(t, router, sender, "one", proto.PromptRequestPayload{AgentKind: "function-test", FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}}) } submit := func(run, call, text, delivery string) proto.InteractionDecisionAckPayload { t.Helper() @@ -120,9 +120,6 @@ func TestFunctionReceiptsScopeRetriesAndConflicts(t *testing.T) { t.Fatal(mutation, ack) } } - if a := submit("two", "call", "second answer", "other-run"); !a.Applied { - t.Fatal(a) - } for _, s := range sessions { s.mu.Lock() count := s.calls diff --git a/apps/daemon/internal/dispatch/local_directory_test.go b/apps/daemon/internal/dispatch/local_directory_test.go index 8739c7c75..27c445c4a 100644 --- a/apps/daemon/internal/dispatch/local_directory_test.go +++ b/apps/daemon/internal/dispatch/local_directory_test.go @@ -23,7 +23,7 @@ func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing workspace := t.TempDir() environment, session := uuid.NewString(), uuid.NewString() - binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, workspace, t.TempDir()) + binding, err := localworkspace.NewWithCapabilityDirectory(environment, workspace, t.TempDir()) if err != nil { t.Fatal(err) } @@ -66,11 +66,6 @@ func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "idle", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "forbidden", Input: proto.TextInput("work")})); err == nil { t.Fatal("read preparation admitted execution") } - bad := request - bad.AgentStateKey = uuid.NewString() - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "wrong-session", proto.ExecutionPreparePayload{Configuration: bad})); err == nil { - t.Fatal("wrong Session accepted") - } _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "idle", proto.ExecutionReleasePayload{Handle: ready.Handle})) waitPreparationStatus(t, sender, "idle", "released", "") if harnessCalls.Load() != 0 || r.ActiveRuns() != 0 { @@ -103,7 +98,7 @@ func TestLocalDirectoryKeepsNotDirectorySeparateFromFailures(t *testing.T) { t.Fatal(err) } environment, session := uuid.NewString(), uuid.NewString() - binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, workspace, t.TempDir()) + binding, err := localworkspace.NewWithCapabilityDirectory(environment, workspace, t.TempDir()) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/dispatch/native_file_results_test.go b/apps/daemon/internal/dispatch/native_file_results_test.go index fda03adbb..dd0eb6f63 100644 --- a/apps/daemon/internal/dispatch/native_file_results_test.go +++ b/apps/daemon/internal/dispatch/native_file_results_test.go @@ -38,7 +38,7 @@ func TestLocalUploadUnknownRetainsOwner(t *testing.T) { t.Fatal(got) } r.workspaceWrite = &workspaceUpload{envelope: proto.Envelope{ID: uuid.NewString()}, finished: true, uncertain: true} - request := proto.WorkspaceWritePayload{Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Path: "file", SizeBytes: 0, SHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"} + request := proto.WorkspaceWritePayload{Step: "begin", EnvironmentID: uuid.NewString(), Path: "file", SizeBytes: 0, SHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"} env, _ := proto.NewEnvelope(proto.TypeWorkspaceWrite, uuid.NewString(), request) if err = r.Handle(t.Context(), env); err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/dispatch/preparation_test.go b/apps/daemon/internal/dispatch/preparation_test.go index 79b816348..ea1a6f1aa 100644 --- a/apps/daemon/internal/dispatch/preparation_test.go +++ b/apps/daemon/internal/dispatch/preparation_test.go @@ -83,7 +83,7 @@ func preparationWorkspace(t *testing.T) *localworkspace.Binding { } t.Setenv("OAC_RUNTIME_HOME", runtimeHome) preparationDirectories.workspace, preparationDirectories.capabilities = t.TempDir(), t.TempDir() - binding, err := localworkspace.NewWithCapabilityDirectory(preparationEnvironmentID, preparationSessionID, preparationDirectories.workspace, preparationDirectories.capabilities) + binding, err := localworkspace.NewWithCapabilityDirectory(preparationEnvironmentID, preparationDirectories.workspace, preparationDirectories.capabilities) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/dispatch/router_test.go b/apps/daemon/internal/dispatch/router_test.go index 607e95535..8603046e0 100644 --- a/apps/daemon/internal/dispatch/router_test.go +++ b/apps/daemon/internal/dispatch/router_test.go @@ -136,7 +136,7 @@ func sessionExecutor(start func(context.Context, string, proto.MessageInput, cha // "input" and waits until it accepts operations. func startRun(t *testing.T, r *dispatch.Router, s *recSender, run string, config proto.PromptRequestPayload) { t.Helper() - ready := executorAdmission(t, r, s, "prepare-"+run, noEnvironmentPreparation("session-"+run, config)) + ready := executorAdmission(t, r, s, "prepare-"+run, noEnvironmentPreparation("session", config)) startExecutorTurn(t, r, s, "prepare-"+run, run, ready) waitFor(t, func() bool { return r.RunStartedForTest(run) }, "run "+run+" to accept operations") } @@ -175,31 +175,6 @@ func TestExecutionStartRunsInputAndForwardsOutput(t *testing.T) { waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "active runs to drop to 0") } -func TestExecutionStartRejectsDuplicateRunID(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - - startRun(t, h.router, h.sender, "run_dup", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) - sess := <-h.gotSess - - // Another Executor must not start a second Turn for the same RunID. - second := executorAdmission(t, h.router, h.sender, "prepare-dup", noEnvironmentPreparation("session-dup", proto.PromptRequestPayload{AgentKind: "fake_alpha"})) - start := mustEnv(t, proto.TypeExecutionStart, "prepare-dup", proto.ExecutionStartPayload{Handle: second.Handle, ExecutorID: second.ExecutorID, RunID: "run_dup", Input: proto.TextInput("input")}) - if err := h.router.Handle(context.Background(), start); err == nil { - t.Fatal("duplicate Run started") - } - if status := waitPreparationStatus(t, h.sender, "prepare-dup", "rejected", ""); status.ErrorCode != "run_conflict" { - t.Fatalf("duplicate start status = %+v, want run_conflict", status) - } - - select { - case extra := <-h.gotSess: - t.Fatalf("Turn started twice for duplicate run, second run=%s", extra.runID) - case <-time.After(50 * time.Millisecond): - } - close(sess.out) -} - func TestExecutionPrepareRejectsUnknownKind(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) diff --git a/apps/daemon/internal/dispatch/runtime_preparation.go b/apps/daemon/internal/dispatch/runtime_preparation.go index a50d1d3b5..e57861ad9 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation.go +++ b/apps/daemon/internal/dispatch/runtime_preparation.go @@ -60,7 +60,7 @@ func (r *Router) handleRuntimePrepare(ctx context.Context, env proto.Envelope) e } return r.sendRuntimePrepareResult(ctx, env.ID, rejectedRuntimePreparation("runtime_preparation_capacity")) } - if r.localWorkspace == nil || !r.localWorkspace.Matches(request.EnvironmentID, request.SessionID) || r.runtimePreparationResourcesBusyLocked() { + if r.localWorkspace == nil || !r.localWorkspace.Matches(request.EnvironmentID) || r.runtimePreparationResourcesBusyLocked() { r.mu.Unlock() return r.sendRuntimePrepareResult(ctx, env.ID, rejectedRuntimePreparation("resource_unavailable")) } diff --git a/apps/daemon/internal/dispatch/runtime_preparation_test.go b/apps/daemon/internal/dispatch/runtime_preparation_test.go index 6a6a8069a..f9b5a48d9 100644 --- a/apps/daemon/internal/dispatch/runtime_preparation_test.go +++ b/apps/daemon/internal/dispatch/runtime_preparation_test.go @@ -30,10 +30,10 @@ func (s *capabilitiesTestSender) Send(ctx context.Context, env proto.Envelope) e } } -func capabilitiesTestRouter(t *testing.T) (*Router, *capabilitiesTestSender, string, string) { +func capabilitiesTestRouter(t *testing.T) (*Router, *capabilitiesTestSender, string) { t.Helper() - environment, session := uuid.NewString(), uuid.NewString() - binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, t.TempDir(), t.TempDir()) + environment := uuid.NewString() + binding, err := localworkspace.NewWithCapabilityDirectory(environment, t.TempDir(), t.TempDir()) if err != nil { t.Fatal(err) } @@ -42,7 +42,7 @@ func capabilitiesTestRouter(t *testing.T) (*Router, *capabilitiesTestSender, str if err != nil { t.Fatal(err) } - return router, sender, environment, session + return router, sender, environment } func capabilityEnvelope(t *testing.T, id string, request proto.RuntimePreparePayload) proto.Envelope { @@ -54,10 +54,10 @@ func capabilityEnvelope(t *testing.T, id string, request proto.RuntimePreparePay return env } -func capabilityBegin(environment, session string, body []byte) proto.RuntimePreparePayload { +func capabilityBegin(environment string, body []byte) proto.RuntimePreparePayload { digest := sha256.Sum256(body) return proto.RuntimePreparePayload{ - BudgetMS: 300000, Step: "begin", Action: "skill", EnvironmentID: environment, SessionID: session, + BudgetMS: 300000, Step: "begin", Action: "skill", EnvironmentID: environment, Skill: &proto.SkillMetadata{Type: "inline", Name: "proof", Description: "A proof."}, SizeBytes: len(body), SHA256: hex.EncodeToString(digest[:]), } @@ -90,10 +90,10 @@ func shutdownCapabilitiesRouter(t *testing.T, router *Router) { func TestRuntimePreparationTransferValidatesCompleteBodyBeforeMutation(t *testing.T) { for _, mode := range []string{"offset", "digest", "short", "oversized-chunk"} { t.Run(mode, func(t *testing.T) { - r, sender, environment, session := capabilitiesTestRouter(t) + r, sender, environment := capabilitiesTestRouter(t) defer shutdownCapabilitiesRouter(t, r) body := bytes.Repeat([]byte("a"), proto.RuntimePrepareChunkBytes+1) - request := capabilityBegin(environment, session, body) + request := capabilityBegin(environment, body) if mode == "digest" { request.SHA256 = stringsOfZeroDigest() } @@ -143,15 +143,13 @@ func TestRuntimePreparationTransferValidatesCompleteBodyBeforeMutation(t *testin func stringsOfZeroDigest() string { return hex.EncodeToString(make([]byte, sha256.Size)) } func TestRuntimePreparationBeginRequiresExactBindingAndBounds(t *testing.T) { - r, sender, environment, session := capabilitiesTestRouter(t) + r, sender, environment := capabilitiesTestRouter(t) defer shutdownCapabilitiesRouter(t, r) - for _, mode := range []string{"environment", "session", "size", "nil-binding"} { - request := capabilityBegin(environment, session, []byte("abc")) + for _, mode := range []string{"environment", "size", "nil-binding"} { + request := capabilityBegin(environment, []byte("abc")) switch mode { case "environment": request.EnvironmentID = uuid.NewString() - case "session": - request.SessionID = uuid.NewString() case "size": request.SizeBytes = proto.RuntimePrepareMaxBytes + 1 case "nil-binding": @@ -171,7 +169,7 @@ func TestRuntimePreparationBeginRequiresExactBindingAndBounds(t *testing.T) { func TestRuntimePreparationPreparationExcludesOwnedResources(t *testing.T) { for _, mode := range []string{"write", "export", "read", "run", "executor", "preparation"} { t.Run(mode, func(t *testing.T) { - r, sender, environment, session := capabilitiesTestRouter(t) + r, sender, environment := capabilitiesTestRouter(t) switch mode { case "write": r.workspaceWrite = &workspaceUpload{} @@ -182,12 +180,12 @@ func TestRuntimePreparationPreparationExcludesOwnedResources(t *testing.T) { case "run": r.sessions["run"] = &sessionState{} case "executor": - r.executors[session] = &executorState{} + r.executors["session"] = &executorState{} case "preparation": r.preparations["p"] = &preparationState{owns: true} } id := uuid.NewString() - if err := r.Handle(t.Context(), capabilityEnvelope(t, id, capabilityBegin(environment, session, []byte("abc")))); err != nil { + if err := r.Handle(t.Context(), capabilityEnvelope(t, id, capabilityBegin(environment, []byte("abc")))); err != nil { t.Fatal(err) } if got := capabilitiesReceipt(t, sender, id, "rejected"); got.ErrorCode != "resource_unavailable" { @@ -208,9 +206,9 @@ func TestRuntimePreparationPreparationExcludesOwnedResources(t *testing.T) { } func TestRuntimePreparationUploadBlocksWorkspaceWriteAndSuspension(t *testing.T) { - r, sender, environment, session := capabilitiesTestRouter(t) + r, sender, environment := capabilitiesTestRouter(t) id := uuid.NewString() - if err := r.Handle(t.Context(), capabilityEnvelope(t, id, capabilityBegin(environment, session, []byte("abc")))); err != nil { + if err := r.Handle(t.Context(), capabilityEnvelope(t, id, capabilityBegin(environment, []byte("abc")))); err != nil { t.Fatal(err) } capabilitiesReceipt(t, sender, id, "ready") @@ -218,7 +216,7 @@ func TestRuntimePreparationUploadBlocksWorkspaceWriteAndSuspension(t *testing.T) t.Fatal(err) } digest := sha256.Sum256([]byte("abc")) - write, err := proto.NewEnvelope(proto.TypeWorkspaceWrite, uuid.NewString(), proto.WorkspaceWritePayload{Step: "begin", EnvironmentID: environment, SessionID: session, Path: "proof", SizeBytes: 3, SHA256: hex.EncodeToString(digest[:])}) + write, err := proto.NewEnvelope(proto.TypeWorkspaceWrite, uuid.NewString(), proto.WorkspaceWritePayload{Step: "begin", EnvironmentID: environment, Path: "proof", SizeBytes: 3, SHA256: hex.EncodeToString(digest[:])}) if err != nil { t.Fatal(err) } @@ -244,11 +242,11 @@ func TestRuntimePreparationUploadBlocksWorkspaceWriteAndSuspension(t *testing.T) } func TestRuntimePreparationCancellationKeepsOwnershipUntilApplyStops(t *testing.T) { - r, sender, environment, session := capabilitiesTestRouter(t) + r, sender, environment := capabilitiesTestRouter(t) sender.frames = make(chan proto.Envelope) ctx, cancel := context.WithCancel(context.Background()) id := uuid.NewString() - request := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", Action: "finalize", EnvironmentID: environment, SessionID: session, Sources: &proto.CapabilitySources{}} + request := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", Action: "finalize", EnvironmentID: environment, Sources: &proto.CapabilitySources{}} owner := &runtimePreparationTransfer{envelope: capabilityEnvelope(t, id, request), request: request, ready: make(chan struct{}), cancel: cancel, finished: true, apply: true} close(owner.ready) r.runtimePreparation = owner @@ -337,10 +335,10 @@ func TestRuntimePreparationResultCategoriesAndUnknownOwnership(t *testing.T) { t.Fatalf("unsafe result: %+v", got) } } - r, sender, environment, session := capabilitiesTestRouter(t) + r, sender, environment := capabilitiesTestRouter(t) ctx, cancel := context.WithCancel(context.Background()) id := uuid.NewString() - request := capabilityBegin(environment, session, []byte("abc")) + request := capabilityBegin(environment, []byte("abc")) owner := &runtimePreparationTransfer{envelope: capabilityEnvelope(t, id, request), request: request, data: []byte("abc"), ready: make(chan struct{}), cancel: cancel, finished: true, apply: true} close(owner.ready) r.runtimePreparation = owner @@ -368,9 +366,9 @@ func TestRuntimePreparationResultCategoriesAndUnknownOwnership(t *testing.T) { func TestRuntimePreparationStagingDeadlineRejectsWithoutApplying(t *testing.T) { synctest.Test(t, func(t *testing.T) { - r, sender, environment, session := capabilitiesTestRouter(t) + r, sender, environment := capabilitiesTestRouter(t) id := uuid.NewString() - request := capabilityBegin(environment, session, []byte("abc")) + request := capabilityBegin(environment, []byte("abc")) if err := r.Handle(t.Context(), capabilityEnvelope(t, id, request)); err != nil { t.Fatal(err) } @@ -390,10 +388,10 @@ func TestRuntimePreparationStagingDeadlineRejectsWithoutApplying(t *testing.T) { func TestRuntimePreparationApplyOutlivesStagingAndHonorsBudget(t *testing.T) { synctest.Test(t, func(t *testing.T) { - r, sender, environment, session := capabilitiesTestRouter(t) + r, sender, environment := capabilitiesTestRouter(t) ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute) id := uuid.NewString() - request := capabilityBegin(environment, session, []byte("abc")) + request := capabilityBegin(environment, []byte("abc")) owner := &runtimePreparationTransfer{envelope: capabilityEnvelope(t, id, request), request: request, ready: make(chan struct{}), cancel: cancel, finished: true, apply: true, transferDeadline: time.Now().Add(2 * time.Minute)} close(owner.ready) r.runtimePreparation = owner @@ -427,9 +425,9 @@ func TestRuntimePreparationApplyOutlivesStagingAndHonorsBudget(t *testing.T) { func TestRuntimePreparationHonorsBeginBudgetDuringStaging(t *testing.T) { synctest.Test(t, func(t *testing.T) { - r, sender, environment, session := capabilitiesTestRouter(t) + r, sender, environment := capabilitiesTestRouter(t) id := uuid.NewString() - request := capabilityBegin(environment, session, []byte("abc")) + request := capabilityBegin(environment, []byte("abc")) request.BudgetMS = 10 if err := r.Handle(t.Context(), capabilityEnvelope(t, id, request)); err != nil { t.Fatal(err) @@ -444,10 +442,10 @@ func TestRuntimePreparationHonorsBeginBudgetDuringStaging(t *testing.T) { func TestRuntimePreparationReportsApplyDuration(t *testing.T) { synctest.Test(t, func(t *testing.T) { - r, sender, environment, session := capabilitiesTestRouter(t) + r, sender, environment := capabilitiesTestRouter(t) _, cancel := context.WithCancel(t.Context()) id := uuid.NewString() - request := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", Action: "finalize", EnvironmentID: environment, SessionID: session, Sources: &proto.CapabilitySources{}} + request := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", Action: "finalize", EnvironmentID: environment, Sources: &proto.CapabilitySources{}} owner := &runtimePreparationTransfer{envelope: capabilityEnvelope(t, id, request), request: request, ready: make(chan struct{}), cancel: cancel, finished: true, apply: true} close(owner.ready) r.runtimePreparation = owner diff --git a/apps/daemon/internal/dispatch/steering_test.go b/apps/daemon/internal/dispatch/steering_test.go index cc327a2d6..16235424e 100644 --- a/apps/daemon/internal/dispatch/steering_test.go +++ b/apps/daemon/internal/dispatch/steering_test.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "testing" - "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -156,46 +155,6 @@ func TestSteeringReadinessAndUnsupportedRuns(t *testing.T) { } } -func TestSteeringDoesNotBlockOtherRunCancellation(t *testing.T) { - h := newHarness(t) - defer h.router.Shutdown(context.Background()) - entered, release := make(chan struct{}), make(chan struct{}) - defer close(release) - registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - return &steeringSession{ - fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, - steer: func(context.Context, proto.PromptSteerPayload) error { - close(entered) - <-release - return agent.ErrSteeringRejected - }, - }, nil - })) - ctx := context.Background() - for _, run := range []struct{ id, engine string }{{"run-1", "codex"}, {"run-2", "fake_alpha"}} { - startRun(t, h.router, h.sender, run.id, proto.PromptRequestPayload{AgentKind: run.engine}) - } - other := <-h.gotSess - other.closeOutOnCancel = true - returned := make(chan error, 1) - go func() { - returned <- h.router.Handle(ctx, mustEnv(t, proto.TypePromptSteer, "run-1", proto.PromptSteerPayload{InputID: "slow", Input: proto.TextInput("extra")})) - }() - select { - case err := <-returned: - if err != nil { - t.Fatal(err) - } - case <-time.After(time.Second): - t.Fatal("steering blocked dispatch") - } - <-entered - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptCancel, "run-2", nil)); err != nil { - t.Fatal(err) - } - waitFor(t, func() bool { return other.cancels() == 1 }, "other run cancellation") -} - func lastSteeringAck(t *testing.T, sender *recSender, runID, inputID string) proto.PromptSteerAckPayload { t.Helper() frames := sender.snapshot() diff --git a/apps/daemon/internal/dispatch/workspace_export_test.go b/apps/daemon/internal/dispatch/workspace_export_test.go index dfbec11e8..594f387ce 100644 --- a/apps/daemon/internal/dispatch/workspace_export_test.go +++ b/apps/daemon/internal/dispatch/workspace_export_test.go @@ -46,8 +46,8 @@ func exporterRouter(t *testing.T, program string) (*Router, exportSender, proto. } f.Close() } - environment, session := uuid.NewString(), uuid.NewString() - binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, workspace, t.TempDir()) + environment := uuid.NewString() + binding, err := localworkspace.NewWithCapabilityDirectory(environment, workspace, t.TempDir()) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/dispatch/workspace_write.go b/apps/daemon/internal/dispatch/workspace_write.go index 50ef6c5ff..e40491869 100644 --- a/apps/daemon/internal/dispatch/workspace_write.go +++ b/apps/daemon/internal/dispatch/workspace_write.go @@ -58,7 +58,7 @@ func (r *Router) handleWorkspaceWrite(ctx context.Context, env proto.Envelope) e } return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("write_capacity")) } - if !r.localWorkspace.AcceptsFileWrite(request.EnvironmentID, request.SessionID) || len(r.sessions) != 0 || len(r.workspaceReads) != 0 { + if !r.localWorkspace.AcceptsFileWrite(request.EnvironmentID) || len(r.sessions) != 0 || len(r.workspaceReads) != 0 { r.mu.Unlock() return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("resource_unavailable")) } diff --git a/apps/daemon/internal/dispatch/workspace_write_test.go b/apps/daemon/internal/dispatch/workspace_write_test.go index 7ecab2792..7b87d6b94 100644 --- a/apps/daemon/internal/dispatch/workspace_write_test.go +++ b/apps/daemon/internal/dispatch/workspace_write_test.go @@ -19,8 +19,8 @@ import ( func localWriterRouter(t *testing.T) (*dispatch.Router, *recSender, proto.WorkspaceWritePayload, string) { t.Helper() workspace := t.TempDir() - environment, session := uuid.NewString(), uuid.NewString() - binding, err := localworkspace.NewWithCapabilityDirectory(environment, session, workspace, t.TempDir()) + environment := uuid.NewString() + binding, err := localworkspace.NewWithCapabilityDirectory(environment, workspace, t.TempDir()) if err != nil { t.Fatal(err) } @@ -35,7 +35,7 @@ func localWriterRouter(t *testing.T) (*dispatch.Router, *recSender, proto.Worksp _ = r.Shutdown(ctx) }) digest := sha256.Sum256([]byte("abc")) - return r, sender, proto.WorkspaceWritePayload{Step: "begin", EnvironmentID: environment, SessionID: session, Path: "file", SizeBytes: 3, SHA256: hex.EncodeToString(digest[:])}, workspace + return r, sender, proto.WorkspaceWritePayload{Step: "begin", EnvironmentID: environment, Path: "file", SizeBytes: 3, SHA256: hex.EncodeToString(digest[:])}, workspace } func waitWorkspaceWrite(t *testing.T, sender *recSender, id, outcome string) proto.WorkspaceWriteResultPayload { @@ -62,19 +62,13 @@ func waitWorkspaceWrite(t *testing.T, sender *recSender, id, outcome string) pro func TestLocalUploadRequiresExactScopeAndCompleteBody(t *testing.T) { r, sender, request, workspace := localWriterRouter(t) - for _, field := range []string{"environment", "session"} { - bad := request - if field == "environment" { - bad.EnvironmentID = uuid.NewString() - } else { - bad.SessionID = uuid.NewString() - } - id := uuid.NewString() - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, bad)); err != nil { - t.Fatal(err) - } - waitWorkspaceWrite(t, sender, id, "rejected") + bad := request + bad.EnvironmentID = uuid.NewString() + badID := uuid.NewString() + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, badID, bad)); err != nil { + t.Fatal(err) } + waitWorkspaceWrite(t, sender, badID, "rejected") id := uuid.NewString() if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, request)); err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/localworkspace/binding.go b/apps/daemon/internal/localworkspace/binding.go index 434351040..cc55eb92b 100644 --- a/apps/daemon/internal/localworkspace/binding.go +++ b/apps/daemon/internal/localworkspace/binding.go @@ -2,6 +2,7 @@ package localworkspace import ( "errors" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "sync" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" @@ -15,33 +16,37 @@ import ( // Binding freezes operator-owned identity and paths for one Runtime lifetime. type Binding struct { environment string - stateKey string workspace string writer *fileWriter capabilityMu sync.Mutex capabilityRoot string stateRoot string + runtimeRoot string } // NewWithCapabilityDirectory freezes paths selected by the Runtime operator. -func NewWithCapabilityDirectory(environment, session, workspace, directory string) (*Binding, error) { +func NewWithCapabilityDirectory(environment, workspace, directory string) (*Binding, error) { stateRoot, err := paths.StateDirectory() if err != nil { return nil, err } - b, err := newNativeBinding(environment, session, workspace, directory) + b, err := newNativeBinding(environment, workspace, directory) if err != nil { return nil, err } - b.stateRoot = stateRoot + runtimeRoot, err := paths.Root() + if err != nil { + return nil, err + } + b.stateRoot, b.runtimeRoot = stateRoot, runtimeRoot return b, nil } var current *Binding // Bind freezes the Runtime's Environment binding for this process. It runs once, before any Harness discovery. -func Bind(environment, session, workspace, capabilityDirectory string) error { - b, err := NewWithCapabilityDirectory(environment, session, workspace, capabilityDirectory) +func Bind(environment, workspace, capabilityDirectory string) error { + b, err := NewWithCapabilityDirectory(environment, workspace, capabilityDirectory) if err != nil { return err } @@ -66,7 +71,7 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa if b == nil && r.LocalEnvironment == nil { return r, nil } - if b == nil || r.LocalEnvironment == nil || r.LocalEnvironment.ID != b.environment || r.AgentStateKey != b.stateKey || + if b == nil || r.LocalEnvironment == nil || r.LocalEnvironment.ID != b.environment || r.DisableExecutionEnvironment { return r, errors.New("request does not match the dedicated local Environment") } @@ -90,6 +95,14 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa return r, nil } -func (b *Binding) Matches(environment, session string) bool { - return b != nil && b.environment == environment && b.stateKey == session +func (b *Binding) Matches(environment string) bool { + return b != nil && b.environment == environment +} + +// RemoveOtherSessionState deletes every Session's native state but keepKey's. +func (b *Binding) RemoveOtherSessionState(keepKey string) error { + if b == nil { + return nil + } + return agent.RemoveSessionState(b.stateRoot, b.runtimeRoot, keepKey) } diff --git a/apps/daemon/internal/localworkspace/binding_test.go b/apps/daemon/internal/localworkspace/binding_test.go index 0bb406ff7..c93d0b34b 100644 --- a/apps/daemon/internal/localworkspace/binding_test.go +++ b/apps/daemon/internal/localworkspace/binding_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "testing" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" @@ -20,7 +21,7 @@ func testBinding(t *testing.T) (*Binding, proto.PromptRequestPayload) { t.Setenv("OAC_RUNTIME_HOME", private) root := t.TempDir() environment, session := uuid.NewString(), uuid.NewString() - b, err := NewWithCapabilityDirectory(environment, session, root, t.TempDir()) + b, err := NewWithCapabilityDirectory(environment, root, t.TempDir()) if err != nil { t.Fatal(err) } @@ -38,8 +39,7 @@ func TestBindingRejectsScopeOverrides(t *testing.T) { "other Environment": func(r *proto.PromptRequestPayload) { r.LocalEnvironment = &proto.LocalEnvironment{ID: uuid.NewString()} }, - "other Session": func(r *proto.PromptRequestPayload) { r.AgentStateKey = uuid.NewString() }, - "none": func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = true }, + "none": func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = true }, } { t.Run(name, func(t *testing.T) { r := valid @@ -57,6 +57,42 @@ func TestBindingRejectsScopeOverrides(t *testing.T) { } } +func TestBindingServesSessionsInSequence(t *testing.T) { + b, req := testBinding(t) + for _, key := range []string{req.AgentStateKey, uuid.NewString()} { + req.AgentStateKey = key + configured, err := b.Configure(req) + if err != nil { + t.Fatalf("Session %s: %v", key, err) + } + if _, err = b.Prepare(t.Context(), configured); err != nil { + t.Fatalf("Session %s: %v", key, err) + } + } +} + +func TestBindingRemovesOtherSessionState(t *testing.T) { + b, _ := testBinding(t) + keep, other := uuid.NewString(), uuid.NewString() + var dirs []string + for _, key := range []string{keep, other} { + dir, err := agent.StateDir(b.runtimeRoot, "codex", key) + if err != nil || os.MkdirAll(dir, 0o700) != nil { + t.Fatal(err) + } + dirs = append(dirs, dir) + } + if err := b.RemoveOtherSessionState(keep); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(dirs[0]); err != nil { + t.Fatal("kept Session state removed") + } + if _, err := os.Stat(dirs[1]); !os.IsNotExist(err) { + t.Fatal("other Session state kept") + } +} + func TestDirectoryValidatesRelativePaths(t *testing.T) { b, _ := testBinding(t) got, err := b.ListWorkspaceDirectory(t.Context(), "", 2) @@ -96,11 +132,11 @@ func TestBindingPrepareRejectsOtherWorkspaceRoot(t *testing.T) { func TestCapabilityLayoutUsesOperatorDirectories(t *testing.T) { b, _ := testBinding(t) for _, directory := range []string{filepath.Join(b.workspace, "capabilities"), filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "capabilities")} { - if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, directory); err != nil { + if _, err := NewWithCapabilityDirectory(b.environment, b.workspace, directory); err != nil { t.Fatal(err) } } - if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, "relative"); err == nil { + if _, err := NewWithCapabilityDirectory(b.environment, b.workspace, "relative"); err == nil { t.Fatal("relative installation path accepted") } } diff --git a/apps/daemon/internal/localworkspace/capabilities.go b/apps/daemon/internal/localworkspace/capabilities.go index d757cd0c4..456f97ab1 100644 --- a/apps/daemon/internal/localworkspace/capabilities.go +++ b/apps/daemon/internal/localworkspace/capabilities.go @@ -20,7 +20,7 @@ func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (pr return r, nil } if b == nil || r.LocalEnvironment == nil || r.LocalEnvironment.CapabilitySources == nil || - r.LocalEnvironment.ID != b.environment || r.AgentStateKey != b.stateKey || r.LocalEnvironment.WorkspaceRoot != b.workspace { + r.LocalEnvironment.ID != b.environment || r.LocalEnvironment.WorkspaceRoot != b.workspace { return r, agentcapabilities.ErrInvalid } b.capabilityMu.Lock() @@ -86,7 +86,7 @@ func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (pr // ApplyRuntimePreparation settles every filesystem operation before returning. A // cancelled caller never leaves an unowned installation goroutine behind. func (b *Binding) ApplyRuntimePreparation(ctx context.Context, input proto.RuntimePreparePayload, data []byte) error { - if b == nil || !b.Matches(input.EnvironmentID, input.SessionID) || !proto.ValidRuntimePrepareRequest(input) || input.Step != "begin" { + if b == nil || !b.Matches(input.EnvironmentID) || !proto.ValidRuntimePrepareRequest(input) || input.Step != "begin" { return agentcapabilities.ErrInvalid } b.capabilityMu.Lock() @@ -161,7 +161,7 @@ func (b *Binding) loadCapabilitySnapshot(root *os.Root, input proto.CapabilitySo } func (b *Binding) capabilityIdentity() agentcapabilities.Identity { - return agentcapabilities.Identity{EnvironmentID: b.environment, SessionID: b.stateKey} + return agentcapabilities.Identity{EnvironmentID: b.environment} } // The current Linux Runtime layout is shared by user-owned and managed hosts. diff --git a/apps/daemon/internal/localworkspace/capability_preparation_test.go b/apps/daemon/internal/localworkspace/capability_preparation_test.go index 06cd5f368..6d5b59bca 100644 --- a/apps/daemon/internal/localworkspace/capability_preparation_test.go +++ b/apps/daemon/internal/localworkspace/capability_preparation_test.go @@ -9,7 +9,6 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/google/uuid" ) func writeSourceSkill(t *testing.T, directory, text string) { @@ -40,7 +39,7 @@ func TestPreparationFreezesLocalContentsAcrossReconnect(t *testing.T) { t.Fatalf("first preparation: %v", err) } writeSourceSkill(t, source, "second") - reconnect, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, b.capabilityRoot) + reconnect, err := NewWithCapabilityDirectory(b.environment, b.workspace, b.capabilityRoot) if err != nil { t.Fatal(err) } @@ -66,8 +65,7 @@ func TestPreparationFreezesLocalContentsAcrossReconnect(t *testing.T) { if err != nil || string(fresh[len(fresh)-6:]) != "second" { t.Fatal("new Session did not capture new source", err) } - // Reusing a snapshot under another selection cannot start native work, even - // for another Session. + // Reusing a snapshot under another selection cannot start native work. local := *configured.LocalEnvironment local.CapabilitySources = &proto.CapabilitySources{} changed := configured @@ -75,11 +73,6 @@ func TestPreparationFreezesLocalContentsAcrossReconnect(t *testing.T) { if _, err = b.Prepare(t.Context(), changed); err == nil { t.Fatal("changed selection accepted") } - reconnect.stateKey = uuid.NewString() - changed.AgentStateKey = reconnect.stateKey - if _, err = reconnect.Prepare(t.Context(), changed); err == nil { - t.Fatal("foreign snapshot accepted") - } } func TestPreparationUsesOperatorSourcesAndLeavesFailuresInert(t *testing.T) { @@ -204,7 +197,7 @@ func TestPreparationFreezesToolOnlyEnvironmentAcrossReconnect(t *testing.T) { if err := os.WriteFile(source, []byte(`{"LOCAL_ONLY":"changed"}`), 0600); err != nil { t.Fatal(err) } - reconnect, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, b.capabilityRoot) + reconnect, err := NewWithCapabilityDirectory(b.environment, b.workspace, b.capabilityRoot) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/localworkspace/capability_rebind_test.go b/apps/daemon/internal/localworkspace/capability_rebind_test.go index a0a10ebfd..df0795ba9 100644 --- a/apps/daemon/internal/localworkspace/capability_rebind_test.go +++ b/apps/daemon/internal/localworkspace/capability_rebind_test.go @@ -23,7 +23,7 @@ func TestFinalizeRebindsIdenticalRestoredInstallation(t *testing.T) { t.Fatal(err) } // The next Session's directory holds the files restored from the first. - next, err := NewWithCapabilityDirectory(uuid.NewString(), uuid.NewString(), first.workspace, first.capabilityRoot) + next, err := NewWithCapabilityDirectory(uuid.NewString(), first.workspace, first.capabilityRoot) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/localworkspace/native_binding.go b/apps/daemon/internal/localworkspace/native_binding.go index 02c57adae..08062c42d 100644 --- a/apps/daemon/internal/localworkspace/native_binding.go +++ b/apps/daemon/internal/localworkspace/native_binding.go @@ -9,12 +9,9 @@ import ( "github.com/google/uuid" ) -func newNativeBinding(environment, session, workspace, capabilities string) (*Binding, error) { - for _, value := range []string{environment, session} { - id, err := uuid.Parse(value) - if err != nil || id == uuid.Nil || id.String() != value { - return nil, errors.New("local workspace requires canonical resource identities") - } +func newNativeBinding(environment, workspace, capabilities string) (*Binding, error) { + if id, err := uuid.Parse(environment); err != nil || id == uuid.Nil || id.String() != environment { + return nil, errors.New("local workspace requires a canonical Environment identity") } for _, path := range []string{workspace, capabilities} { if runtimefs.ValidateLocalPath(path) != nil || filepath.Dir(path) == path { @@ -25,7 +22,7 @@ func newNativeBinding(environment, session, workspace, capabilities string) (*Bi if err != nil || !info.IsDir() { return nil, errors.New("local workspace root must be an existing directory") } - return &Binding{environment: environment, stateKey: session, workspace: workspace, capabilityRoot: capabilities, writer: &fileWriter{}}, nil + return &Binding{environment: environment, workspace: workspace, capabilityRoot: capabilities, writer: &fileWriter{}}, nil } // ReadToolEnvironment reads explicit initialization values for this installation. diff --git a/apps/daemon/internal/localworkspace/runtime_initialization_test.go b/apps/daemon/internal/localworkspace/runtime_initialization_test.go index 4e4aeb2bf..3b6472e46 100644 --- a/apps/daemon/internal/localworkspace/runtime_initialization_test.go +++ b/apps/daemon/internal/localworkspace/runtime_initialization_test.go @@ -347,7 +347,7 @@ func TestRuntimePreparationRejectsFilesAfterFinalization(t *testing.T) { t.Fatal(err) } digest := sha256.Sum256(nil) - input := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: b.environment, SessionID: b.capabilityIdentity().SessionID, + input := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: b.environment, Action: "file", File: &proto.RuntimeInitialFile{Path: "/workspace/file"}, SHA256: hex.EncodeToString(digest[:])} if err = b.ApplyRuntimePreparation(t.Context(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { t.Fatal("finalized Runtime accepted file", err) diff --git a/apps/daemon/internal/localworkspace/snapshot_marker.go b/apps/daemon/internal/localworkspace/snapshot_marker.go index 48ea5e49e..f3e2a813a 100644 --- a/apps/daemon/internal/localworkspace/snapshot_marker.go +++ b/apps/daemon/internal/localworkspace/snapshot_marker.go @@ -23,11 +23,8 @@ type snapshotMarker struct { func (b *Binding) openSnapshotMarker() (*snapshotMarker, error) { identity := b.capabilityIdentity() - for _, id := range []string{identity.EnvironmentID, identity.SessionID} { - parsed, err := uuid.Parse(id) - if err != nil || parsed == uuid.Nil || parsed.String() != id { - return nil, agentcapabilities.ErrInvalid - } + if parsed, err := uuid.Parse(identity.EnvironmentID); err != nil || parsed == uuid.Nil || parsed.String() != identity.EnvironmentID { + return nil, agentcapabilities.ErrInvalid } private := b.stateRoot if agentcapabilities.ValidateLocalDirectories([]string{private}) != nil { @@ -65,7 +62,7 @@ func (b *Binding) openSnapshotMarker() (*snapshotMarker, error) { current.Close() return nil, agentcapabilities.ErrInvalid } - marker := &snapshotMarker{directory: current, unlock: unlock, name: identity.EnvironmentID + "-" + identity.SessionID + ".json", root: b.capabilityRoot} + marker := &snapshotMarker{directory: current, unlock: unlock, name: identity.EnvironmentID + ".json", root: b.capabilityRoot} completed, err := marker.read() if err != nil { marker.close() diff --git a/apps/daemon/internal/localworkspace/snapshot_marker_test.go b/apps/daemon/internal/localworkspace/snapshot_marker_test.go index 5126ade53..2748be6dc 100644 --- a/apps/daemon/internal/localworkspace/snapshot_marker_test.go +++ b/apps/daemon/internal/localworkspace/snapshot_marker_test.go @@ -27,7 +27,7 @@ func markerBinding(t *testing.T) (*Binding, proto.PromptRequestPayload) { } func markerPath(b *Binding) string { identity := b.capabilityIdentity() - return filepath.Join(b.stateRoot, "daemon", "capability-installations", identity.EnvironmentID+"-"+identity.SessionID+".json") + return filepath.Join(b.stateRoot, "daemon", "capability-installations", identity.EnvironmentID+".json") } func TestSnapshotMarkerRoundTrip(t *testing.T) { b, _ := markerBinding(t) @@ -185,7 +185,7 @@ func TestSnapshotMarkerRefusesDamagedState(t *testing.T) { func TestCapabilityFinalizeRecordsCompletionAndRejectsLaterImports(t *testing.T) { b, _ := markerBinding(t) identity := b.capabilityIdentity() - finalize := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "finalize", Sources: &proto.CapabilitySources{}} + finalize := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: identity.EnvironmentID, Action: "finalize", Sources: &proto.CapabilitySources{}} if err := b.ApplyRuntimePreparation(t.Context(), finalize, nil); err != nil { t.Fatal(err) } @@ -196,7 +196,7 @@ func TestCapabilityFinalizeRecordsCompletionAndRejectsLaterImports(t *testing.T) if err := os.RemoveAll(b.capabilityRoot); err != nil { t.Fatal(err) } - skill := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "skill", Skill: &proto.SkillMetadata{Type: "inline", Name: "example", Description: "Example"}, SizeBytes: 1, SHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + skill := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: identity.EnvironmentID, Action: "skill", Skill: &proto.SkillMetadata{Type: "inline", Name: "example", Description: "Example"}, SizeBytes: 1, SHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} if err := b.ApplyRuntimePreparation(t.Context(), skill, []byte("x")); err == nil { t.Fatal("completed identity accepted import") } @@ -237,7 +237,7 @@ func TestCapabilityMarkerSurvivesComputeReplacement(t *testing.T) { marker.close() t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) identity := b.capabilityIdentity() - replacement, err := NewWithCapabilityDirectory(identity.EnvironmentID, identity.SessionID, b.workspace, b.capabilityRoot) + replacement, err := NewWithCapabilityDirectory(identity.EnvironmentID, b.workspace, b.capabilityRoot) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/localworkspace/write.go b/apps/daemon/internal/localworkspace/write.go index 1047ff497..3b54d3f7d 100644 --- a/apps/daemon/internal/localworkspace/write.go +++ b/apps/daemon/internal/localworkspace/write.go @@ -12,8 +12,8 @@ import ( // This private transfer bound is distinct from the public inline-file limit. const WriteMaxBytes = proto.WorkspaceWriteMaxBytes -func (b *Binding) AcceptsFileWrite(environment, session string) bool { - return b != nil && b.writer != nil && b.environment == environment && b.stateKey == session +func (b *Binding) AcceptsFileWrite(environment string) bool { + return b != nil && b.writer != nil && b.environment == environment } // WriteWorkspaceFile starts only after the caller supplies the complete bounded diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index 4a9bbd4db..96a7c3973 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -180,13 +180,13 @@ Changing `environment` to `{"type":"openai_hosted"}` reuses the same preparation **Readiness.** Transport `connected` is a connection observation, not readiness. Execution and live file access wait for initialization; then the native preparation owner validates the installed snapshot and Harness before admitting a Turn. File reads keep their own readiness and authorization and do not require capability or native readiness. Deployment model credentials are never sent to application-owned machines. -**Transfer.** Initial files, configure, npm, Python and setup operations, inert Skill and Plugin archives and finalization selections travel as typed `runtime_prepare` operations with canonical Session and Environment identities; the [protocol](../../docs/runtime-protocol.md#preparation-and-execution-order) owns chunking and receipts. Files and setup working directories use logical `/workspace` addresses; the Runtime chooses executables and physical destinations, and Core supplies no executable or host-platform field. Source selections accept portable absolute Unix, Windows drive and UNC paths; Core never resolves them on its own host, and the daemon applies its local path and access checks. +**Transfer.** Initial files, configure, npm, Python and setup operations, inert Skill and Plugin archives and finalization selections travel as typed `runtime_prepare` operations with a canonical Environment identity; the [protocol](../../docs/runtime-protocol.md#preparation-and-execution-order) owns chunking and receipts. Files and setup working directories use logical `/workspace` addresses; the Runtime chooses executables and physical destinations, and Core supplies no executable or host-platform field. Source selections accept portable absolute Unix, Windows drive and UNC paths; Core never resolves them on its own host, and the daemon applies its local path and access checks. ### Installed snapshot Both origins use the common Runtime parser and an `installed.json` manifest on Linux, macOS and Windows. The Runtime operator chooses the capability root ([installer options](../../docs/getting-started/self-hosted.md#options-for-automation)); Core and transfer requests cannot. -- The manifest binds the Session and Environment to the ordered source-selection digest. The preparation owner verifies or creates it before native execution, also for an empty selection. +- The manifest binds the Environment to the ordered source-selection digest. The preparation owner verifies or creates it before native execution, also for an empty selection. - After the setup commands, the initializer snapshots the declared workspace-contained capability directories into Runtime storage. Directory bytes are read after setup, not at Session creation. - A filesystem lock prevents concurrent installation. A private completion record keeps only the operator's installation root, so a deleted snapshot is never mistaken for a first preparation or captured again. - Missing, partial, conflicting or foreign snapshots fail without deleting data, repairing or replaying. diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md index e85093ea0..743e5f6d3 100644 --- a/contracts/agents-api/zh/environments.md +++ b/contracts/agents-api/zh/environments.md @@ -1,7 +1,7 @@ --- title: "Environment 与模板" source: contracts/agents-api/environments.md -source_hash: 029df42249e47ea130d35b2883572e3f89f10bf2fbd756060f2e53cd63559e11 +source_hash: a9d64e2e8a51a00f11071e1f5255814f6340c355554e6a0a3c9262ca9415e5df --- Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 @@ -182,13 +182,13 @@ Core 会在 Session 创建时冻结资源版本、元数据和源选择。随后 **就绪性。** 传输 `connected` 是一种连接观察,而不代表就绪。执行和实时文件访问都要等待初始化;随后,原生准备所有者会在接纳 Turn 前验证已安装的快照和 Harness。文件读取保留自身的就绪性和授权,不要求能力或原生就绪。部署模型凭据绝不发送到应用程序所有的机器。 -**传输。** 初始文件、configure、npm、Python 和设置操作、惰性的 Skill 和 Plugin 归档以及最终确定选择,都会作为带有类型的 `runtime_prepare` 操作传输,并携带规范的 Session 和 Environment 身份;[protocol](../../../docs/zh/runtime-protocol.md#preparation-and-execution-order) 负责分块和回执。文件及设置工作目录使用逻辑 `/workspace` 地址;Runtime 负责选择可执行文件和物理目标位置,Core 不提供任何可执行文件或主机平台字段。源选择接受可移植的 Unix 绝对路径、Windows 驱动器路径和 UNC 路径;Core 绝不会在自己的主机上解析这些路径,而 daemon 会应用其本地路径和访问检查。 +**传输。** 初始文件、configure、npm、Python 和设置操作、惰性的 Skill 和 Plugin 归档以及最终确定选择,都会作为带有类型的 `runtime_prepare` 操作传输,并携带规范的 Environment 身份;[protocol](../../../docs/zh/runtime-protocol.md#preparation-and-execution-order) 负责分块和回执。文件及设置工作目录使用逻辑 `/workspace` 地址;Runtime 负责选择可执行文件和物理目标位置,Core 不提供任何可执行文件或主机平台字段。源选择接受可移植的 Unix 绝对路径、Windows 驱动器路径和 UNC 路径;Core 绝不会在自己的主机上解析这些路径,而 daemon 会应用其本地路径和访问检查。 ### 已安装快照 {#installed-snapshot} 两种来源都使用通用 Runtime 解析器,以及适用于 Linux、macOS 和 Windows 的 `installed.json` 清单。Runtime 操作者负责选择能力根目录([installer options](../../../docs/zh/getting-started/self-hosted.md#options-for-automation));Core 和传输请求无法选择。 -- 清单会将 Session 和 Environment 绑定到有序的源选择摘要。准备所有者会在原生执行之前验证或创建该清单,即使选择为空也是如此。 +- 清单会将 Environment 绑定到有序的源选择摘要。准备所有者会在原生执行之前验证或创建该清单,即使选择为空也是如此。 - 设置命令运行后,初始化器会将声明的、位于工作区内的能力目录快照到 Runtime 存储中。目录字节是在设置之后读取,而不是在 Session 创建时读取。 - 文件系统锁可防止并发安装。私有完成记录仅保留操作者的安装根目录,因此已删除的快照绝不会被误认为首次准备,也不会再次被捕获。 - 快照缺失、不完整、冲突或属于外部来源时,会在不删除数据、修复或重播的情况下失败。 diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index 0b70287bf..a3352596b 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -45,7 +45,7 @@ sequenceDiagram - The first heartbeat is sent at once, then at the interval that bootstrap returned. Each heartbeat declares `supported_agent_kinds`, their availability and their [capabilities](#capability-declarations). Before the first heartbeat, capabilities are unknown; a kind missing from a heartbeat is not advertised. Neither permits inference. - Heartbeats establish liveness only, never execution progress or a receipt for an earlier message. -Each physical connection has fresh routing, admission handles and transfer state. A newer connection for the same device replaces the previous one: Core evicts the old Run routes, and the new connection inherits none of them. A valid credential and connection are never authority to choose another Session or Environment binding. +Each physical connection has fresh routing, admission handles and transfer state. A newer connection for the same device replaces the previous one: Core evicts the old Run routes, and the new connection inherits none of them. A valid credential and connection are never authority to choose another Environment binding. ## Versioning @@ -90,7 +90,8 @@ Every data frame is one JSON [`Envelope`](https://github.com/MiniMax-AI/OpenAgen | Identity | Scope and meaning | | --- | --- | | Device ID and connection | Authenticated Runtime routing and connection ownership | -| Session ID / Environment ID | Core-owned configuration and workspace binding; canonical UUIDs where the payload validator requires them | +| Environment ID | The Environment and workspace a Runtime is bound to for its lifetime; a canonical UUID in `runtime_prepare` and `workspace_write` | +| Session ID | Core-owned identity of the configuration in one `execution_prepare`; the Runtime serves one Session at a time | | Executor ID | Runtime-owned native resource, possibly retained across settled Turns with identical configuration, except the `turn` settings its Harness declares changeable | | Preparation request ID | `Envelope.id` for prepare, start, release and status; distinct from a Run | | Admission handle | Runtime-generated reservation, valid only on the connection that accepted it | @@ -208,6 +209,8 @@ stateDiagram-v2 ready --> failed ``` +One Session at a time: a Runtime serves a single Session, and each `execution_prepare` names it. If an Executor of another Session is preparing, admitted or running, the prepare is rejected with the retryable `resource_unavailable`. If it is idle, the Runtime closes it, waits for the confirmed close, removes the native state of every other Session and then prepares the new Session. A read-only workspace preparation starts no Harness and never switches Sessions. + Preparation and start run outside the receive loop and router lock. An admission expires at a fixed deadline that retries do not extend; expiry does not remove the Runtime's obligation to settle cleanup. The Runtime bounds active preparation and execution separately from idle retained resources and counts closing or uncertain resources until their cleanup succeeds. A definite `execution_prepare` rejection with `preparation_capacity` leaves the queued Turn unclaimed for the Worker to retry, including when cleanup holds the capacity; any other error or uncertain delivery authorizes no replay. The Runtime retains a bounded number of admission records, and an old handle never consumes a replacement's admission. These records are connection-local, not durable input replay. Idle expiry of an Executor is a Runtime resource policy, separate from Core's active-Turn concurrency. On shutdown the Runtime closes active and idle Executors, keeps any target whose close failed and allows a later serialized retry. An ordinary disconnection closes the failed transport and keeps the exact router until shutdown succeeds; a wait timeout or failed cleanup never authorizes reconnection, and process shutdown keeps waiting rather than discarding owned native resources. For Runtime initialization, shutdown joins the synchronous apply operation and its receipt delivery before releasing the connection; an `unknown` result remains unknown and blocks further preparation on that Router, but does not prevent reconnection after local mutations have stopped. Core retains the failed initialization and never replays it. Workspace operations keep their binding and settlement rules across Turn boundaries and Executor closure. diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index c06569a0e..88289d91e 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: b5b774b66b84f5efd11650e8b5d6e7b5cd569804ffbc53487ba62bdb584ff3fe +source_hash: 089f67fdb36cfd4778418684a5d0eec4b7425bc386e7d224c6cc382be35ee003 --- Core–Runtime 协议连接 Core 与 Runtime daemon,定义 daemon 连接上消息的含义和顺序。托管和自托管 Runtime 使用同一协议。 @@ -47,7 +47,7 @@ sequenceDiagram - 立即发送第一个 heartbeat,之后按 bootstrap 返回的间隔发送。每个 heartbeat 声明 `supported_agent_kinds`、其可用性和[能力](#capability-declarations)。第一个 heartbeat 之前能力未知;heartbeat 中缺失的 kind 视为未声明。两者都不允许推断。 - Heartbeat 仅证明存活,不证明执行进度,也不充当此前消息的回执。 -每条物理连接拥有新的路由、admission handle 和传输状态。同一设备的新连接替代旧连接:Core 移除旧 Run 路由,新连接不继承这些状态。有效凭据和连接不授权选择其他 Session 或 Environment 绑定。 +每条物理连接拥有新的路由、admission handle 和传输状态。同一设备的新连接替代旧连接:Core 移除旧 Run 路由,新连接不继承这些状态。有效凭据和连接不授权选择其他 Environment 绑定。 ## 版本 {#versioning} @@ -92,7 +92,8 @@ Core 对 `usage` 和 `resume` 没有准入规则。 | 身份 | 范围与含义 | | --- | --- | | Device ID 和连接 | 经认证的 Runtime 路由与连接所有权 | -| Session ID / Environment ID | Core 拥有的配置与工作区绑定;payload validator 要求时使用规范 UUID | +| Environment ID | Runtime 在其生命周期内绑定的 Environment 与工作区;`runtime_prepare` 和 `workspace_write` 中为规范 UUID | +| Session ID | Core 拥有的、一次 `execution_prepare` 中配置的身份;Runtime 一次只服务一个 Session | | Executor ID | Runtime 拥有的原生资源,可在配置相同的已结算 Turn 之间保留,其 Harness 声明可变的 `turn` 设置除外 | | Preparation request ID | prepare、start、release 和 status 的 `Envelope.id`;与 Run 不同 | | Admission handle | Runtime 生成的预约,仅在接受它的连接上有效 | @@ -210,6 +211,8 @@ stateDiagram-v2 ready --> failed ``` +一次只服务一个 Session:Runtime 只服务一个 Session,每个 `execution_prepare` 指明该 Session。若另一个 Session 的 Executor 正在 preparing、已 admitted 或正在运行,该 prepare 以可重试的 `resource_unavailable` 拒绝。若它空闲,Runtime 先关闭它,等待确认关闭,删除其他所有 Session 的原生状态,再 prepare 新 Session。只读工作区 preparation 不启动 Harness,也从不切换 Session。 + preparation 和 start 在 receive loop 与 router lock 之外运行。admission 在固定截止时间到期,重试不延长该时间;到期不解除 Runtime 完成清理结算的义务。Runtime 分别限制活动 preparation、execution 和保留的空闲资源,关闭中或不确定资源持续计入限制,直到清理成功。明确的 `execution_prepare` 拒绝若为 `preparation_capacity`,会让排队 Turn 保持未领取,供 Worker 重试,包括清理占用容量的情况;其他错误或不确定交付都不授权重放。Runtime 保留有限数量的 admission 记录,旧 handle 不会消耗替代项的 admission。这些记录仅属于连接,不是持久化输入重放。 Executor 空闲到期属于 Runtime 资源策略,与 Core 的活动 Turn 并发限制独立。关闭时 Runtime 关闭活动和空闲 Executor,保留关闭失败的目标,并允许稍后串行重试。普通断连会关闭失败的 transport 并保留原 router,直到 shutdown 成功;等待超时或清理失败不授权重连,进程 shutdown 继续等待,不丢弃自己拥有的原生资源。对于 Runtime 初始化,shutdown 会等待同步 apply 操作和回执发送结束后才释放连接;`unknown` 结果仍是不确定结果,并阻止该 Router 上的后续准备,但本地变更停止后不再阻止重连。Core 保留失败的初始化状态,绝不重放初始化。工作区操作在跨 Turn 和 Executor 关闭后仍保留绑定与结算规则。 diff --git a/internal/agentcapabilities/manifest.go b/internal/agentcapabilities/manifest.go index d8454b870..06adc4916 100644 --- a/internal/agentcapabilities/manifest.go +++ b/internal/agentcapabilities/manifest.go @@ -25,10 +25,9 @@ const MaxSnapshotBytes = 50 << 20 var ErrInvalid = errors.New("capability installation unavailable or unsupported") -// Identity binds an installation to one immutable Environment and Session. +// Identity binds an installation to one immutable Environment. type Identity struct { EnvironmentID string `json:"environment_id"` - SessionID string `json:"session_id"` } // DirectoryResolver opens a declared source after local authorization and path @@ -131,11 +130,9 @@ func (m *Manifest) addMCPPackage(root string) error { } func validateIdentity(identity Identity) error { - for _, value := range []string{identity.EnvironmentID, identity.SessionID} { - id, err := uuid.Parse(value) - if err != nil || id == uuid.Nil || id.String() != value { - return ErrInvalid - } + id, err := uuid.Parse(identity.EnvironmentID) + if err != nil || id == uuid.Nil || id.String() != identity.EnvironmentID { + return ErrInvalid } return nil } diff --git a/internal/agentcapabilities/selection_test.go b/internal/agentcapabilities/selection_test.go index fc669fb5c..3758aa947 100644 --- a/internal/agentcapabilities/selection_test.go +++ b/internal/agentcapabilities/selection_test.go @@ -17,7 +17,6 @@ import ( var testIdentity = Identity{ EnvironmentID: "a1000000-0000-4000-8000-000000000001", - SessionID: "a1000000-0000-4000-8000-000000000002", } // The fixture maps declared managed paths; Finalize knows nothing about it. @@ -142,15 +141,11 @@ func TestSnapshotIdentityAndSourceLifetime(t *testing.T) { if content, err := first.ReadFile("directories/0/resource.txt"); err != nil || string(content) != "first" { t.Fatal("old snapshot followed source edit") } - next := testIdentity - next.SessionID = "a1000000-0000-4000-8000-000000000003" - for _, scenario := range []string{"session", "environment", "selection"} { + for _, scenario := range []string{"environment", "selection"} { identity, selection := testIdentity, input switch scenario { - case "session": - identity = next case "environment": - identity.EnvironmentID = next.SessionID + identity.EnvironmentID = "a1000000-0000-4000-8000-000000000003" case "selection": selection.Directories = []string{"/caller/other"} } @@ -158,11 +153,11 @@ func TestSnapshotIdentityAndSourceLifetime(t *testing.T) { t.Fatalf("%s mismatch accepted", scenario) } } - if err := Finalize(second, input, next, resolve); err != nil { + if err := Finalize(second, input, testIdentity, resolve); err != nil { t.Fatal(err) } if content, err := second.ReadFile("directories/0/resource.txt"); err != nil || string(content) != "second" { - t.Fatal("new Session did not capture new source bytes") + t.Fatal("new snapshot did not capture new source bytes") } } diff --git a/internal/agentdaemon/proto/runtime_prepare.go b/internal/agentdaemon/proto/runtime_prepare.go index ad710190f..bf46a29f5 100644 --- a/internal/agentdaemon/proto/runtime_prepare.go +++ b/internal/agentdaemon/proto/runtime_prepare.go @@ -41,7 +41,6 @@ type RuntimePreparePayload struct { BudgetMS int64 `json:"budget_ms,omitempty"` Step string `json:"step"` EnvironmentID string `json:"environment_id,omitempty"` - SessionID string `json:"session_id,omitempty"` Action string `json:"action,omitempty"` Slot int `json:"slot,omitempty"` Skill *SkillMetadata `json:"skill,omitempty"` @@ -71,11 +70,9 @@ func ValidRuntimePrepareRequest(p RuntimePreparePayload) bool { if p.BudgetMS <= 0 || p.BudgetMS > RuntimePrepareMaxBudgetMS { return false } - for _, id := range []string{p.EnvironmentID, p.SessionID} { - value, err := uuid.Parse(id) - if err != nil || value == uuid.Nil || value.String() != id { - return false - } + value, err := uuid.Parse(p.EnvironmentID) + if err != nil || value == uuid.Nil || value.String() != p.EnvironmentID { + return false } if p.Offset != 0 || len(p.Data) != 0 { return false @@ -126,7 +123,7 @@ func ValidRuntimePrepareRequest(p RuntimePreparePayload) bool { encoded, err := json.Marshal(p) return err == nil && len(encoded) <= RuntimePrepareMaxFrameBytes } - if p.BudgetMS != 0 || p.EnvironmentID != "" || p.SessionID != "" || p.Action != "" || p.Slot != 0 || + if p.BudgetMS != 0 || p.EnvironmentID != "" || p.Action != "" || p.Slot != 0 || p.Skill != nil || p.Plugin != nil || p.Sources != nil || p.File != nil || p.Initialization != nil || p.SizeBytes != 0 || p.SHA256 != "" { return false } diff --git a/internal/agentdaemon/proto/runtime_prepare_test.go b/internal/agentdaemon/proto/runtime_prepare_test.go index 1e43d6ebd..440b6eafb 100644 --- a/internal/agentdaemon/proto/runtime_prepare_test.go +++ b/internal/agentdaemon/proto/runtime_prepare_test.go @@ -10,7 +10,7 @@ import ( ) func capabilityBegin() RuntimePreparePayload { - return RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), + return RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: uuid.NewString(), Action: "skill", Skill: &SkillMetadata{Type: "inline", Name: "example", Description: "Example"}, SizeBytes: 10, SHA256: strings.Repeat("a", 64)} } @@ -24,7 +24,7 @@ func TestCapabilitiesRequestValidation(t *testing.T) { "negative budget": func(p *RuntimePreparePayload) { p.BudgetMS = -1 }, "excessive budget": func(p *RuntimePreparePayload) { p.BudgetMS = RuntimePrepareMaxBudgetMS + 1 }, "noncanonical identity": func(p *RuntimePreparePayload) { p.EnvironmentID = "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA" }, - "zero identity": func(p *RuntimePreparePayload) { p.SessionID = uuid.Nil.String() }, + "zero identity": func(p *RuntimePreparePayload) { p.EnvironmentID = uuid.Nil.String() }, "begin data": func(p *RuntimePreparePayload) { p.Data = []byte("x") }, "begin offset": func(p *RuntimePreparePayload) { p.Offset = 1 }, "mixed metadata": func(p *RuntimePreparePayload) { @@ -91,7 +91,7 @@ func TestCapabilitiesRequestValidation(t *testing.T) { } func TestCapabilitiesFinalizeRequiresExplicitBoundedSelection(t *testing.T) { - p := RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "finalize", Sources: &CapabilitySources{}} + p := RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: uuid.NewString(), Action: "finalize", Sources: &CapabilitySources{}} if !ValidRuntimePrepareRequest(p) { t.Fatal("explicit empty finalization refused") } @@ -173,7 +173,7 @@ func TestLocalEnvironmentCarriesSelectionButNeverInstalledRoots(t *testing.T) { } func TestRuntimePreparationInitialActions(t *testing.T) { - base := RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString()} + base := RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: uuid.NewString()} for _, initialization := range []RuntimeInitialization{ {Action: "configure", Env: map[string]string{"EXAMPLE": "value"}}, {Action: "npm", Packages: []string{"typescript"}}, @@ -263,7 +263,7 @@ func TestRuntimePreparationExitCodes(t *testing.T) { } func TestRuntimePreparationPortableSources(t *testing.T) { - p := RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "finalize", Sources: &CapabilitySources{Directories: []string{`C:\Users\operator\skills`, `\\host\share\skills`}}} + p := RuntimePreparePayload{BudgetMS: 300000, Step: "begin", EnvironmentID: uuid.NewString(), Action: "finalize", Sources: &CapabilitySources{Directories: []string{`C:\Users\operator\skills`, `\\host\share\skills`}}} if !ValidRuntimePrepareRequest(p) { t.Fatal("portable sources rejected by wire") } diff --git a/internal/agentdaemon/proto/version.go b/internal/agentdaemon/proto/version.go index e0b69c9aa..1833e8d43 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.25.0" +const Version = "0.26.0" // VersionCompatible accepts only this contract. Patch drift, prerelease suffixes // and malformed versions do not select an implicit compatibility path. diff --git a/internal/agentdaemon/proto/workspace_write.go b/internal/agentdaemon/proto/workspace_write.go index 779a3f29a..4724ef729 100644 --- a/internal/agentdaemon/proto/workspace_write.go +++ b/internal/agentdaemon/proto/workspace_write.go @@ -21,7 +21,6 @@ const ( type WorkspaceWritePayload struct { Step string `json:"step"` EnvironmentID string `json:"environment_id,omitempty"` - SessionID string `json:"session_id,omitempty"` Path string `json:"path,omitempty"` SizeBytes int `json:"size_bytes,omitempty"` SHA256 string `json:"sha256,omitempty"` @@ -46,18 +45,16 @@ const ( func ValidWorkspaceWriteRequest(p WorkspaceWritePayload) bool { if p.Step == "begin" { - for _, id := range []string{p.EnvironmentID, p.SessionID} { - v, err := uuid.Parse(id) - if err != nil || v == uuid.Nil || v.String() != id { - return false - } + v, err := uuid.Parse(p.EnvironmentID) + if err != nil || v == uuid.Nil || v.String() != p.EnvironmentID { + return false } digest, err := hex.DecodeString(p.SHA256) return err == nil && len(digest) == 32 && strings.ToLower(p.SHA256) == p.SHA256 && p.SizeBytes >= 0 && p.SizeBytes <= WorkspaceWriteMaxBytes && p.Offset == 0 && len(p.Data) == 0 && len(p.Path) <= 4096 && p.Path != "." && fs.ValidPath(p.Path) && !strings.ContainsAny(p.Path, "\\\x00\r\n") } - if p.EnvironmentID != "" || p.SessionID != "" || p.Path != "" || p.SizeBytes != 0 || p.SHA256 != "" { + if p.EnvironmentID != "" || p.Path != "" || p.SizeBytes != 0 || p.SHA256 != "" { return false } switch p.Step { diff --git a/services/core/internal/execution/environment_file_write.go b/services/core/internal/execution/environment_file_write.go index 2053a697e..9ce518987 100644 --- a/services/core/internal/execution/environment_file_write.go +++ b/services/core/internal/execution/environment_file_write.go @@ -108,7 +108,7 @@ func (w *Worker) runFileWrite(owner context.Context, request fileWriteRequest) f dataDigest := sha256.Sum256(request.data) body, _ := json.Marshal([]any{request.path, len(request.data), hex.EncodeToString(dataDigest[:])}) digest := sha256.Sum256(body) - wire := proto.WorkspaceWritePayload{Step: "begin", EnvironmentID: environment.ID, SessionID: session.ID, Path: request.path, SizeBytes: len(request.data), SHA256: hex.EncodeToString(dataDigest[:])} + wire := proto.WorkspaceWritePayload{Step: "begin", EnvironmentID: environment.ID, Path: request.path, SizeBytes: len(request.data), SHA256: hex.EncodeToString(dataDigest[:])} if !proto.ValidWorkspaceWriteRequest(wire) { return fileWriteResult{err: sessions.ErrInvalidInput} } @@ -120,7 +120,7 @@ func (w *Worker) runFileWrite(owner context.Context, request fileWriteRequest) f if intent.Replayed { return unavailable } - result, err := peer.WriteWorkspaceFile(ctx, key.ID, proto.WorkspaceWritePayload{EnvironmentID: environment.ID, SessionID: session.ID, Path: request.path}, request.data) + result, err := peer.WriteWorkspaceFile(ctx, key.ID, proto.WorkspaceWritePayload{EnvironmentID: environment.ID, Path: request.path}, request.data) if err != nil || (result.Outcome != "completed" && result.Outcome != "rejected") { return unavailable } diff --git a/services/core/internal/execution/runtime_capabilities_test.go b/services/core/internal/execution/runtime_capabilities_test.go index 0c2b411bd..a0d7c3162 100644 --- a/services/core/internal/execution/runtime_capabilities_test.go +++ b/services/core/internal/execution/runtime_capabilities_test.go @@ -36,7 +36,7 @@ func TestRuntimeCapabilitiesPreserveRawBundlesAndSetupOrdering(t *testing.T) { if len(operations) != 5 || operations[0].Request.Initialization.Action != "configure" || operations[1].Request.Action != "skill" || operations[2].Request.Action != "plugin" || operations[3].Request.Initialization.Action != "setup" || operations[4].Request.Action != "finalize" { t.Fatal("bundle-before-setup or directory-after-setup ordering changed", operations) } - owner := agentcapabilities.Identity{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString()} + owner := agentcapabilities.Identity{EnvironmentID: uuid.NewString()} for _, op := range operations { peer := &capabilityFixture{outcome: "completed"} if _, err := runRuntimeSetup(initializationTestContext(t), peer, owner, op); err != nil { @@ -45,7 +45,7 @@ func TestRuntimeCapabilitiesPreserveRawBundlesAndSetupOrdering(t *testing.T) { if _, err := uuid.Parse(peer.requestID); err != nil { t.Fatal("request ID", err) } - if peer.request.EnvironmentID != owner.EnvironmentID || peer.request.SessionID != owner.SessionID { + if peer.request.EnvironmentID != owner.EnvironmentID { t.Fatal("allocation identity lost") } if (op.Request.Action == "skill" || op.Request.Action == "plugin") && !bytes.Equal(peer.body, archive) { diff --git a/services/core/internal/execution/runtime_initialization.go b/services/core/internal/execution/runtime_initialization.go index 91177c91b..d2645608b 100644 --- a/services/core/internal/execution/runtime_initialization.go +++ b/services/core/internal/execution/runtime_initialization.go @@ -153,7 +153,7 @@ func (w *Worker) prepareEnvironment(ctx context.Context, owner sessions.Environm return sessions.ErrInvalidInput } } - identity := agentcapabilities.Identity{EnvironmentID: owner.EnvironmentID, SessionID: owner.SessionID} + identity := agentcapabilities.Identity{EnvironmentID: owner.EnvironmentID} operations := setupOperations(setup) step := func(index int) error { err := w.lease.CheckOwnership(ctx) diff --git a/services/core/internal/execution/runtime_setup.go b/services/core/internal/execution/runtime_setup.go index 7abd302b1..7d48eeab8 100644 --- a/services/core/internal/execution/runtime_setup.go +++ b/services/core/internal/execution/runtime_setup.go @@ -149,7 +149,7 @@ func runRuntimeSetup(ctx context.Context, peer runtimePreparer, identity agentca } request := operation.Request request.BudgetMS = budget - request.EnvironmentID, request.SessionID = identity.EnvironmentID, identity.SessionID + request.EnvironmentID = identity.EnvironmentID result, err := peer.PrepareRuntime(ctx, uuid.NewString(), request, operation.Data) if err == nil && result.Outcome == "completed" { return result, nil diff --git a/services/core/internal/execution/runtime_setup_test.go b/services/core/internal/execution/runtime_setup_test.go index 5e6c5ac6c..78fff6cd1 100644 --- a/services/core/internal/execution/runtime_setup_test.go +++ b/services/core/internal/execution/runtime_setup_test.go @@ -73,12 +73,12 @@ func TestRuntimeSetupFailureLabels(t *testing.T) { func TestInitialFileUsesTypedRuntimeBytes(t *testing.T) { body := []byte(setupCanary) size := int64(len(body)) - owner := agentcapabilities.Identity{EnvironmentID: "environment", SessionID: "session"} + owner := agentcapabilities.Identity{EnvironmentID: "environment"} peer := &receiptRuntime{result: proto.RuntimePrepareResultPayload{Outcome: "completed"}} if _, err := installInitialFile(initializationTestContext(t), peer, owner, environmentconfig.InitialFileMetadata{Path: "/workspace/a", SizeBytes: &size}, body); err != nil { t.Fatal(err) } - if peer.request.Action != "file" || peer.request.File.Path != "/workspace/a" || peer.request.EnvironmentID != owner.EnvironmentID || peer.request.SessionID != owner.SessionID || string(peer.data) != setupCanary { + if peer.request.Action != "file" || peer.request.File.Path != "/workspace/a" || peer.request.EnvironmentID != owner.EnvironmentID || string(peer.data) != setupCanary { t.Fatal("file transport changed") } size++ diff --git a/services/core/internal/runtimegateway/runtime_prepare_test.go b/services/core/internal/runtimegateway/runtime_prepare_test.go index 3cf39ae80..94ab6427c 100644 --- a/services/core/internal/runtimegateway/runtime_prepare_test.go +++ b/services/core/internal/runtimegateway/runtime_prepare_test.go @@ -17,7 +17,7 @@ import ( ) func skillPreparation() proto.RuntimePreparePayload { - return proto.RuntimePreparePayload{BudgetMS: 300000, EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "skill", + return proto.RuntimePreparePayload{BudgetMS: 300000, EnvironmentID: uuid.NewString(), Action: "skill", Skill: &proto.SkillMetadata{Type: "inline", Name: "example", Description: "Example"}} } @@ -83,7 +83,7 @@ func TestCapabilitiesTransfersMoreThanFrameLimitAndCorrelates(t *testing.T) { switch p.Step { case "begin": digest := sha256.Sum256(data) - if p.EnvironmentID != request.EnvironmentID || p.SessionID != request.SessionID || p.Skill == nil || *p.Skill != *request.Skill || p.SizeBytes != len(data) || p.SHA256 != hex.EncodeToString(digest[:]) { + if p.EnvironmentID != request.EnvironmentID || p.Skill == nil || *p.Skill != *request.Skill || p.SizeBytes != len(data) || p.SHA256 != hex.EncodeToString(digest[:]) { t.Fatal("identity, metadata or digest changed") } result.Outcome = "ready" @@ -115,7 +115,7 @@ func TestCapabilitiesTransfersMoreThanFrameLimitAndCorrelates(t *testing.T) { func TestCapabilitiesFinalizeTransfersNoArchive(t *testing.T) { s := newTestSession(newFakeConn(), "device", "test", nil, nil) defer s.Close("test") - request := proto.RuntimePreparePayload{BudgetMS: 300000, EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "finalize", Sources: &proto.CapabilitySources{}} + request := proto.RuntimePreparePayload{BudgetMS: 300000, EnvironmentID: uuid.NewString(), Action: "finalize", Sources: &proto.CapabilitySources{}} id := uuid.NewString() done := beginCapabilities(s, t.Context(), id, request, nil) env := nextCapabilityFrame(t, s) @@ -238,7 +238,7 @@ func TestRuntimeInitialFileChunking(t *testing.T) { t.Run(fmt.Sprint(size), func(t *testing.T) { s := newTestSession(newFakeConn(), "device", "test", nil, nil) defer s.Close("test") - request := proto.RuntimePreparePayload{BudgetMS: 300000, EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "file", File: &proto.RuntimeInitialFile{Path: "/workspace/project/file"}} + request := proto.RuntimePreparePayload{BudgetMS: 300000, EnvironmentID: uuid.NewString(), Action: "file", File: &proto.RuntimeInitialFile{Path: "/workspace/project/file"}} data := bytes.Repeat([]byte("z"), size) id := uuid.NewString() done := beginCapabilities(s, t.Context(), id, request, data) @@ -288,7 +288,7 @@ func TestRuntimeInitializationNoDataAndExitReceipt(t *testing.T) { t.Run(fmt.Sprint(exit), func(t *testing.T) { s := newTestSession(newFakeConn(), "device", "test", nil, nil) defer s.Close("test") - request := proto.RuntimePreparePayload{BudgetMS: 300000, EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "setup", Command: "echo test"}} + request := proto.RuntimePreparePayload{BudgetMS: 300000, EnvironmentID: 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") } @@ -333,7 +333,7 @@ func TestRuntimePreparationSeparatesTransferAndApplyBudgets(t *testing.T) { synctest.Test(t, func(t *testing.T) { s := newTestSession(newFakeConn(), "device", "test", nil, nil) defer s.Close("test") - request := proto.RuntimePreparePayload{BudgetMS: 300000, EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "configure"}} + request := proto.RuntimePreparePayload{BudgetMS: 300000, EnvironmentID: uuid.NewString(), Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "configure"}} id := uuid.NewString() done := beginCapabilities(s, t.Context(), id, request, nil) frame := nextCapabilityFrame(t, s) @@ -376,7 +376,7 @@ func TestRuntimePreparationCommittedApplyStopsWaitingOnDisconnectOrCallerDeadlin defer s.Close("test") ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) defer cancel() - request := proto.RuntimePreparePayload{BudgetMS: 300000, EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "configure"}} + request := proto.RuntimePreparePayload{BudgetMS: 300000, EnvironmentID: uuid.NewString(), Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "configure"}} id := uuid.NewString() done := beginCapabilities(s, ctx, id, request, nil) nextCapabilityFrame(t, s) diff --git a/services/core/internal/runtimegateway/workspace_write_test.go b/services/core/internal/runtimegateway/workspace_write_test.go index 99dab5b33..edaac8f28 100644 --- a/services/core/internal/runtimegateway/workspace_write_test.go +++ b/services/core/internal/runtimegateway/workspace_write_test.go @@ -17,7 +17,7 @@ func TestWorkspaceWriteChunksAndCorrelatesReceipt(t *testing.T) { defer s.Close("test") data := bytes.Repeat([]byte{0, 255, 3}, 400000) id := uuid.NewString() - request := proto.WorkspaceWritePayload{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Path: "file"} + request := proto.WorkspaceWritePayload{EnvironmentID: uuid.NewString(), Path: "file"} done := make(chan error, 1) go func() { result, err := s.WriteWorkspaceFile(t.Context(), id, request, data) @@ -37,7 +37,7 @@ func TestWorkspaceWriteChunksAndCorrelatesReceipt(t *testing.T) { switch p.Step { case "begin": digest := sha256.Sum256(data) - if p.EnvironmentID != request.EnvironmentID || p.SessionID != request.SessionID || p.SizeBytes != len(data) || p.SHA256 != hex.EncodeToString(digest[:]) { + if p.EnvironmentID != request.EnvironmentID || p.SizeBytes != len(data) || p.SHA256 != hex.EncodeToString(digest[:]) { t.Fatal("scope or digest changed") } result.Outcome = "ready" @@ -72,7 +72,7 @@ func TestWorkspaceWriteDoesNotCommitAfterLostObservation(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) done := make(chan error, 1) go func() { - _, err := s.WriteWorkspaceFile(ctx, uuid.NewString(), proto.WorkspaceWritePayload{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Path: "file"}, []byte("value")) + _, err := s.WriteWorkspaceFile(ctx, uuid.NewString(), proto.WorkspaceWritePayload{EnvironmentID: uuid.NewString(), Path: "file"}, []byte("value")) done <- err }() <-s.sendCh diff --git a/services/core/tests/integration/environment_initialization_test.go b/services/core/tests/integration/environment_initialization_test.go index 77c1261d9..313546f6b 100644 --- a/services/core/tests/integration/environment_initialization_test.go +++ b/services/core/tests/integration/environment_initialization_test.go @@ -90,7 +90,7 @@ func TestUserManagedPreparationUsesAuthenticatedRuntimeWithoutAllocation(t *test peer := &initializationPeer{unavailable: outcome == "unavailable"} peer.setRuntimeGateway(t, "ws"+strings.TrimPrefix(server.URL, "http"), registry) peer.apply = func(request proto.RuntimePreparePayload, data []byte) proto.RuntimePrepareResultPayload { - if request.EnvironmentID != environment.ID || request.SessionID != session.ID { + if request.EnvironmentID != environment.ID { t.Error("wrong authorization binding") } action := request.Action diff --git a/services/core/tests/integration/local_environment_file_write_test.go b/services/core/tests/integration/local_environment_file_write_test.go index 89d87e926..466220e0a 100644 --- a/services/core/tests/integration/local_environment_file_write_test.go +++ b/services/core/tests/integration/local_environment_file_write_test.go @@ -49,7 +49,7 @@ func TestLocalEnvironmentFileWriteOwnsMutationBeforeDispatch(t *testing.T) { done := startLocalWrite(ctx, w, environment) begin := h.read(proto.TypeWorkspaceWrite) var request proto.WorkspaceWritePayload - if begin.DecodePayload(&request) != nil || request.Step != "begin" || request.EnvironmentID != environment.ID || request.SessionID != h.session.ID || request.Path != "input" || request.SizeBytes != 3 { + if begin.DecodePayload(&request) != nil || request.Step != "begin" || request.EnvironmentID != environment.ID || request.Path != "input" || request.SizeBytes != 3 { t.Fatal("upload identity changed") } intent, err := FixtureFileWrite(t.Context(), h.s.pool, h.tenant, environment.ID, begin.ID) diff --git a/services/core/tests/integration/runtime_initialization_test.go b/services/core/tests/integration/runtime_initialization_test.go index 65aabfaca..be39270a9 100644 --- a/services/core/tests/integration/runtime_initialization_test.go +++ b/services/core/tests/integration/runtime_initialization_test.go @@ -172,7 +172,7 @@ func TestManagedRuntimePreparationAllOperationsUsePeer(t *testing.T) { var actions []string var actionsMu sync.Mutex provider.apply = func(request proto.RuntimePreparePayload, data []byte) proto.RuntimePrepareResultPayload { - if request.SessionID != session.ID || request.EnvironmentID != environment.ID { + if request.EnvironmentID != environment.ID { t.Error("Runtime identity changed") } action := request.Action From af991b808d8c575224043445d29ebc8dd49d308c Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 09:07:48 +0000 Subject: [PATCH 3/4] Limit the one-Session rule to Runtimes bound to an Environment An unbound Runtime, such as the service-side host for environment: none, serves Sessions concurrently again: restore the executor capacity limits, idle eviction and session_binding_conflict with their tests. --- apps/daemon/internal/dispatch/executor.go | 88 +++++++++++++------ .../internal/dispatch/executor_switch_test.go | 51 +++++++++++ .../daemon/internal/dispatch/executor_test.go | 55 +++++++++--- .../internal/dispatch/functions_test.go | 7 +- apps/daemon/internal/dispatch/router_test.go | 27 +++++- .../daemon/internal/dispatch/steering_test.go | 41 +++++++++ docs/runtime-protocol.md | 4 +- docs/zh/runtime-protocol.md | 6 +- 8 files changed, 232 insertions(+), 47 deletions(-) create mode 100644 apps/daemon/internal/dispatch/executor_switch_test.go diff --git a/apps/daemon/internal/dispatch/executor.go b/apps/daemon/internal/dispatch/executor.go index b04d9ba34..de315a1f4 100644 --- a/apps/daemon/internal/dispatch/executor.go +++ b/apps/daemon/internal/dispatch/executor.go @@ -13,24 +13,26 @@ import ( "github.com/google/uuid" ) +const executorIdleCapacity = 16 + type executorState struct { - capabilities proto.AgentKindCapabilities - id, sessionID, environmentID string - fingerprint [32]byte - native agent.Executor - nativeID string - ctx context.Context - cancel context.CancelFunc - turn proto.TurnSettings - admission *preparationState - run *sessionState - preparing bool - invalid bool - closeDone chan struct{} - closeReason string - closeErr error - timer *time.Timer - idleLease uint64 + capabilities proto.AgentKindCapabilities + id, sessionID, environmentID, stateKey string + fingerprint [32]byte + native agent.Executor + nativeID string + ctx context.Context + cancel context.CancelFunc + turn proto.TurnSettings + admission *preparationState + run *sessionState + preparing bool + invalid bool + closeDone chan struct{} + closeReason string + closeErr error + timer *time.Timer + idleLease uint64 } // executorFingerprint covers the configuration a retained Executor fixes. The @@ -101,11 +103,25 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, r.mu.Unlock() return r.rejectPreparation(env, "preparation_capacity") } - for _, owner := range r.executors { - if owner.sessionID != input.SessionID { + active := 0 + for _, candidate := range r.executors { + if r.localWorkspace != nil && candidate.sessionID != input.SessionID { r.mu.Unlock() return r.rejectPreparation(env, "resource_unavailable") } + if candidate.sessionID != input.SessionID && candidate.stateKey == req.AgentStateKey { + r.mu.Unlock() + return r.rejectPreparation(env, "session_binding_conflict") + } + } + for _, owner := range r.executors { + if owner.preparing || owner.admission != nil || owner.run != nil || owner.invalid { + active++ + } + } + if active >= preparationCapacity { + r.mu.Unlock() + return r.rejectPreparation(env, "preparation_capacity") } owner := r.executors[input.SessionID] reused := owner != nil @@ -133,8 +149,12 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, owner.idleLease++ owner.turn = req.Turn } else { + if len(r.executors) >= executorIdleCapacity+preparationCapacity { + r.mu.Unlock() + return r.rejectPreparation(env, "executor_capacity") + } ownerCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) - owner = &executorState{capabilities: caps, id: uuid.NewString(), sessionID: input.SessionID, environmentID: req.EnvironmentID(), fingerprint: fingerprint, turn: req.Turn, ctx: ownerCtx, cancel: cancel, preparing: true, nativeID: req.AgentSessionID} + owner = &executorState{capabilities: caps, id: uuid.NewString(), sessionID: input.SessionID, environmentID: req.EnvironmentID(), stateKey: req.AgentStateKey, fingerprint: fingerprint, turn: req.Turn, ctx: ownerCtx, cancel: cancel, preparing: true, nativeID: req.AgentSessionID} r.executors[input.SessionID] = owner r.log.Info("executor owner_created", "executor_id", owner.id, "session_id", owner.sessionID) } @@ -161,12 +181,17 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, return nil } -// switchSession makes the Runtime serve sessionID alone before a new executor -// is created. Other Sessions' executors that hold no work are closed, and only -// then is every other Session's native state removed. That includes state whose -// executor already expired or whose process restarted. It returns a rejection -// code when a Session still holds work or a close is unconfirmed. +// switchSession makes a Runtime bound to an Environment serve sessionID alone +// before a new executor is created; an unbound Runtime serves Sessions +// concurrently and is left alone. Other Sessions' executors that hold no work +// are closed, and only then is every other Session's native state removed. That +// includes state whose executor already expired or whose process restarted. It +// returns a rejection code when a Session still holds work or a close is +// unconfirmed. func (r *Router) switchSession(sessionID string) string { + if r.localWorkspace == nil { + return "" + } var others []*executorState r.mu.Lock() if r.executors[sessionID] != nil && len(r.executors) == 1 { @@ -287,6 +312,19 @@ func (r *Router) scheduleExecutorIdleLocked(owner *executorState) { if owner.timer != nil { owner.timer.Stop() } + idle := 0 + for _, candidate := range r.executors { + if candidate != owner && candidate.run == nil && candidate.admission == nil && !candidate.preparing { + idle++ + } + } + if idle >= executorIdleCapacity { + owner.invalid = true + owner.closeReason = "idle_capacity" + r.shutdownWG.Add(1) + go func() { defer r.shutdownWG.Done(); _ = r.closeExecutor(owner) }() + return + } owner.idleLease++ lease := owner.idleLease r.log.Info("executor owner_idle", "executor_id", owner.id, "session_id", owner.sessionID) diff --git a/apps/daemon/internal/dispatch/executor_switch_test.go b/apps/daemon/internal/dispatch/executor_switch_test.go new file mode 100644 index 000000000..87fa7e661 --- /dev/null +++ b/apps/daemon/internal/dispatch/executor_switch_test.go @@ -0,0 +1,51 @@ +package dispatch_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +func TestBoundRuntimeSwitchesSessionOnlyWhenOtherSessionIsIdle(t *testing.T) { + var made []*controlledPreparation + sender := &recSender{} + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (preparedFixture, error) { + p := &controlledPreparation{closed: make(chan struct{})} + made = append(made, p) + return p, nil + }) + root := os.Getenv("OAC_RUNTIME_HOME") + first, next := preparationRequest(), preparationRequest() + next.SessionID = uuid.NewString() + next.Configuration.AgentStateKey = next.SessionID + ready := executorAdmission(t, r, sender, "first", first) + var dirs [2]string + for i, id := range []string{first.SessionID, next.SessionID} { + dir, err := agent.StateDir(root, "codex", id) + if err != nil || os.MkdirAll(dir, 0o700) != nil { + t.Fatal(err) + } + dirs[i] = dir + } + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "busy", next)) + if got := waitPreparationStatus(t, sender, "busy", "rejected", ""); got.ErrorCode != "resource_unavailable" || len(made) != 1 { + t.Fatalf("admitted Session was displaced: %+v", got) + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "first", proto.ExecutionReleasePayload{Handle: ready.Handle})); err != nil { + t.Fatal(err) + } + waitPreparationStatus(t, sender, "first", "released", "") + executorAdmission(t, r, sender, "switch", next) + waitPreparationClosed(t, made[0]) + if _, err := os.Stat(dirs[0]); !os.IsNotExist(err) { + t.Fatal("old Session state kept") + } + if _, err := os.Stat(dirs[1]); err != nil { + t.Fatal("new Session state removed") + } +} diff --git a/apps/daemon/internal/dispatch/executor_test.go b/apps/daemon/internal/dispatch/executor_test.go index e162be265..26928a647 100644 --- a/apps/daemon/internal/dispatch/executor_test.go +++ b/apps/daemon/internal/dispatch/executor_test.go @@ -5,6 +5,7 @@ import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" import ( "context" "errors" + "fmt" "sync" "sync/atomic" "testing" @@ -296,22 +297,48 @@ func TestExecutorRejectsSessionStateScopeMismatch(t *testing.T) { } } -func TestExecutorSwitchesSessionOnlyWhenOtherSessionIsIdle(t *testing.T) { - owner := &reusableExecutor{starts: make(chan *reusableTurn, 1)} - r, s, calls := executorRouter(t, owner, time.Minute) - first := executorAdmission(t, r, s, "first", executorRequest()) - next := noEnvironmentPreparation("next", proto.PromptRequestPayload{AgentKind: "reusable"}) - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "busy", next)) - if got := waitPreparationStatus(t, s, "busy", "rejected", ""); got.ErrorCode != "resource_unavailable" || owner.closes.Load() != 0 || calls.Load() != 1 { - t.Fatalf("admitted Session was displaced: %+v closes=%d", got, owner.closes.Load()) +func TestExecutorIdleAndActiveCapacitiesAreIndependent(t *testing.T) { + var mu sync.Mutex + var owners []*reusableExecutor + r, s := poolRouter(t, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + e := &reusableExecutor{} + mu.Lock() + owners = append(owners, e) + mu.Unlock() + return e, nil + }) + for i := 0; i < 17; i++ { + id := fmt.Sprint("idle-", i) + ready := executorAdmission(t, r, s, id, poolRequest(id)) + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, id, proto.ExecutionReleasePayload{Handle: ready.Handle})); err != nil { + t.Fatal(err) + } } - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "first", proto.ExecutionReleasePayload{Handle: first.Handle})); err != nil { - t.Fatal(err) + waitFor(t, func() bool { + mu.Lock() + defer mu.Unlock() + var closed int32 + for _, e := range owners { + closed += e.closes.Load() + } + return closed == 1 + }, "idle capacity eviction") + for i := 0; i < 4; i++ { + id := fmt.Sprint("active-", i) + executorAdmission(t, r, s, id, poolRequest(id)) + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "overflow", poolRequest("overflow"))); err == nil { + t.Fatal("active capacity exceeded") } - waitPreparationStatus(t, s, "first", "released", "") - executorAdmission(t, r, s, "switch", next) - if owner.closes.Load() != 1 || calls.Load() != 2 { - t.Fatalf("switch closes=%d prepares=%d, want 1 and 2", owner.closes.Load(), calls.Load()) +} + +func TestUnboundRuntimeKeepsSessionsSideBySide(t *testing.T) { + owner := &reusableExecutor{} + r, s, calls := executorRouter(t, owner, time.Minute) + executorAdmission(t, r, s, "first", executorRequest()) + executorAdmission(t, r, s, "second", noEnvironmentPreparation("next", proto.PromptRequestPayload{AgentKind: "reusable"})) + if owner.closes.Load() != 0 || calls.Load() != 2 { + t.Fatalf("unbound Runtime closes=%d prepares=%d, want 0 and 2", owner.closes.Load(), calls.Load()) } } func TestExecutorRejectsUnsupportedConfigurationBeforeFactory(t *testing.T) { diff --git a/apps/daemon/internal/dispatch/functions_test.go b/apps/daemon/internal/dispatch/functions_test.go index ff3be723a..db0bc5d56 100644 --- a/apps/daemon/internal/dispatch/functions_test.go +++ b/apps/daemon/internal/dispatch/functions_test.go @@ -49,8 +49,8 @@ func TestFunctionReceiptsScopeRetriesAndConflicts(t *testing.T) { t.Fatal(err) } defer router.Shutdown(context.Background()) - { - startRun(t, router, sender, "one", proto.PromptRequestPayload{AgentKind: "function-test", FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}}) + for _, id := range []string{"one", "two"} { + startRun(t, router, sender, id, proto.PromptRequestPayload{AgentKind: "function-test", FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{}`)}}}) } submit := func(run, call, text, delivery string) proto.InteractionDecisionAckPayload { t.Helper() @@ -120,6 +120,9 @@ func TestFunctionReceiptsScopeRetriesAndConflicts(t *testing.T) { t.Fatal(mutation, ack) } } + if a := submit("two", "call", "second answer", "other-run"); !a.Applied { + t.Fatal(a) + } for _, s := range sessions { s.mu.Lock() count := s.calls diff --git a/apps/daemon/internal/dispatch/router_test.go b/apps/daemon/internal/dispatch/router_test.go index 8603046e0..607e95535 100644 --- a/apps/daemon/internal/dispatch/router_test.go +++ b/apps/daemon/internal/dispatch/router_test.go @@ -136,7 +136,7 @@ func sessionExecutor(start func(context.Context, string, proto.MessageInput, cha // "input" and waits until it accepts operations. func startRun(t *testing.T, r *dispatch.Router, s *recSender, run string, config proto.PromptRequestPayload) { t.Helper() - ready := executorAdmission(t, r, s, "prepare-"+run, noEnvironmentPreparation("session", config)) + ready := executorAdmission(t, r, s, "prepare-"+run, noEnvironmentPreparation("session-"+run, config)) startExecutorTurn(t, r, s, "prepare-"+run, run, ready) waitFor(t, func() bool { return r.RunStartedForTest(run) }, "run "+run+" to accept operations") } @@ -175,6 +175,31 @@ func TestExecutionStartRunsInputAndForwardsOutput(t *testing.T) { waitFor(t, func() bool { return h.router.ActiveRuns() == 0 }, "active runs to drop to 0") } +func TestExecutionStartRejectsDuplicateRunID(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + + startRun(t, h.router, h.sender, "run_dup", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) + sess := <-h.gotSess + + // Another Executor must not start a second Turn for the same RunID. + second := executorAdmission(t, h.router, h.sender, "prepare-dup", noEnvironmentPreparation("session-dup", proto.PromptRequestPayload{AgentKind: "fake_alpha"})) + start := mustEnv(t, proto.TypeExecutionStart, "prepare-dup", proto.ExecutionStartPayload{Handle: second.Handle, ExecutorID: second.ExecutorID, RunID: "run_dup", Input: proto.TextInput("input")}) + if err := h.router.Handle(context.Background(), start); err == nil { + t.Fatal("duplicate Run started") + } + if status := waitPreparationStatus(t, h.sender, "prepare-dup", "rejected", ""); status.ErrorCode != "run_conflict" { + t.Fatalf("duplicate start status = %+v, want run_conflict", status) + } + + select { + case extra := <-h.gotSess: + t.Fatalf("Turn started twice for duplicate run, second run=%s", extra.runID) + case <-time.After(50 * time.Millisecond): + } + close(sess.out) +} + func TestExecutionPrepareRejectsUnknownKind(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) diff --git a/apps/daemon/internal/dispatch/steering_test.go b/apps/daemon/internal/dispatch/steering_test.go index 16235424e..cc327a2d6 100644 --- a/apps/daemon/internal/dispatch/steering_test.go +++ b/apps/daemon/internal/dispatch/steering_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "testing" + "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -155,6 +156,46 @@ func TestSteeringReadinessAndUnsupportedRuns(t *testing.T) { } } +func TestSteeringDoesNotBlockOtherRunCancellation(t *testing.T) { + h := newHarness(t) + defer h.router.Shutdown(context.Background()) + entered, release := make(chan struct{}), make(chan struct{}) + defer close(release) + registerExecutorKind(h.reg, proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, DurableInputReceipts: proto.CapabilitySupported})}, sessionExecutor(func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { + return &steeringSession{ + fakeSession: &fakeSession{out: out, closeOutOnCancel: true}, + steer: func(context.Context, proto.PromptSteerPayload) error { + close(entered) + <-release + return agent.ErrSteeringRejected + }, + }, nil + })) + ctx := context.Background() + for _, run := range []struct{ id, engine string }{{"run-1", "codex"}, {"run-2", "fake_alpha"}} { + startRun(t, h.router, h.sender, run.id, proto.PromptRequestPayload{AgentKind: run.engine}) + } + other := <-h.gotSess + other.closeOutOnCancel = true + returned := make(chan error, 1) + go func() { + returned <- h.router.Handle(ctx, mustEnv(t, proto.TypePromptSteer, "run-1", proto.PromptSteerPayload{InputID: "slow", Input: proto.TextInput("extra")})) + }() + select { + case err := <-returned: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("steering blocked dispatch") + } + <-entered + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptCancel, "run-2", nil)); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { return other.cancels() == 1 }, "other run cancellation") +} + func lastSteeringAck(t *testing.T, sender *recSender, runID, inputID string) proto.PromptSteerAckPayload { t.Helper() frames := sender.snapshot() diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index a3352596b..b83af7350 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -91,7 +91,7 @@ Every data frame is one JSON [`Envelope`](https://github.com/MiniMax-AI/OpenAgen | --- | --- | | Device ID and connection | Authenticated Runtime routing and connection ownership | | Environment ID | The Environment and workspace a Runtime is bound to for its lifetime; a canonical UUID in `runtime_prepare` and `workspace_write` | -| Session ID | Core-owned identity of the configuration in one `execution_prepare`; the Runtime serves one Session at a time | +| Session ID | Core-owned identity of the configuration in one `execution_prepare`; a Runtime bound to an Environment serves one Session at a time | | Executor ID | Runtime-owned native resource, possibly retained across settled Turns with identical configuration, except the `turn` settings its Harness declares changeable | | Preparation request ID | `Envelope.id` for prepare, start, release and status; distinct from a Run | | Admission handle | Runtime-generated reservation, valid only on the connection that accepted it | @@ -209,7 +209,7 @@ stateDiagram-v2 ready --> failed ``` -One Session at a time: a Runtime serves a single Session, and each `execution_prepare` names it. If an Executor of another Session is preparing, admitted or running, the prepare is rejected with the retryable `resource_unavailable`. If it is idle, the Runtime closes it, waits for the confirmed close, removes the native state of every other Session and then prepares the new Session. A read-only workspace preparation starts no Harness and never switches Sessions. +One Session at a time: a Runtime bound to an Environment serves a single Session, and each `execution_prepare` names it. A Runtime without a bound Environment, such as the service-side host for `environment: none` Sessions, serves Sessions concurrently within its capacity and never switches. If an Executor of another Session is preparing, admitted or running, the prepare is rejected with the retryable `resource_unavailable`. If it is idle, the Runtime closes it, waits for the confirmed close, removes the native state of every other Session and then prepares the new Session. A read-only workspace preparation starts no Harness and never switches Sessions. Preparation and start run outside the receive loop and router lock. An admission expires at a fixed deadline that retries do not extend; expiry does not remove the Runtime's obligation to settle cleanup. The Runtime bounds active preparation and execution separately from idle retained resources and counts closing or uncertain resources until their cleanup succeeds. A definite `execution_prepare` rejection with `preparation_capacity` leaves the queued Turn unclaimed for the Worker to retry, including when cleanup holds the capacity; any other error or uncertain delivery authorizes no replay. The Runtime retains a bounded number of admission records, and an old handle never consumes a replacement's admission. These records are connection-local, not durable input replay. diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index 88289d91e..e4222142a 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: 089f67fdb36cfd4778418684a5d0eec4b7425bc386e7d224c6cc382be35ee003 +source_hash: b17c514575d105856306c3dd89340c29efd23513e328127abc5ff5b1b69df20f --- Core–Runtime 协议连接 Core 与 Runtime daemon,定义 daemon 连接上消息的含义和顺序。托管和自托管 Runtime 使用同一协议。 @@ -93,7 +93,7 @@ Core 对 `usage` 和 `resume` 没有准入规则。 | --- | --- | | Device ID 和连接 | 经认证的 Runtime 路由与连接所有权 | | Environment ID | Runtime 在其生命周期内绑定的 Environment 与工作区;`runtime_prepare` 和 `workspace_write` 中为规范 UUID | -| Session ID | Core 拥有的、一次 `execution_prepare` 中配置的身份;Runtime 一次只服务一个 Session | +| Session ID | Core 拥有的、一次 `execution_prepare` 中配置的身份;绑定了 Environment 的 Runtime 一次只服务一个 Session | | Executor ID | Runtime 拥有的原生资源,可在配置相同的已结算 Turn 之间保留,其 Harness 声明可变的 `turn` 设置除外 | | Preparation request ID | prepare、start、release 和 status 的 `Envelope.id`;与 Run 不同 | | Admission handle | Runtime 生成的预约,仅在接受它的连接上有效 | @@ -211,7 +211,7 @@ stateDiagram-v2 ready --> failed ``` -一次只服务一个 Session:Runtime 只服务一个 Session,每个 `execution_prepare` 指明该 Session。若另一个 Session 的 Executor 正在 preparing、已 admitted 或正在运行,该 prepare 以可重试的 `resource_unavailable` 拒绝。若它空闲,Runtime 先关闭它,等待确认关闭,删除其他所有 Session 的原生状态,再 prepare 新 Session。只读工作区 preparation 不启动 Harness,也从不切换 Session。 +一次只服务一个 Session:绑定了 Environment 的 Runtime 只服务一个 Session,每个 `execution_prepare` 指明该 Session。未绑定 Environment 的 Runtime,例如 `environment: none` Session 的服务端宿主,在其容量内并发服务多个 Session,从不切换。若另一个 Session 的 Executor 正在 preparing、已 admitted 或正在运行,该 prepare 以可重试的 `resource_unavailable` 拒绝。若它空闲,Runtime 先关闭它,等待确认关闭,删除其他所有 Session 的原生状态,再 prepare 新 Session。只读工作区 preparation 不启动 Harness,也从不切换 Session。 preparation 和 start 在 receive loop 与 router lock 之外运行。admission 在固定截止时间到期,重试不延长该时间;到期不解除 Runtime 完成清理结算的义务。Runtime 分别限制活动 preparation、execution 和保留的空闲资源,关闭中或不确定资源持续计入限制,直到清理成功。明确的 `execution_prepare` 拒绝若为 `preparation_capacity`,会让排队 Turn 保持未领取,供 Worker 重试,包括清理占用容量的情况;其他错误或不确定交付都不授权重放。Runtime 保留有限数量的 admission 记录,旧 handle 不会消耗替代项的 admission。这些记录仅属于连接,不是持久化输入重放。 From 9d1e5fa215c529391364273960ab401d19ab73e5 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 09:33:51 +0000 Subject: [PATCH 4/4] Switch Sessions inside the new Executor's preparation Run every non-destructive admission check before closing another Session, close and remove state outside the receive loop, ignore the enrollment receipt's Session, require a canonical Session key on bound Runtimes, skip stray files under the runtime directory, and fix stale docs. --- apps/daemon/internal/agent/runtime_paths.go | 5 +- .../internal/agent/runtime_paths_test.go | 3 + .../cli/connect_environment_binding.go | 1 + .../cli/connect_environment_binding_test.go | 7 +- apps/daemon/internal/dispatch/executor.go | 83 +++++++------------ .../daemon/internal/dispatch/executor_test.go | 29 +++---- .../daemon/internal/localworkspace/binding.go | 4 +- .../internal/localworkspace/binding_test.go | 26 +----- .../internal/localworkspace/capabilities.go | 2 +- .../internal/localworkspace/native_binding.go | 7 +- docs/runtime-bootstrap.md | 2 +- docs/runtime-protocol.md | 2 +- docs/zh/runtime-bootstrap.md | 4 +- docs/zh/runtime-protocol.md | 4 +- internal/agentcapabilities/install.go | 2 +- internal/runtimebootstrap/bootstrap.go | 3 +- services/core/deploy/e2b/README.md | 2 +- 17 files changed, 77 insertions(+), 109 deletions(-) diff --git a/apps/daemon/internal/agent/runtime_paths.go b/apps/daemon/internal/agent/runtime_paths.go index c1fc2f06f..004c3d8dd 100644 --- a/apps/daemon/internal/agent/runtime_paths.go +++ b/apps/daemon/internal/agent/runtime_paths.go @@ -56,7 +56,7 @@ func safeRuntimePathPart(value string) string { // Sessions, so it also covers state left by earlier processes and checkpoints. func RemoveSessionState(stateRoot, runtimeRoot, keepKey string) error { keep := safeRuntimePathParts(keepKey) - if len(keep) == 0 { + if len(keep) != 1 { return fmt.Errorf("agent: invalid agent state key %q", keepKey) } for _, root := range []string{stateRoot, runtimeRoot} { @@ -68,6 +68,9 @@ func RemoveSessionState(stateRoot, runtimeRoot, keepKey string) error { return err } for _, kind := range kinds { + if !kind.IsDir() { + continue + } states := filepath.Join(root, "runtime", kind.Name(), "state") entries, err := os.ReadDir(states) if err != nil && !os.IsNotExist(err) { diff --git a/apps/daemon/internal/agent/runtime_paths_test.go b/apps/daemon/internal/agent/runtime_paths_test.go index 130d9b22f..3e3f9e93b 100644 --- a/apps/daemon/internal/agent/runtime_paths_test.go +++ b/apps/daemon/internal/agent/runtime_paths_test.go @@ -51,6 +51,9 @@ func TestRemoveSessionStateKeepsOnlyTheNamedKey(t *testing.T) { kept, removed = append(kept, keep), append(removed, other) } } + if err := os.WriteFile(filepath.Join(stateRoot, "runtime", ".DS_Store"), nil, 0o600); err != nil { + t.Fatal(err) + } if err := RemoveSessionState(stateRoot, runtimeRoot, "keep"); err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/cli/connect_environment_binding.go b/apps/daemon/internal/cli/connect_environment_binding.go index 17657f053..ce8459356 100644 --- a/apps/daemon/internal/cli/connect_environment_binding.go +++ b/apps/daemon/internal/cli/connect_environment_binding.go @@ -65,6 +65,7 @@ func bindEnvironmentRuntime(remote string, bound machineapi.Enrollment, workspac if err := localworkspace.Bind(bound.EnvironmentID, workspace, capabilityDirectory); err != nil { return errors.New("connect: local Runtime layout unavailable") } + bound.SessionID = "" // The Runtime binds to its Environment; the receipt's Session is not part of it. return saveEnvironmentBinding(root, environmentBinding{RemoteURL: remote, Enrollment: bound, LocalWorkspace: workspace, CapabilityDirectory: capabilityDirectory}) } diff --git a/apps/daemon/internal/cli/connect_environment_binding_test.go b/apps/daemon/internal/cli/connect_environment_binding_test.go index df7b7b4cd..3a69e8b09 100644 --- a/apps/daemon/internal/cli/connect_environment_binding_test.go +++ b/apps/daemon/internal/cli/connect_environment_binding_test.go @@ -44,11 +44,14 @@ func TestBindEnvironmentRuntimeUsesCanonicalOperatorWorkspace(t *testing.T) { t.Fatal(err) } var receipt environmentBinding - if decodeEnvironmentJSON(raw, &receipt) != nil || receipt.LocalWorkspace != workspace || receipt.Enrollment != bound { + want := bound + want.SessionID = "" + if decodeEnvironmentJSON(raw, &receipt) != nil || receipt.LocalWorkspace != workspace || receipt.Enrollment != want { t.Fatal("workspace identity changed", receipt) } + bound.SessionID = uuid.NewString() if err := bindEnvironmentRuntime(receipt.RemoteURL, bound, workspace, localworkspace.CapabilityDirectory); err != nil { - t.Fatal("same binding refused", err) + t.Fatal("same Environment refused for another Session", err) } replacement := filepath.Join(filepath.Dir(workspace), "replacement") if err := os.Mkdir(replacement, 0700); err != nil { diff --git a/apps/daemon/internal/dispatch/executor.go b/apps/daemon/internal/dispatch/executor.go index de315a1f4..33631f75c 100644 --- a/apps/daemon/internal/dispatch/executor.go +++ b/apps/daemon/internal/dispatch/executor.go @@ -77,9 +77,6 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, return r.rejectPreparation(env, "invalid_configuration") } requestFingerprint := sha256.Sum256(encoded) - if code := r.switchSession(input.SessionID); code != "" { - return r.rejectPreparation(env, code) - } r.mu.Lock() if r.closed || r.suspension != nil { r.mu.Unlock() @@ -104,12 +101,18 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, return r.rejectPreparation(env, "preparation_capacity") } active := 0 + // A Runtime bound to an Environment serves one Session at a time; the new + // executor closes the other Sessions' idle executors before it prepares. + var others []*executorState for _, candidate := range r.executors { - if r.localWorkspace != nil && candidate.sessionID != input.SessionID { + switch { + case candidate.sessionID == input.SessionID: + case r.localWorkspace != nil && (candidate.preparing || candidate.admission != nil || candidate.run != nil): r.mu.Unlock() return r.rejectPreparation(env, "resource_unavailable") - } - if candidate.sessionID != input.SessionID && candidate.stateKey == req.AgentStateKey { + case r.localWorkspace != nil: + others = append(others, candidate) + case candidate.stateKey == req.AgentStateKey: r.mu.Unlock() return r.rejectPreparation(env, "session_binding_conflict") } @@ -176,51 +179,15 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, if reused { r.publishPreparation(p, status) } else { - go r.prepareExecutor(p, req, factory) + go r.prepareExecutor(p, req, factory, others) } return nil } -// switchSession makes a Runtime bound to an Environment serve sessionID alone -// before a new executor is created; an unbound Runtime serves Sessions -// concurrently and is left alone. Other Sessions' executors that hold no work -// are closed, and only then is every other Session's native state removed. That -// includes state whose executor already expired or whose process restarted. It -// returns a rejection code when a Session still holds work or a close is -// unconfirmed. -func (r *Router) switchSession(sessionID string) string { - if r.localWorkspace == nil { - return "" - } - var others []*executorState - r.mu.Lock() - if r.executors[sessionID] != nil && len(r.executors) == 1 { - r.mu.Unlock() - return "" - } - for _, owner := range r.executors { - if owner.sessionID == sessionID { - continue - } - if owner.preparing || owner.admission != nil || owner.run != nil { - r.mu.Unlock() - return "resource_unavailable" - } - others = append(others, owner) - } - r.mu.Unlock() - for _, owner := range others { - if r.closeExecutor(owner) != nil { - return "executor_cleanup_unconfirmed" - } - } - if r.localWorkspace.RemoveOtherSessionState(sessionID) != nil { - return "executor_cleanup_unconfirmed" - } - return "" -} - -func (r *Router) prepareExecutor(p *preparationState, req proto.PromptRequestPayload, factory agent.ExecutorFactory) { +// prepareExecutor confirms the close of the other Sessions' executors and +// removes their native state, which also covers state left by expired +// executors and earlier processes, before it prepares the new executor. +func (r *Router) prepareExecutor(p *preparationState, req proto.PromptRequestPayload, factory agent.ExecutorFactory, others []*executorState) { defer r.shutdownWG.Done() owner := p.executor started := time.Now() @@ -230,15 +197,21 @@ func (r *Router) prepareExecutor(p *preparationState, req proto.PromptRequestPay if !r.sendPreparation(p.requestID, p.trace, initial) { r.abandonExecutorAdmission(p, "failed", "status_delivery_failed", false) } - var native agent.Executor - var err error - if owner.ctx.Err() == nil { - req, err = r.localWorkspace.Prepare(owner.ctx, req) - if err == nil && owner.ctx.Err() == nil { - native, err = factory(owner.ctx, req) + err := owner.ctx.Err() + for _, other := range others { + if err == nil { + err = r.closeExecutor(other) } - } else { - err = owner.ctx.Err() + } + if err == nil { + err = r.localWorkspace.RemoveOtherSessionState(req.AgentStateKey) + } + if err == nil { + req, err = r.localWorkspace.Prepare(owner.ctx, req) + } + var native agent.Executor + if err == nil && owner.ctx.Err() == nil { + native, err = factory(owner.ctx, req) } r.mu.Lock() owner.native, owner.preparing = native, false diff --git a/apps/daemon/internal/dispatch/executor_test.go b/apps/daemon/internal/dispatch/executor_test.go index 26928a647..3a479074b 100644 --- a/apps/daemon/internal/dispatch/executor_test.go +++ b/apps/daemon/internal/dispatch/executor_test.go @@ -283,20 +283,6 @@ func poolRequest(id string) proto.ExecutionPreparePayload { return req } -func TestExecutorRejectsSessionStateScopeMismatch(t *testing.T) { - e := &reusableExecutor{} - r, s, calls := executorRouter(t, e, time.Minute) - executorAdmission(t, r, s, "one", executorRequest()) - req := executorRequest() - req.SessionID = "another" - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "scope", req)); err == nil { - t.Fatal("two sessions shared native state") - } - if calls.Load() != 1 { - t.Fatal("mismatched session invoked native preparation") - } -} - func TestExecutorIdleAndActiveCapacitiesAreIndependent(t *testing.T) { var mu sync.Mutex var owners []*reusableExecutor @@ -332,6 +318,20 @@ func TestExecutorIdleAndActiveCapacitiesAreIndependent(t *testing.T) { } } +func TestExecutorRejectsSessionStateScopeMismatch(t *testing.T) { + e := &reusableExecutor{} + r, s, calls := executorRouter(t, e, time.Minute) + executorAdmission(t, r, s, "one", executorRequest()) + req := executorRequest() + req.SessionID = "another" + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "scope", req)); err == nil { + t.Fatal("two sessions shared native state") + } + if calls.Load() != 1 { + t.Fatal("mismatched session invoked native preparation") + } +} + func TestUnboundRuntimeKeepsSessionsSideBySide(t *testing.T) { owner := &reusableExecutor{} r, s, calls := executorRouter(t, owner, time.Minute) @@ -341,6 +341,7 @@ func TestUnboundRuntimeKeepsSessionsSideBySide(t *testing.T) { t.Fatalf("unbound Runtime closes=%d prepares=%d, want 0 and 2", owner.closes.Load(), calls.Load()) } } + func TestExecutorRejectsUnsupportedConfigurationBeforeFactory(t *testing.T) { for name, change := range map[string]func(*proto.PromptRequestPayload){ "no environment": func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = false }, diff --git a/apps/daemon/internal/localworkspace/binding.go b/apps/daemon/internal/localworkspace/binding.go index cc55eb92b..5511d9af5 100644 --- a/apps/daemon/internal/localworkspace/binding.go +++ b/apps/daemon/internal/localworkspace/binding.go @@ -2,9 +2,9 @@ package localworkspace import ( "errors" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "sync" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" @@ -71,7 +71,7 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa if b == nil && r.LocalEnvironment == nil { return r, nil } - if b == nil || r.LocalEnvironment == nil || r.LocalEnvironment.ID != b.environment || + if b == nil || r.LocalEnvironment == nil || r.LocalEnvironment.ID != b.environment || !canonicalID(r.AgentStateKey) || r.DisableExecutionEnvironment { return r, errors.New("request does not match the dedicated local Environment") } diff --git a/apps/daemon/internal/localworkspace/binding_test.go b/apps/daemon/internal/localworkspace/binding_test.go index c93d0b34b..72c4ff9cc 100644 --- a/apps/daemon/internal/localworkspace/binding_test.go +++ b/apps/daemon/internal/localworkspace/binding_test.go @@ -6,7 +6,6 @@ import ( "path/filepath" "testing" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/google/uuid" @@ -39,7 +38,8 @@ func TestBindingRejectsScopeOverrides(t *testing.T) { "other Environment": func(r *proto.PromptRequestPayload) { r.LocalEnvironment = &proto.LocalEnvironment{ID: uuid.NewString()} }, - "none": func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = true }, + "non-canonical Session": func(r *proto.PromptRequestPayload) { r.AgentStateKey = "x y" }, + "none": func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment = true }, } { t.Run(name, func(t *testing.T) { r := valid @@ -71,28 +71,6 @@ func TestBindingServesSessionsInSequence(t *testing.T) { } } -func TestBindingRemovesOtherSessionState(t *testing.T) { - b, _ := testBinding(t) - keep, other := uuid.NewString(), uuid.NewString() - var dirs []string - for _, key := range []string{keep, other} { - dir, err := agent.StateDir(b.runtimeRoot, "codex", key) - if err != nil || os.MkdirAll(dir, 0o700) != nil { - t.Fatal(err) - } - dirs = append(dirs, dir) - } - if err := b.RemoveOtherSessionState(keep); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(dirs[0]); err != nil { - t.Fatal("kept Session state removed") - } - if _, err := os.Stat(dirs[1]); !os.IsNotExist(err) { - t.Fatal("other Session state kept") - } -} - func TestDirectoryValidatesRelativePaths(t *testing.T) { b, _ := testBinding(t) got, err := b.ListWorkspaceDirectory(t.Context(), "", 2) diff --git a/apps/daemon/internal/localworkspace/capabilities.go b/apps/daemon/internal/localworkspace/capabilities.go index 456f97ab1..567dcd981 100644 --- a/apps/daemon/internal/localworkspace/capabilities.go +++ b/apps/daemon/internal/localworkspace/capabilities.go @@ -151,7 +151,7 @@ func (b *Binding) loadCapabilitySnapshot(root *os.Root, input proto.CapabilitySo } manifest, err := agentcapabilities.Load(root) if err == nil && !completed && manifest.Identity != identity { - // A preparation restored from an identical earlier Session carries that Session's identity. + // A preparation restored from an identical earlier Environment carries that Environment's identity. err = agentcapabilities.Rebind(root, &manifest, input, identity) } if err != nil || agentcapabilities.ValidateSelection(manifest, input, identity) != nil { diff --git a/apps/daemon/internal/localworkspace/native_binding.go b/apps/daemon/internal/localworkspace/native_binding.go index 08062c42d..9f62e7cb0 100644 --- a/apps/daemon/internal/localworkspace/native_binding.go +++ b/apps/daemon/internal/localworkspace/native_binding.go @@ -10,7 +10,7 @@ import ( ) func newNativeBinding(environment, workspace, capabilities string) (*Binding, error) { - if id, err := uuid.Parse(environment); err != nil || id == uuid.Nil || id.String() != environment { + if !canonicalID(environment) { return nil, errors.New("local workspace requires a canonical Environment identity") } for _, path := range []string{workspace, capabilities} { @@ -25,6 +25,11 @@ func newNativeBinding(environment, workspace, capabilities string) (*Binding, er return &Binding{environment: environment, workspace: workspace, capabilityRoot: capabilities, writer: &fileWriter{}}, nil } +func canonicalID(value string) bool { + id, err := uuid.Parse(value) + return err == nil && id != uuid.Nil && id.String() == value +} + // ReadToolEnvironment reads explicit initialization values for this installation. func (b *Binding) ReadToolEnvironment() (map[string]string, error) { return ReadOptionalToolEnvironment() diff --git a/docs/runtime-bootstrap.md b/docs/runtime-bootstrap.md index d6c94e404..2bdfcf407 100644 --- a/docs/runtime-bootstrap.md +++ b/docs/runtime-bootstrap.md @@ -51,7 +51,7 @@ oac-daemon connect --bootstrap-file /home/runtime/runtime-bootstrap.json | `device_id` | Canonical nonzero UUID of the daemon identity Core issued | | `credential` | Nonempty daemon credential Core issued, without whitespace or NUL | | `environment_id` | Canonical nonzero UUID of the Environment the Runtime serves | -| `session_id` | Canonical nonzero UUID of the Session whose native history the Runtime keeps | +| `session_id` | Canonical nonzero UUID of a Session of the Environment. The Runtime binds to the Environment and ignores it | | `workspace` | Clean absolute directory of the workspace, `runtimebootstrap.RuntimeWorkspace` in the packaged Runtime layout. The Runtime serves it as `runtimebootstrap.PublicWorkspace` (`/workspace`), the path applications and Core use | | `suspend_control` | Required boolean. Core sets it exactly when the Provider declares [checkpoint support](./sandbox-provider.md), and the Provider forwards it unchanged | diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index b83af7350..ab18db506 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -209,7 +209,7 @@ stateDiagram-v2 ready --> failed ``` -One Session at a time: a Runtime bound to an Environment serves a single Session, and each `execution_prepare` names it. A Runtime without a bound Environment, such as the service-side host for `environment: none` Sessions, serves Sessions concurrently within its capacity and never switches. If an Executor of another Session is preparing, admitted or running, the prepare is rejected with the retryable `resource_unavailable`. If it is idle, the Runtime closes it, waits for the confirmed close, removes the native state of every other Session and then prepares the new Session. A read-only workspace preparation starts no Harness and never switches Sessions. +One Session at a time: a Runtime bound to an Environment serves a single Session, and each `execution_prepare` names it. A Runtime without a bound Environment, such as the service-side host for `environment: none` Sessions, serves Sessions concurrently within its capacity and never switches. If an Executor of another Session is preparing, admitted or running, the prepare is rejected with `resource_unavailable`. Otherwise the new Executor's preparation closes the other Sessions' Executors, waits for each confirmed close, removes the native state of every other Session and only then starts the Harness; an unconfirmed close fails the preparation and keeps the state. A read-only workspace preparation starts no Harness and never switches Sessions. Preparation and start run outside the receive loop and router lock. An admission expires at a fixed deadline that retries do not extend; expiry does not remove the Runtime's obligation to settle cleanup. The Runtime bounds active preparation and execution separately from idle retained resources and counts closing or uncertain resources until their cleanup succeeds. A definite `execution_prepare` rejection with `preparation_capacity` leaves the queued Turn unclaimed for the Worker to retry, including when cleanup holds the capacity; any other error or uncertain delivery authorizes no replay. The Runtime retains a bounded number of admission records, and an old handle never consumes a replacement's admission. These records are connection-local, not durable input replay. diff --git a/docs/zh/runtime-bootstrap.md b/docs/zh/runtime-bootstrap.md index 0e8fc535e..ded38e795 100644 --- a/docs/zh/runtime-bootstrap.md +++ b/docs/zh/runtime-bootstrap.md @@ -1,7 +1,7 @@ --- title: "Runtime 引导" source: docs/runtime-bootstrap.md -source_hash: 903a8abffdaacc581cd85c2c4dae890c492e15647762d7a917ff3005f4770428 +source_hash: 50bfbec00d4aec47aab573e3f0c99ff3bd6ebababf97b859d37101a60657b786 --- Runtime 引导协议连接沙箱提供商与其启动的托管 Runtime。提供商向 Runtime 交付一个引导文件,该文件是此次启动中 Runtime 唯一的认证输入,也是其 Environment 绑定的唯一来源。 @@ -53,7 +53,7 @@ oac-daemon connect --bootstrap-file /home/runtime/runtime-bootstrap.json | `device_id` | Core 签发的 daemon 身份的规范非零 UUID | | `credential` | Core 签发的非空 daemon 凭据,不含空白或 NUL | | `environment_id` | Runtime 所服务 Environment 的规范非零 UUID | -| `session_id` | Runtime 保存其原生历史的 Session 的规范非零 UUID | +| `session_id` | 该 Environment 的一个 Session 的规范非零 UUID。Runtime 绑定到 Environment,忽略此字段 | | `workspace` | 工作区的干净绝对目录;打包的 Runtime 布局中为 `runtimebootstrap.RuntimeWorkspace`。Runtime 将其作为 `runtimebootstrap.PublicWorkspace`(`/workspace`)提供,这是应用与 Core 使用的路径 | | `suspend_control` | 必填布尔值。当且仅当提供商声明[检查点支持](./sandbox-provider.md)时,Core 将其置为 true,提供商原样转交 | diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index e4222142a..3bb4906d6 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: b17c514575d105856306c3dd89340c29efd23513e328127abc5ff5b1b69df20f +source_hash: 968ac5ce4f3d0b40acdafbae4435cba2348456e8978e53f0ab2bbe618e111c49 --- Core–Runtime 协议连接 Core 与 Runtime daemon,定义 daemon 连接上消息的含义和顺序。托管和自托管 Runtime 使用同一协议。 @@ -211,7 +211,7 @@ stateDiagram-v2 ready --> failed ``` -一次只服务一个 Session:绑定了 Environment 的 Runtime 只服务一个 Session,每个 `execution_prepare` 指明该 Session。未绑定 Environment 的 Runtime,例如 `environment: none` Session 的服务端宿主,在其容量内并发服务多个 Session,从不切换。若另一个 Session 的 Executor 正在 preparing、已 admitted 或正在运行,该 prepare 以可重试的 `resource_unavailable` 拒绝。若它空闲,Runtime 先关闭它,等待确认关闭,删除其他所有 Session 的原生状态,再 prepare 新 Session。只读工作区 preparation 不启动 Harness,也从不切换 Session。 +一次只服务一个 Session:绑定了 Environment 的 Runtime 只服务一个 Session,每个 `execution_prepare` 指明该 Session。未绑定 Environment 的 Runtime,例如 `environment: none` Session 的服务端宿主,在其容量内并发服务多个 Session,从不切换。若另一个 Session 的 Executor 正在 preparing、已 admitted 或正在运行,该 prepare 以 `resource_unavailable` 拒绝。否则新 Executor 的 preparation 先关闭其他 Session 的 Executor,等待每次关闭确认,删除其他所有 Session 的原生状态,然后才启动 Harness;关闭未确认时 preparation 失败,状态保留。只读工作区 preparation 不启动 Harness,也从不切换 Session。 preparation 和 start 在 receive loop 与 router lock 之外运行。admission 在固定截止时间到期,重试不延长该时间;到期不解除 Runtime 完成清理结算的义务。Runtime 分别限制活动 preparation、execution 和保留的空闲资源,关闭中或不确定资源持续计入限制,直到清理成功。明确的 `execution_prepare` 拒绝若为 `preparation_capacity`,会让排队 Turn 保持未领取,供 Worker 重试,包括清理占用容量的情况;其他错误或不确定交付都不授权重放。Runtime 保留有限数量的 admission 记录,旧 handle 不会消耗替代项的 admission。这些记录仅属于连接,不是持久化输入重放。 diff --git a/internal/agentcapabilities/install.go b/internal/agentcapabilities/install.go index f422022cb..ec1b536f2 100644 --- a/internal/agentcapabilities/install.go +++ b/internal/agentcapabilities/install.go @@ -110,7 +110,7 @@ func Finalize(installed *os.Root, input proto.CapabilitySources, identity Identi } // Rebind gives an installation restored from an earlier preparation the identity -// of this Session. The earlier preparation must have had the identical selection. +// of this Environment. The earlier preparation must have had the identical selection. func Rebind(installed *os.Root, manifest *Manifest, input proto.CapabilitySources, identity Identity) error { digest, err := selectionHash(input) if err != nil || validateIdentity(identity) != nil || manifest.Version != 1 || manifest.SelectionSHA256 != digest { diff --git a/internal/runtimebootstrap/bootstrap.go b/internal/runtimebootstrap/bootstrap.go index ca3aecf14..765f9be34 100644 --- a/internal/runtimebootstrap/bootstrap.go +++ b/internal/runtimebootstrap/bootstrap.go @@ -35,7 +35,8 @@ var ErrInvalid = errors.New("invalid Runtime bootstrap input") // Connection is a current-version launch input, not Runtime's private auth store. // CoreURL includes the machine API base path. The provider owns delivery and // file permissions; Runtime owns validation and its authentication representation. -// EnvironmentID, SessionID and Workspace bind the Runtime to its Environment. +// EnvironmentID and Workspace bind the Runtime to its Environment; the Runtime +// ignores SessionID. // SuspendControl is true exactly when the Provider declares checkpoint support. type Connection struct { Version int `json:"version"` diff --git a/services/core/deploy/e2b/README.md b/services/core/deploy/e2b/README.md index 89e45bb53..0ad874864 100644 --- a/services/core/deploy/e2b/README.md +++ b/services/core/deploy/e2b/README.md @@ -74,7 +74,7 @@ After an uncertain startup, inspect the same VM or destroy it. The SDK's `Sandbo | `/root/.oac/e2b/launch.json` | The startup claim, written before any other startup step | | `/root/.oac/e2b/ready.json` | Process handoff only (`daemon_started`, with the daemon PID), even if the daemon exits later | | `/home/runtime/.oac/daemon/default/daemon.log` | The daemon log | -| `/home/runtime/.oac/daemon/environment.json` | The daemon's verified Environment and Session binding | +| `/home/runtime/.oac/daemon/environment.json` | The daemon's verified Environment binding | Neither record proves enrollment, native readiness or a successful Turn; check the Environment status in Core. Keep the records and native history after a failure. `init.py` refuses to run again once any launch record exists. VM expiry destroys the history in it; a replacement VM never recovers the original Session.