Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2d86a54
test: cover browser automation watchdog recovery
austinywang Jul 14, 2026
087d82f
fix: recover unresponsive browser automation
austinywang Jul 14, 2026
f761f82
Merge remote-tracking branch 'origin/main' into issue-8054-browser-pr…
austinywang Jul 14, 2026
9c00737
fix: cover all browser automation callback channels
austinywang Jul 14, 2026
72c969a
refactor: isolate browser watchdog signal
austinywang Jul 14, 2026
e29cafb
fix: preserve browser automation recovery state
austinywang Jul 14, 2026
cf69bd1
Merge remote-tracking branch 'origin/main' into issue-8054-browser-pr…
austinywang Jul 14, 2026
c1c3fbf
test: cover shared browser recovery
austinywang Jul 14, 2026
ea1ec4b
fix: share browser automation recovery
austinywang Jul 15, 2026
961ed32
Merge remote-tracking branch 'origin/main' into issue-8054-browser-pr…
austinywang Jul 15, 2026
7824157
test: cover superseded browser recovery
austinywang Jul 15, 2026
7095778
Merge remote-tracking branch 'origin/main' into issue-8054-browser-pr…
austinywang Jul 15, 2026
f3f1f52
fix: preserve page-world automation state
austinywang Jul 15, 2026
8307d58
fix: await recovered browser document readiness
austinywang Jul 15, 2026
c41152f
test: preserve browser consent during recovery
austinywang Jul 15, 2026
6ff8adf
fix: preserve consent across browser recovery
austinywang Jul 15, 2026
9000695
fix: preserve interactive prompts during browser recovery
austinywang Jul 15, 2026
0cbd187
Merge remote-tracking branch 'origin/main' into issue-8054-browser-pr…
austinywang Jul 15, 2026
905ac49
test: cover browser recovery lifecycle races
austinywang Jul 15, 2026
39f7b8c
fix: guard browser automation recovery lifecycle
austinywang Jul 15, 2026
056e6cf
test: remove browser readiness registration hook
austinywang Jul 15, 2026
d0a7fdb
fix: preserve browser recovery timeout headroom
austinywang Jul 15, 2026
335c745
Merge remote-tracking branch 'origin/main' into issue-8054-browser-pr…
austinywang Jul 15, 2026
6f2807c
test: cover browser omnibar draft isolation
austinywang Jul 15, 2026
1f0ce4e
fix: isolate browser omnibar drafts by panel
austinywang Jul 15, 2026
05fd181
Merge remote-tracking branch 'origin/main' into issue-8054-browser-pr…
austinywang Jul 15, 2026
c1c839b
ci: gate browser panel identity regression
austinywang Jul 15, 2026
b39ad9d
Merge remote-tracking branch 'origin/main' into issue-8054-browser-pr…
austinywang Jul 15, 2026
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: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -631,12 +631,12 @@ jobs:
-only-testing:cmuxTests/CLISSHSessionAttachAnchorTests \
test

- name: Run omnibar suggestion click regression
- name: Run focused browser regressions
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
# Focused gate for https://github.com/manaflow-ai/cmux/issues/7380.
# Focused gates for https://github.com/manaflow-ai/cmux/issues/7380 and #8054.
# The tolerant full-suite step can crash-skip late suites (see #5888
# note above), so this regression gets a non-tolerant focused
# note above), so these regressions get a non-tolerant focused
# invocation.
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
Expand All @@ -649,6 +649,7 @@ jobs:
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
-only-testing:cmuxTests/BrowserOmnibarSuggestionClickRoutingTests \
-only-testing:cmuxTests/BrowserPanelViewIdentityTests \
test

