Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CLI/CMUXCLI+AgentHookAdmission.swift
Original file line number Diff line number Diff line change
Expand Up @@ -927,15 +927,18 @@ extension CMUXCLI {
}

static func queuedAgentHookDataEnvironmentKeys(agent: String) -> [String] {
[
// The routed-launch metadata is Claude's; the session-start capture
// validates it before recording (`AgentLaunchEnvironmentPolicy`).
let routedLaunchKeys = agent == "claude" ? SubrouterClaudeResumeRouting.hookCapturedEnvironmentKeys : []
return [
"PWD",
"CMUX_AGENT_HOOK_STATE_DIR", "CMUX_AGENT_HOOK_SUPPRESS_VISIBLE_MUTATIONS",
"CMUX_AGENT_LAUNCH_ARGV_B64", "CMUX_AGENT_LAUNCH_CWD",
"CMUX_AGENT_LAUNCH_EXECUTABLE", "CMUX_AGENT_LAUNCH_KIND",
"CMUX_AGENT_MANAGED_SUBAGENT", "CMUX_SUPPRESS_SUBAGENT_NOTIFICATIONS",
"CMUX_SURFACE_ID", "CMUX_WORKSPACE_ID", agentHookRouteSnapshotEnvironmentKey,
agentHookPIDEnvironmentVariable(agentName: agent),
]
] + routedLaunchKeys
}

static func agentHookPIDEnvironmentVariable(agentName: String) -> String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,15 @@ public struct AgentLaunchEnvironmentPolicy: Sendable {
// exact command text, never a URL or credential. They cross into
// the durable restore record only as an agreeing pair, so a marker
// inherited from an ancestor `sr claude` session proves nothing.
selected.merge(SubrouterClaudeResumeRouting().capturedEnvironment(in: env)) { _, marker in
let router = SubrouterClaudeResumeRouting()
selected.merge(router.capturedEnvironment(in: env)) { _, marker in
marker
}
// The account a routed launch was pinned to, recorded by the
// wrapper. It only picks the launcher's `--account` on restore.
selected.merge(router.capturedAccountEnvironment(in: env)) { _, account in
account
}
}
return selected
}
Expand Down Expand Up @@ -251,6 +257,7 @@ public struct AgentLaunchEnvironmentPolicy: Sendable {
)
selected.removeValue(forKey: SubrouterCodexResumeRouting.environmentKey)
selected.removeValue(forKey: SubrouterCodexResumeRouting.launchBoundEnvironmentKey)
selected.removeValue(forKey: SubrouterClaudeResumeRouting.accountEnvironmentKey)
return selected
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@ public struct AgentRestorePlanner: Sendable {
}
selected.removeValue(forKey: SubrouterClaudeResumeRouting.environmentKey)
selected.removeValue(forKey: SubrouterClaudeResumeRouting.launchBoundEnvironmentKey)
selected.removeValue(forKey: SubrouterClaudeResumeRouting.accountEnvironmentKey)
let keys = selected.keys.sorted().filter {
Self.claudeAuthSelectionEnvironmentKeys.contains($0)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,55 @@ public struct AgentSessionRecoveryPlanner: Sendable {
return result
}
}

