fix: Mac node reports app update when bundled worker cannot read newer state (#157948)

Explain when the Mac app’s bundled worker cannot read a newer state schema, and direct the operator to update the app rather than only the core package. Offer the existing updater action only when it is available. Preserve worker schema enforcement and capability readiness.

Include focused status mapping and native accessibility coverage. The Developer ID-signed, end-to-end update/replacement sequence remains untested; this change does not modify that updater implementation.

Co-authored-by: PollyBot13 <pollybot13@gmail.com>
Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
This commit is contained in:
PollyBot13andfuller-stack-dev authored and GitHub committed 2026-10-14 14:55:54 -07:00
1 parent 7666cccd01
commit 6199dfe4fd
8 files changed
+240 -27

No files matched your search

+1
View File
@@ -3554,6 +3554,7 @@
{"path":"apps/macos/Sources/OpenClaw/StatusMenuHeaderView.swift","kind":"ui-localized-call","surface":"apple","id":"native.apple.9e4f0aae5dc1d7a9","source":"OpenClaw active"},
{"path":"apps/macos/Sources/OpenClaw/StatusMenuHeaderView.swift","kind":"ui-localized-call","surface":"apple","id":"native.apple.0d72c4f3045a12a1","source":"Pairing approval pending"},
{"path":"apps/macos/Sources/OpenClaw/StatusMenuHeaderView.swift","kind":"ui-localized-call","surface":"apple","id":"native.apple.4746d52ca761661c","source":"Show pairing requests"},
{"path":"apps/macos/Sources/OpenClaw/StatusMenuHeaderView.swift","kind":"ui-localized-call","surface":"apple","id":"native.apple.13213edf4beb15a9","source":"Update"},
{"path":"apps/macos/Sources/OpenClaw/StatusMenuHeaderView.swift","kind":"ui-localized-call","surface":"apple","id":"native.apple.82701e2dbfc77dd1","source":"Voice Wake"},
{"path":"apps/macos/Sources/OpenClaw/StatusMenuHeaderView.swift","kind":"ui-localized-call","surface":"apple","id":"native.apple.bde7b5b40ae21c79","source":"local"},
{"path":"apps/macos/Sources/OpenClaw/StatusMenuHeaderView.swift","kind":"ui-localized-call","surface":"apple","id":"native.apple.af7a06a9db8426bd","source":"remote"},
@@ -6,6 +6,10 @@ import Observation
/// health from gateway node listings, so a node channel that never dials still
/// surfaces its reason to the operator.
enum MacNodeChannelState: Equatable, Sendable {
enum RecoveryAction: Equatable, Sendable {
case checkForUpdates
}
/// Node mode is paused, stopped, or not configured to run.
case idle
/// The channel connected. A non-nil reason means the node-host worker is
@@ -14,17 +18,48 @@ enum MacNodeChannelState: Equatable, Sendable {
/// The last connect attempt failed; the coordinator keeps retrying.
case unavailable(reason: String, diagnostic: String? = nil)
var operatorStatusLine: (label: String, diagnostic: String?, isDegraded: Bool)? {
var operatorStatusLine: (
label: String,
diagnostic: String?,
isDegraded: Bool,
recoveryAction: RecoveryAction?)?
{
switch self {
case .idle, .connected(workerUnavailableReason: nil, diagnostic: _):
nil
return nil
case let .connected(workerUnavailableReason: .some(reason), diagnostic: diagnostic):
("Mac node degraded — \(Self.condense(reason))", Self.excerpt(diagnostic), true)
let compatibility = Self.workerSchemaMismatch(in: reason + "\n" + (diagnostic ?? ""))
let label = compatibility.map {
"Mac node degraded — update the OpenClaw app: its worker supports schema \($0.supported), " +
"but shared state requires schema \($0.required)"
} ?? "Mac node degraded — \(Self.condense(reason))"
return (label, Self.excerpt(diagnostic), true, compatibility == nil ? nil : .checkForUpdates)
case let .unavailable(reason, diagnostic):
("Mac node unavailable — \(Self.condense(reason))", Self.excerpt(diagnostic), false)
let compatibility = Self.workerSchemaMismatch(in: reason + "\n" + (diagnostic ?? ""))
let label = compatibility.map {
"Mac node unavailable — update the OpenClaw app: its worker supports schema \($0.supported), " +
"but shared state requires schema \($0.required)"
} ?? "Mac node unavailable — \(Self.condense(reason))"
return (label, Self.excerpt(diagnostic), false, compatibility == nil ? nil : .checkForUpdates)
}
}
private static func workerSchemaMismatch(in message: String) -> (required: Int, supported: Int)? {
let marker = "uses newer schema version "
guard let markerRange = message.range(of: marker),
let separatorRange = message[markerRange.upperBound...].range(of: "; this build supports ")
else { return nil }
let requiredText = message[markerRange.upperBound..<separatorRange.lowerBound]
let supportedSuffix = message[separatorRange.upperBound...]
let supportedText = supportedSuffix.prefix(while: \.isNumber)
guard let required = Int(requiredText),
let supported = Int(supportedText),
required > supported
else { return nil }
return (required, supported)
}
/// Menu status lines are single-line; keep the leading reason sentence and
/// bound it so a CLI stack trace cannot flood the menu.
private static func condense(_ reason: String) -> String {
@@ -50,9 +50,6 @@ final class StatusMenuController: NSObject, NSMenuDelegate {
StatusMenuAppearance.pin(self.menu)
let renderer = StatusMenuRenderer(menu: self.menu, state: self.state, approvalQueue: self.approvals)
renderer.onInstallUpdate = { [weak self] in
self?.updater.checkForUpdates(nil)
}
self.renderer = renderer
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
@@ -245,6 +242,14 @@ final class StatusMenuController: NSObject, NSMenuDelegate {
mainSessionKey: self.activityStore.mainSessionKey,
approvals: self.approvals.requests,
gateways: DashboardGatewayMenuModel.items(from: self.dashboard.gatewayEntries))
if self.updater.isAvailable {
self.renderer?.onInstallUpdate = { [weak self] in
guard let self, self.updater.isAvailable else { return }
self.updater.checkForUpdates(nil)
}
} else {
self.renderer?.onInstallUpdate = nil
}
self.renderer?.isSleeping = statusMenuGatewayIsSleeping(state: self.state)
self.renderer?.reconcile(StatusMenuDescriptor.build(from: snapshot))
}
@@ -17,10 +17,16 @@ struct StatusMenuHeaderView: View {
private let nodesStore = NodesStore.shared
private let nodeChannelStatus = MacNodeChannelStatusStore.shared
private let dashboardManager = DashboardManager.shared
private let onCheckForUpdates: (@MainActor () -> Void)?
init(state: AppState, isSleeping: Bool = false) {
init(
state: AppState,
isSleeping: Bool = false,
onCheckForUpdates: (@MainActor () -> Void)? = nil)
{
self._state = Bindable(wrappedValue: state)
self.isSleeping = isSleeping
self.onCheckForUpdates = onCheckForUpdates
}
var body: some View {
@@ -76,8 +82,14 @@ struct StatusMenuHeaderView: View {
let problems = self.problemLines
if !problems.isEmpty {
VStack(alignment: .leading, spacing: 4) {
ForEach(problems, id: \.label) { problem in
self.statusLine(label: problem.label, diagnostic: problem.diagnostic, color: problem.color)
ForEach(problems.indices, id: \.self) { index in
let problem = problems[index]
self.statusLine(
label: problem.label,
diagnostic: problem.diagnostic,
color: problem.color,
onCheckForUpdates: index == problems.count - 1 && self.shouldOfferAppUpdate
? self.onCheckForUpdates : nil)
}
}
}
@@ -274,23 +286,24 @@ struct StatusMenuHeaderView: View {
return (String(localized: "Mac capabilities offline"), nil, .orange)
}
private func statusLine(label: String, diagnostic: String? = nil, color: Color) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption)
.foregroundStyle(color)
.lineLimit(diagnostic == nil ? 3 : 2)
private var shouldOfferAppUpdate: Bool {
guard self.state.connectionMode != .unconfigured,
case .connected = self.controlChannel.state
else { return false }
return self.nodeChannelStatus.state.operatorStatusLine?.recoveryAction == .checkForUpdates
}
if let diagnostic {
Text(diagnostic)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.lineLimit(4)
.textSelection(.enabled)
}
}
.fixedSize(horizontal: false, vertical: true)
.help(diagnostic.map { "\(label)\n\($0)" } ?? label)
private func statusLine(
label: String,
diagnostic: String? = nil,
color: Color,
onCheckForUpdates: (@MainActor () -> Void)? = nil) -> some View
{
StatusMenuProblemLineView(
label: label,
diagnostic: diagnostic,
color: color,
onCheckForUpdates: onCheckForUpdates)
}
private func pairingRow(_ label: String) -> some View {
@@ -322,3 +335,35 @@ struct StatusMenuHeaderView: View {
}
}
}
@MainActor
struct StatusMenuProblemLineView: View {
let label: String
let diagnostic: String?
let color: Color
let onCheckForUpdates: (@MainActor () -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(self.label)
.font(.caption)
.foregroundStyle(self.color)
.lineLimit(self.diagnostic == nil ? 3 : 2)
if let diagnostic = self.diagnostic {
Text(diagnostic)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.lineLimit(4)
.textSelection(.enabled)
}
if let onCheckForUpdates = self.onCheckForUpdates {
Button(String(localized: "Update"), action: onCheckForUpdates)
.font(.caption.weight(.semibold))
.buttonStyle(.link)
}
}
.fixedSize(horizontal: false, vertical: true)
.help(self.diagnostic.map { "\(self.label)\n\($0)" } ?? self.label)
}
}
@@ -197,7 +197,11 @@ final class StatusMenuRenderer: NSObject {
switch content.kind {
case .header:
Self.configureHostedView(
item, rootView: StatusMenuHeaderView(state: self.state, isSleeping: self.isSleeping))
item,
rootView: StatusMenuHeaderView(
state: self.state,
isSleeping: self.isSleeping,
onCheckForUpdates: self.onInstallUpdate))
case let .session(row):
StatusMenuSessions.shared.configureSessionItem(item, row: row)
case let .approval(request):
@@ -401,6 +401,7 @@ struct MacNodeModeCoordinatorTests {
#expect(degraded?.label == "Mac node degraded — worker exited: schema mismatch")
#expect(degraded?.diagnostic == nil)
#expect(degraded?.isDegraded == true)
#expect(degraded?.recoveryAction == nil)
let unavailable = MacNodeChannelState
.unavailable(reason: "state database uses newer schema version 10\nTry: openclaw doctor")
@@ -408,6 +409,25 @@ struct MacNodeModeCoordinatorTests {
#expect(unavailable?.label == "Mac node unavailable — state database uses newer schema version 10")
#expect(unavailable?.diagnostic == nil)
#expect(unavailable?.isDegraded == false)
#expect(unavailable?.recoveryAction == nil)
}
@Test func `newer state schema exposes app update recovery from native node status`() throws {
let diagnostic = "OpenClaw state database /state/state.db uses newer schema version 18; " +
"this build supports 17."
let line = try #require(MacNodeChannelState.connected(
workerUnavailableReason: "worker exited with status exited(1)",
diagnostic: diagnostic).operatorStatusLine)
#expect(line.label == "Mac node degraded — update the OpenClaw app: its worker supports schema 17, " +
"but shared state requires schema 18")
#expect(line.recoveryAction == .checkForUpdates)
#expect(line.diagnostic == diagnostic)
let unrelated = try #require(MacNodeChannelState.connected(
workerUnavailableReason: "worker exited with status exited(1)",
diagnostic: "worker could not initialize").operatorStatusLine)
#expect(unrelated.recoveryAction == nil)
}
@Test func `worker stderr never becomes part of the operator status headline`() throws {
@@ -423,6 +443,7 @@ struct MacNodeModeCoordinatorTests {
#expect(line.label.contains(reason))
#expect(!line.label.contains(diagnostic))
#expect(line.diagnostic == diagnostic)
#expect(line.recoveryAction == nil)
}
}
@@ -0,0 +1,99 @@
import AppKit
import SwiftUI
import Testing
@testable import OpenClaw
@Suite(.serialized)
@MainActor
struct StatusMenuRecoveryPresentationTests {
@Test func `worker recovery row renders guidance and only offers a callable update`() async throws {
_ = AppKitTestSupport.application
let diagnostic = "OpenClaw state database uses newer schema version 18; this build supports 17."
let mismatch = MacNodeChannelState.connected(
workerUnavailableReason: "worker exited with status exited(1)",
diagnostic: diagnostic)
let beforeLine: (
label: String,
diagnostic: String?,
isDegraded: Bool,
recoveryAction: MacNodeChannelState.RecoveryAction?) = (
"Mac node degraded — worker exited with status exited(1)", diagnostic, true, nil)
let afterLine = try #require(mismatch.operatorStatusLine)
var updateCalls = 0
try await self.capture(beforeLine, name: "worker-schema-before", onCheckForUpdates: nil) { buttons in
#expect(!buttons.contains("Update"))
}
try await self.capture(afterLine, name: "worker-schema-after", onCheckForUpdates: {
updateCalls += 1
}) { buttons in
#expect(buttons.contains("Update"))
}
try await self.capture(afterLine, name: "worker-schema-no-updater", onCheckForUpdates: nil) { buttons in
#expect(!buttons.contains("Update"))
}
#expect(updateCalls == 1)
}
private func capture(
_ line: (label: String, diagnostic: String?, isDegraded: Bool, recoveryAction: MacNodeChannelState.RecoveryAction?),
name: String,
onCheckForUpdates: (@MainActor () -> Void)?,
verify: ([String]) throws -> Void) async throws
{
let view = StatusMenuProblemLineView(
label: line.label,
diagnostic: line.diagnostic,
color: line.isDegraded ? .orange : .red,
onCheckForUpdates: onCheckForUpdates)
.padding(14)
.frame(width: StatusMenuMetrics.width, alignment: .leading)
.background(.regularMaterial)
let hosting = NSHostingView(rootView: view)
hosting.frame = NSRect(x: 0, y: 0, width: StatusMenuMetrics.width, height: 130)
let window = NSWindow(contentRect: hosting.frame, styleMask: [.titled], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.contentView = hosting
defer {
window.orderOut(nil)
window.contentView = nil
window.close()
}
window.orderFront(nil)
hosting.layoutSubtreeIfNeeded()
let elements = try await AppKitTestSupport.accessibilityElements(in: hosting)
let controls = elements.filter {
$0.accessibilityRole?() == .button || $0.accessibilityRole?() == .link
}
let directory = try #require(ProcessInfo.processInfo.environment["OPENCLAW_TEST_MENU_CAPTURE_DIR"])
let image = try #require(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds))
hosting.cacheDisplay(in: hosting.bounds, to: image)
let png = try #require(image.representation(using: .png, properties: [:]))
let output = URL(fileURLWithPath: directory, isDirectory: true)
try png.write(to: output.appendingPathComponent("\(name)-window.png"))
let status: [String: Any] = [
"name": name,
"source": "StatusMenuProblemLineView",
"synthetic": true,
"baselinePresentationReconstructed": name == "worker-schema-before",
"updateActionAvailable": onCheckForUpdates != nil,
"accessibilityElements": elements.map {
[
"role": String(describing: $0.accessibilityRole?()),
"name": AppKitTestSupport.accessibilityName(of: $0) ?? "",
]
},
"requiresVisualInspection": true,
]
try JSONSerialization.data(withJSONObject: status, options: [.sortedKeys])
.write(to: output.appendingPathComponent("\(name)-capture-status.json"))
try verify(controls.compactMap(AppKitTestSupport.accessibilityName(of:)))
if onCheckForUpdates != nil {
let button = try #require(controls.first {
AppKitTestSupport.accessibilityName(of: $0) == "Update"
})
#expect(button.accessibilityPerformPress?() == true)
}
}
}
+3
View File
@@ -330,6 +330,9 @@ await runWithFailedTrailer("macos-native", async () => {
"guest-model-invalidated",
"guest-model-recovered",
"history-message-recovery",
"worker-schema-before",
"worker-schema-after",
"worker-schema-no-updater",
]
: ["thread-reasoning", "thread-tool-activity", "model-initial", "thread-restored"];
const allowed = new RegExp(