- name: Run remote tmux mirror active-pane regression
Expand Down
63 changes: 38 additions & 25 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13070,6 +13070,14 @@ struct CMUXCLI {
let subArgs = Array(args.dropFirst())
let browserValueTextFormatter = BrowserValueTextFormatter()

// A post-action snapshot can spend 3s in document readiness, 10s in the
// requested action, 10s in snapshot JavaScript, and 2.5s in recovery.
// Keep transport headroom beyond that 25.5s app-side maximum.
func sendBrowserAutomationRequest(method: String, params: [String: Any]) throws -> [String: Any] {
let responseTimeout: TimeInterval = (params["snapshot_after"] as? Bool) == true ? 30 : 20
return try client.sendV2(method: method, params: params, responseTimeout: responseTimeout)
}

func requireSurface() throws -> String {
guard let raw = surfaceRaw else {
throw CLIError(message: "browser \(subcommand) requires a surface handle (use: browser <surface> \(subcommand) ... or --surface)")
Expand Down Expand Up @@ -13538,7 +13546,7 @@ struct CMUXCLI {
if snapshotAfter {
params["snapshot_after"] = true
}
let payload = try client.sendV2(method: "browser.navigate", params: params)
let payload = try sendBrowserAutomationRequest(method: "browser.navigate", params: params)
output(payload, fallback: "OK")
return
}
Expand All @@ -13554,7 +13562,7 @@ struct CMUXCLI {
if hasFlag(subArgs, name: "--snapshot-after") {
params["snapshot_after"] = true
}
let payload = try client.sendV2(method: methodMap[subcommand]!, params: params)
let payload = try sendBrowserAutomationRequest(method: methodMap[subcommand]!, params: params)
output(payload, fallback: "OK")
return
}
Expand Down Expand Up @@ -13754,7 +13762,7 @@ struct CMUXCLI {
params["max_depth"] = depth
}

let payload = try client.sendV2(method: "browser.snapshot", params: params)
let payload = try sendBrowserAutomationRequest(method: "browser.snapshot", params: params)
if effectiveJSONOutput {
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
} else {
Expand All @@ -13770,7 +13778,7 @@ struct CMUXCLI {
guard !trimmed.isEmpty else {
throw CLIError(message: "browser eval requires a script")
}
let payload = try client.sendV2(method: "browser.eval", params: ["surface_id": sid, "script": trimmed])
let payload = try sendBrowserAutomationRequest(method: "browser.eval", params: ["surface_id": sid, "script": trimmed])
let fallback: String
if let value = payload["value"] {
fallback = browserValueTextFormatter.string(from: value)
Expand Down Expand Up @@ -13824,7 +13832,7 @@ struct CMUXCLI {
// Give the socket response timeout headroom beyond the wait deadline so long
// waits are reported by the app handler instead of dying at the socket layer.
let waitMs = (params["timeout_ms"] as? Int) ?? 5_000
let responseTimeout = max(10.0, Double(waitMs) / 1000.0 + 5.0)
let responseTimeout = max(15.0, Double(waitMs) / 1000.0 + 8.0)
let payload = try client.sendV2(method: "browser.wait", params: params, responseTimeout: responseTimeout)
output(payload, fallback: "OK")
return
Expand Down Expand Up @@ -13852,7 +13860,7 @@ struct CMUXCLI {
if hasFlag(subArgs, name: "--snapshot-after") {
params["snapshot_after"] = true
}
let payload = try client.sendV2(method: methodMap[subcommand]!, params: params)
let payload = try sendBrowserAutomationRequest(method: methodMap[subcommand]!, params: params)
output(payload, fallback: "OK")
return
}
Expand Down Expand Up @@ -13885,7 +13893,7 @@ struct CMUXCLI {
if hasFlag(subArgs, name: "--snapshot-after") {
params["snapshot_after"] = true
}
let payload = try client.sendV2(method: method, params: params)
let payload = try sendBrowserAutomationRequest(method: method, params: params)
output(payload, fallback: "OK")
return
}
Expand All @@ -13907,7 +13915,7 @@ struct CMUXCLI {
if hasFlag(subArgs, name: "--snapshot-after") {
params["snapshot_after"] = true
}
let payload = try client.sendV2(method: methodMap[subcommand]!, params: params)
let payload = try sendBrowserAutomationRequest(method: methodMap[subcommand]!, params: params)
output(payload, fallback: "OK")
return
}
Expand All @@ -13928,7 +13936,7 @@ struct CMUXCLI {
if hasFlag(subArgs, name: "--snapshot-after") {
params["snapshot_after"] = true
}
let payload = try client.sendV2(method: "browser.select", params: params)
let payload = try sendBrowserAutomationRequest(method: "browser.select", params: params)
output(payload, fallback: "OK")
return
}
Expand Down Expand Up @@ -13962,7 +13970,7 @@ struct CMUXCLI {
params["snapshot_after"] = true
}

let payload = try client.sendV2(method: "browser.scroll", params: params)
let payload = try sendBrowserAutomationRequest(method: "browser.scroll", params: params)
output(payload, fallback: "OK")
return
}
Expand All @@ -13972,7 +13980,12 @@ struct CMUXCLI {
let (outPathOpt, _) = parseOption(subArgs, name: "--out")
let localJSONOutput = hasFlag(subArgs, name: "--json")
let outputAsJSON = effectiveJSONOutput || localJSONOutput
var payload = try client.sendV2(method: "browser.screenshot", params: ["surface_id": sid])
// Leave room beyond the app's capture deadline for its liveness probe and recovery reply.
var payload = try client.sendV2(
method: "browser.screenshot",
params: ["surface_id": sid],
responseTimeout: 25
)

func fileURL(fromPath rawPath: String) -> URL {
let resolvedPath = resolvePath(rawPath)
Expand Down Expand Up @@ -14159,7 +14172,7 @@ struct CMUXCLI {
"box": "browser.get.box",
"styles": "browser.get.styles",
]
let payload = try client.sendV2(method: methodMap[getVerb]!, params: params)
let payload = try sendBrowserAutomationRequest(method: methodMap[getVerb]!, params: params)
if effectiveJSONOutput {
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
} else if let value = payload["value"] {
Expand Down Expand Up @@ -14199,7 +14212,7 @@ struct CMUXCLI {
guard let method = methodMap[isVerb] else {
throw CLIError(message: "Unsupported browser is subcommand: \(isVerb)")
}
let payload = try client.sendV2(method: method, params: ["surface_id": sid, "selector": selector])
let payload = try sendBrowserAutomationRequest(method: method, params: ["surface_id": sid, "selector": selector])
if effectiveJSONOutput {
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
} else if let value = payload["value"] {
Expand Down Expand Up @@ -14282,7 +14295,7 @@ struct CMUXCLI {
throw CLIError(message: "Unsupported browser find locator: \(locator)")
}

let payload = try client.sendV2(method: method, params: params)
let payload = try sendBrowserAutomationRequest(method: method, params: params)
output(payload, fallback: "OK")
return
}
Expand All @@ -14302,7 +14315,7 @@ struct CMUXCLI {
guard let selector else {
throw CLIError(message: "browser frame requires a selector or 'main'")
}
let payload = try client.sendV2(method: "browser.frame.select", params: ["surface_id": sid, "selector": selector])
let payload = try sendBrowserAutomationRequest(method: "browser.frame.select", params: ["surface_id": sid, "selector": selector])
output(payload, fallback: "OK")
return
}
Expand All @@ -14320,10 +14333,10 @@ struct CMUXCLI {
if !text.isEmpty {
params["text"] = text
}
let payload = try client.sendV2(method: "browser.dialog.accept", params: params)
let payload = try sendBrowserAutomationRequest(method: "browser.dialog.accept", params: params)
output(payload, fallback: "OK")
case "dismiss":
let payload = try client.sendV2(method: "browser.dialog.dismiss", params: ["surface_id": sid])
let payload = try sendBrowserAutomationRequest(method: "browser.dialog.dismiss", params: ["surface_id": sid])
output(payload, fallback: "OK")
default:
throw CLIError(message: "Unsupported browser dialog subcommand: \(dialogVerb)")
Expand Down Expand Up @@ -14445,18 +14458,18 @@ struct CMUXCLI {
if let key = positional.first {
params["key"] = key
}
let payload = try client.sendV2(method: "browser.storage.get", params: params)
let payload = try sendBrowserAutomationRequest(method: "browser.storage.get", params: params)
output(payload, fallback: "OK")
case "set":
guard positional.count >= 2 else {
throw CLIError(message: "browser storage \(storageType) set requires <key> <value>")
}
params["key"] = positional[0]
params["value"] = positional[1]
let payload = try client.sendV2(method: "browser.storage.set", params: params)
let payload = try sendBrowserAutomationRequest(method: "browser.storage.set", params: params)
output(payload, fallback: "OK")
case "clear":
let payload = try client.sendV2(method: "browser.storage.clear", params: params)
let payload = try sendBrowserAutomationRequest(method: "browser.storage.clear", params: params)
output(payload, fallback: "OK")
default:
throw CLIError(message: "Unsupported browser storage subcommand: \(op)")
Expand Down Expand Up @@ -14518,7 +14531,7 @@ struct CMUXCLI {
if consoleVerb != "list" && consoleVerb != "clear" {
throw CLIError(message: "Unsupported browser console subcommand: \(consoleVerb)")
}
let payload = try client.sendV2(method: method, params: ["surface_id": sid])
let payload = try sendBrowserAutomationRequest(method: method, params: ["surface_id": sid])
if effectiveJSONOutput || consoleVerb == "clear" {
output(payload, fallback: "OK")
} else {
Expand All @@ -14536,7 +14549,7 @@ struct CMUXCLI {
} else if errorsVerb != "list" {
throw CLIError(message: "Unsupported browser errors subcommand: \(errorsVerb)")
}
let payload = try client.sendV2(method: "browser.errors.list", params: params)
let payload = try sendBrowserAutomationRequest(method: "browser.errors.list", params: params)
if effectiveJSONOutput || errorsVerb == "clear" {
output(payload, fallback: "OK")
} else {
Expand All @@ -14552,7 +14565,7 @@ struct CMUXCLI {
guard let selector else {
throw CLIError(message: "browser highlight requires a selector")
}
let payload = try client.sendV2(method: "browser.highlight", params: ["surface_id": sid, "selector": selector])
let payload = try sendBrowserAutomationRequest(method: "browser.highlight", params: ["surface_id": sid, "selector": selector])
output(payload, fallback: "OK")
return
}
Expand All @@ -14575,7 +14588,7 @@ struct CMUXCLI {
default:
throw CLIError(message: "Unsupported browser state subcommand: \(stateVerb)")
}
let payload = try client.sendV2(method: method, params: ["surface_id": sid, "path": path])
let payload = try sendBrowserAutomationRequest(method: method, params: ["surface_id": sid, "path": path])
output(payload, fallback: "OK")
return
}
Expand All @@ -14589,7 +14602,7 @@ struct CMUXCLI {
guard !content.isEmpty else {
throw CLIError(message: "browser \(subcommand) requires content")
}
let payload = try client.sendV2(method: "browser.\(subcommand)", params: ["surface_id": sid, field: content])
let payload = try sendBrowserAutomationRequest(method: "browser.\(subcommand)", params: ["surface_id": sid, field: content])
output(payload, fallback: "OK")
return
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
public import Foundation

/// Tracks the first committed document for the browser instance owned by one panel.
///
/// The owner calls ``bind(to:hasCommittedDocument:)`` whenever it replaces its WebView and
/// ``didCommit(instanceID:)`` from the corresponding navigation-delegate callback. Automation
/// callers can then await the real lifecycle signal without polling WebKit state.
@MainActor
public final class BrowserAutomationDocumentReadiness {
private var observedInstanceID: UUID?
private var committedInstanceID: UUID?
private var waiters: [UUID: AsyncStream<BrowserAutomationDocumentReadinessOutcome>.Continuation] = [:]

/// Creates an empty document-readiness owner.
public init() {}

/// Starts observing a browser instance and supersedes waits for any previous instance.
/// - Parameters:
/// - instanceID: Stable identity of the newly bound browser instance.
/// - hasCommittedDocument: Whether the instance committed before the owner attached its delegate.
public func bind(to instanceID: UUID, hasCommittedDocument: Bool) {
if observedInstanceID == instanceID {
if hasCommittedDocument, committedInstanceID != instanceID {
committedInstanceID = instanceID
finishWaiters(with: .committed)
}
return
}

finishWaiters(with: .superseded)
observedInstanceID = instanceID
committedInstanceID = hasCommittedDocument ? instanceID : nil
}

/// Records a navigation commit when it belongs to the currently bound browser instance.
/// - Parameter instanceID: Identity captured when the navigation delegate was bound.
public func didCommit(instanceID: UUID) {
guard observedInstanceID == instanceID else { return }
committedInstanceID = instanceID
finishWaiters(with: .committed)
}

/// Stops observing the current browser instance and cancels its pending waits.
public func invalidate() {
observedInstanceID = nil
committedInstanceID = nil
finishWaiters(with: .cancelled)
}

/// Returns whether the currently bound browser instance has committed a document.
/// - Parameter instanceID: Browser instance to inspect.
/// - Returns: `true` only after that exact instance produced a commit signal.
public func hasCommittedDocument(for instanceID: UUID) -> Bool {
observedInstanceID == instanceID && committedInstanceID == instanceID
}

/// Waits for a real commit signal from the specified browser instance.
/// - Parameter instanceID: Browser instance whose first document is required.
/// - Returns: Whether the instance committed, was superseded, or the wait was cancelled.
public func waitForCommit(
instanceID: UUID
) async -> BrowserAutomationDocumentReadinessOutcome {
guard !Task.isCancelled else { return .cancelled }
guard observedInstanceID == instanceID else { return .superseded }
guard committedInstanceID != instanceID else { return .committed }

let waiterID = UUID()
let (events, continuation) = AsyncStream.makeStream(
of: BrowserAutomationDocumentReadinessOutcome.self,
bufferingPolicy: .bufferingNewest(1)
)
waiters[waiterID] = continuation
defer {
waiters.removeValue(forKey: waiterID)
continuation.finish()
}
var iterator = events.makeAsyncIterator()
return await iterator.next() ?? .cancelled
}

private func finishWaiters(with outcome: BrowserAutomationDocumentReadinessOutcome) {
let pendingWaiters = Array(waiters.values)
waiters.removeAll()
for waiter in pendingWaiters {
waiter.yield(outcome)
waiter.finish()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/// Result of waiting for a browser instance to commit its first automation document.
public enum BrowserAutomationDocumentReadinessOutcome: Sendable, Equatable {
/// The observed browser instance committed a document and is ready for JavaScript automation.
case committed

/// A newer browser instance replaced the observed instance before it committed a document.
case superseded

/// The caller cancelled its wait before the observed instance committed a document.
case cancelled
}
Comment thread
austinywang marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// Internal result from racing browser automation liveness callbacks against their deadline.
enum BrowserAutomationProbeSignal: Sendable {
case responsive
case timedOut
case cancelled
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/// Result of checking whether a browser automation callback pipeline is alive.
public enum BrowserAutomationRecoveryOutcome: Sendable, Equatable {
/// The liveness callback arrived before the deadline, so the current WebView remains authoritative.
case responsive

/// The callback missed its deadline and the owning browser surface replaced the unresponsive WebView.
case recovered

/// The callback missed its deadline, but another lifecycle path had already replaced the observed WebView.
case superseded

/// The check was cancelled before liveness or timeout produced an outcome.
case cancelled
}
Loading
Loading