/// Which recovered sessions start right away and which wait until their
/// workspace is first shown.
///
/// A heavy user can have dozens of agents running when cmux dies, and starting
/// them all on relaunch spikes CPU and memory. Only a few start now: sessions
/// from a workspace on screen, then the most recently active. The rest open
/// their workspace now and resume on its first visit, the way startup restore
/// treats background workspaces.
///
/// A session resumed through its recorded launcher always starts now: its
/// launch claim is taken when the command is typed, and nothing would take it
/// on a later visit. Such sessions are rare.
public struct AgentRecoveryStartPlan: Equatable, Sendable {
/// How many recovered sessions start right away by default.
public static let defaultImmediateLimit = 3

/// Sessions whose terminal starts now, in start order.
public private(set) var startNow: [AgentRecoveryCandidate] = []
/// Sessions whose workspace opens now and whose terminal starts on first visit.
public private(set) var startOnVisit: [AgentRecoveryCandidate] = []

/// - Parameters:
/// - candidates: The sessions to recover.
/// - visibleWorkspaceIds: Workspaces shown in a window right now.
/// - immediateLimit: How many sessions start right away.
public init(
candidates: [AgentRecoveryCandidate],
visibleWorkspaceIds: Set<UUID> = [],
immediateLimit: Int = AgentRecoveryStartPlan.defaultImmediateLimit
) {
func isVisible(_ candidate: AgentRecoveryCandidate) -> Bool {
candidate.workspaceId.flatMap(UUID.init(uuidString:)).map(visibleWorkspaceIds.contains) ?? false
}
let ranked = candidates.enumerated().sorted { lhs, rhs in
let lhsVisible = isVisible(lhs.element)
let rhsVisible = isVisible(rhs.element)
if lhsVisible != rhsVisible { return lhsVisible }
if lhs.element.lastActivity != rhs.element.lastActivity {
return lhs.element.lastActivity > rhs.element.lastActivity
}
return lhs.offset < rhs.offset
}
for (_, candidate) in ranked {
if startNow.count < immediateLimit || candidate.launcherResumeArguments != nil {
startNow.append(candidate)
} else {
startOnVisit.append(candidate)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ public struct SubrouterClaudeResumeRouting: Sendable, Equatable {
/// Wrapper-attested copy of the marker bound to the current Claude argv.
public static let launchBoundEnvironmentKey = "CMUX_AGENT_LAUNCH_SUBROUTER_CLAUDE_RESUME_COMMAND"

/// The account a routed launch was pinned to, exported by
/// `cmux-claude-wrapper` from the routing headers in the launcher's
/// private `--settings` file. It is the launcher's resolved choice, so it
/// survives however the launcher was invoked or what it appended to the
/// arguments it forwarded.
public static let accountEnvironmentKey = "CMUX_AGENT_LAUNCH_ROUTED_CLAUDE_ACCOUNT"

/// Launch metadata the wrapper exports for a routed launch, which the
/// queued Claude hooks must carry to the session-start capture.
public static let hookCapturedEnvironmentKeys = [
environmentKey,
launchBoundEnvironmentKey,
accountEnvironmentKey,
]

/// Directory-name prefix of the private settings directory `sr claude proxy` creates.
public static let privateSettingsDirectoryPrefix = "subrouter-claude-settings-"

Expand All @@ -50,6 +65,7 @@ public struct SubrouterClaudeResumeRouting: Sendable, Equatable {
public static let restoreOwnedEnvironmentKeys: Set<String> = [
environmentKey,
launchBoundEnvironmentKey,
accountEnvironmentKey,
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
Expand Down Expand Up @@ -136,6 +152,29 @@ public struct SubrouterClaudeResumeRouting: Sendable, Equatable {
]
}

/// The pinned account the wrapper recorded for this launch, or `nil` when
/// the launch was pooled or the value is not a plain account id. The value
/// becomes an argument to the launcher, so anything that could read as an
/// option or carry shell or control characters is refused.
public func capturedAccount(in environment: [String: String]?) -> String? {
guard let value = environment?[Self.accountEnvironmentKey],
(1...256).contains(value.count),
!value.hasPrefix("-"),
value.unicodeScalars.allSatisfy(Self.accountScalars.contains) else {
return nil
}
return value
}

private static let accountScalars = CharacterSet(
charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._@+:=-"
)

/// The pinned account as durable launch metadata, or an empty environment.
public func capturedAccountEnvironment(in environment: [String: String]?) -> [String: String] {
capturedAccount(in: environment).map { [Self.accountEnvironmentKey: $0] } ?? [:]
}

/// Whether the captured launch proves a Subrouter-routed plain `claude` launch.
///
/// cmux launchers (`claudeTeams` and friends) own their own resume shape and
Expand All @@ -161,9 +200,10 @@ public struct SubrouterClaudeResumeRouting: Sendable, Equatable {
/// to replay follow the session id; Subrouter's private `--settings` file is
/// dropped because the launcher issues a fresh one.
///
/// When the record also carries the launcher argv that started Claude
/// (`sr claude proxy --account x`), its account pin is kept; otherwise the
/// pool picks the account.
/// A pinned launch keeps its account: the pin the wrapper recorded
/// (``accountEnvironmentKey``) when present, else one read from a captured
/// launcher argv (`sr claude proxy --account x`) on records that predate
/// it. Otherwise the pool picks the account.
public func resumeArguments(
launcher: String?,
sessionID: String,
Expand All @@ -180,7 +220,9 @@ public struct SubrouterClaudeResumeRouting: Sendable, Equatable {
return nil
}
let markerTokens = marker.split(separator: " ").map(String.init)
let head = pinnedLauncherArguments(launcherPrefix, markerTokens: markerTokens) ?? markerTokens
let head = capturedAccount(in: environment).map { Array(markerTokens[0..<3]) + ["--account", $0, "--resume"] }
?? pinnedLauncherArguments(launcherPrefix, markerTokens: markerTokens)
?? markerTokens
return head
+ [sessionID]
+ removingPrivateSettingsArguments(from: preserved)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,75 @@ struct AgentSessionRecoveryPlannerTests {
#expect(command.launcherPrefix == nil)
}
}

/// A heavy user can have dozens of agents running when cmux dies; starting
/// them all on relaunch spikes CPU and memory. Only a few start at once.
@Suite("Agent session recovery start plan")
struct AgentRecoveryStartPlanTests {
private let now = Date(timeIntervalSince1970: 1_790_428_300)

private func candidate(
_ id: String,
minutesAgo: Double,
workspaceId: UUID? = nil,
launcherPrefix: [String]? = nil
) -> AgentRecoveryCandidate {
AgentRecoveryCandidate(
kind: "claude",
sessionId: id,
workspaceId: workspaceId?.uuidString,
cwd: "/tmp",
launchCommand: AgentLaunchCommand(arguments: ["claude"], launcherPrefix: launcherPrefix),
lastActivity: now.addingTimeInterval(-minutesAgo * 60)
)
}

@Test("the most recently active sessions start now and the rest wait for a visit")
func startsMostRecentFirst() {
let plan = AgentRecoveryStartPlan(
candidates: (1...6).map { candidate("s\($0)", minutesAgo: Double(7 - $0)) },
immediateLimit: 2
)
#expect(plan.startNow.map(\.sessionId) == ["s6", "s5"])
#expect(plan.startOnVisit.map(\.sessionId) == ["s4", "s3", "s2", "s1"])
}

@Test("sessions from a workspace on screen start before more recent ones")
func visibleWorkspacesStartFirst() {
let visible = UUID()
let plan = AgentRecoveryStartPlan(
candidates: [
candidate("recent", minutesAgo: 1),
candidate("on-screen", minutesAgo: 30, workspaceId: visible),
candidate("older", minutesAgo: 5),
],
visibleWorkspaceIds: [visible],
immediateLimit: 2
)
#expect(plan.startNow.map(\.sessionId) == ["on-screen", "recent"])
#expect(plan.startOnVisit.map(\.sessionId) == ["older"])
}

/// A session resumed through its recorded launcher takes its launch claim
/// when it is typed, and nothing would claim it on a later visit.
@Test("a session resumed through its recorded launcher always starts now")
func launcherSessionsStartNow() {
let plan = AgentRecoveryStartPlan(
candidates: [
candidate("a", minutesAgo: 1),
candidate("b", minutesAgo: 2),
candidate("launcher", minutesAgo: 3, launcherPrefix: ["sr", "claude", "proxy", "--account", "me"]),
],
immediateLimit: 1
)
#expect(plan.startNow.map(\.sessionId) == ["a", "launcher"])
#expect(plan.startOnVisit.map(\.sessionId) == ["b"])
}

@Test("everything starts now when there are only a few sessions")
func fewSessionsAllStartNow() {
let plan = AgentRecoveryStartPlan(candidates: [candidate("a", minutesAgo: 1), candidate("b", minutesAgo: 2)])
#expect(plan.startNow.map(\.sessionId) == ["a", "b"])
#expect(plan.startOnVisit.isEmpty)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct SubrouterClaudeRestoreRoutingTests {
private let marker = "sr claude proxy --resume"
private let markerKey = "SUBROUTER_CLAUDE_RESUME_COMMAND"
private let launchBoundMarkerKey = "CMUX_AGENT_LAUNCH_SUBROUTER_CLAUDE_RESUME_COMMAND"
private let accountKey = "CMUX_AGENT_LAUNCH_ROUTED_CLAUDE_ACCOUNT"
private let localPoolBaseURL = "http://127.0.0.1:31415/v1"
private let capturedClaude = "/opt/homebrew/bin/claude"
private let capturedConfigDir = "/Users/me/.subrouter/codex/claude-proxy/3fa7ce27b6c3bad79bd47d1a"
Expand Down Expand Up @@ -158,6 +159,72 @@ struct SubrouterClaudeRestoreRoutingTests {
}
}

/// The pin recorded for the launch itself holds when the launcher argv
/// cannot be split from what it forwarded (a routed launcher that appends
/// arguments after the forwarded tail leaves no launcher prefix), and wins
/// over a prefix that names another account.
@Test("The recorded account pin survives without a launcher prefix and wins over one")
func recordedAccountPinSurvivesWithoutLauncherPrefix() throws {
for launcherPrefix in [nil, ["sr", "claude", "proxy", "--account", "stale@example.com"]] {
var environment = routedLaunchEnvironment(baseURL: localPoolBaseURL)
environment[accountKey] = "me@example.com"
let request = resumeRequest(environment: environment, launcherPrefix: launcherPrefix)

let invocation = try #require(plannerWithSubrouterOnPath().invocation(
for: request,
ambientEnvironment: ambientEnvironment
))

#expect(
invocation.arguments == ["sr", "claude", "proxy", "--account", "me@example.com", "--resume", sessionID, "--model", "opus"],
"\(invocation.arguments)"
)
// The pin is launch metadata, never replayed into the environment.
#expect(invocation.environment[accountKey] == nil)
}
}

@Test(
"A recorded account that could read as an option or carry shell text is not a pin",
arguments: ["", "--print", "me@example.com; rm -rf /", "me example", "me\nnext"]
)
func unusableRecordedAccountIsIgnored(account: String) throws {
var environment = routedLaunchEnvironment(baseURL: localPoolBaseURL)
environment[accountKey] = account
let invocation = try #require(plannerWithSubrouterOnPath().invocation(
for: resumeRequest(environment: environment),
ambientEnvironment: ambientEnvironment
))

#expect(
invocation.arguments == ["sr", "claude", "proxy", "--resume", sessionID, "--model", "opus"],
"\(invocation.arguments)"
)
}

/// The hook records the pin into the durable launch environment, and a
/// replayed resume command never carries it.
@Test("The recorded account pin is kept as restore metadata and dropped from replay")
func recordedAccountPinIsRestoreMetadataOnly() {
let policy = AgentLaunchEnvironmentPolicy()
let captured = [
accountKey: "me@example.com",
"ANTHROPIC_BASE_URL": localPoolBaseURL,
]
#expect(policy.selectedRestoreEnvironment(from: captured, kind: "claude")[accountKey] == "me@example.com")
#expect(policy.selectedRestoreEnvironment(from: captured, kind: "codex")[accountKey] == nil)
#expect(policy.selectedRestoreEnvironment(
from: [accountKey: "--print"],
kind: "claude"
)[accountKey] == nil)
#expect(policy.selectedReplayEnvironment(
from: captured,
kind: "claude",
launcher: "claude",
arguments: ["claude"]
)[accountKey] == nil)
}

/// Only the account pin is carried over: a prompt, `--settings`, or
/// `--print` sr received stays out of the restore argv, and the program
/// is still resolved on PATH even when the captured one moved.
Expand Down
Loading
Loading