diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go index 8fa5449efc..b0ae9478a1 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go @@ -152,6 +152,21 @@ var persistentMetadataPrefixes = []string{ "env_secret_id_", // Secret store UUIDs for profile env vars } +// sessionScopedMetadataKeys lists metadata keys that point to per-session +// runtime resources (process PIDs, allocated ports, session directories on +// the remote). These keys ARE persisted across a SAME-session resume — that's +// how a backend restart reattaches to a still-running remote agent — but they +// MUST NOT be carried across SIBLING sessions on the same task. If they were, +// the second session would try to attach to the first session's agentctl +// process and end up sharing its ACP session and instance port. +var sessionScopedMetadataKeys = map[string]bool{ + MetadataKeySSHRemoteSessionDir: true, + MetadataKeySSHRemoteAgentctlPort: true, + MetadataKeySSHRemoteAgentctlPID: true, + MetadataKeySSHLocalForwardPort: true, + MetadataKeySSHRemoteAgentctlURL: true, +} + // ShouldPersistMetadataKey returns true if the given metadata key should // be carried forward when resuming a session from an ExecutorRunning record. func ShouldPersistMetadataKey(key string) bool { @@ -166,6 +181,13 @@ func ShouldPersistMetadataKey(key string) bool { return false } +// IsSessionScopedMetadataKey reports whether key references per-session +// runtime resources that must not leak across sibling sessions on the same +// task environment. +func IsSessionScopedMetadataKey(key string) bool { + return sessionScopedMetadataKeys[key] +} + // FilterPersistentMetadata returns a copy of src containing only keys that // should be carried forward across session resumes. Returns nil if no keys match. func FilterPersistentMetadata(src map[string]interface{}) map[string]interface{} { diff --git a/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go b/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go index 79bf3cb752..99dfcd9205 100644 --- a/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go +++ b/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go @@ -133,9 +133,21 @@ func applyExecutorRunningMetadata(req *LaunchAgentRequest, running *models.Execu if metadata == nil { metadata = ensureLaunchMetadata(req) } - if _, exists := metadata[k]; !exists && lifecycle.ShouldPersistMetadataKey(k) { - metadata[k] = v + if _, exists := metadata[k]; exists { + continue } + if !lifecycle.ShouldPersistMetadataKey(k) { + continue + } + // Skip session-scoped runtime resources (PIDs, ports, session dirs). + // Carrying these across sibling sessions on the same task makes a + // fresh launch look like a same-session resume — see SSH executor's + // ResumeRemoteInstance — and the new session ends up sharing the + // previous session's agentctl process. + if lifecycle.IsSessionScopedMetadataKey(k) { + continue + } + metadata[k] = v } }