Files
openclaw/apps/macos/Sources/OpenClaw/NodeMode/MacNodeChannelStatusStore.swift
Peter Steinberger af9b0c7616 fix(macos): keep the node channel alive and visible when the node-host worker cannot start (#129925)
* fix(macos): keep the node channel alive and visible when the node-host worker cannot start

A node-host worker that exited before its ready manifest never notified the
retry policy, so the coordinator respawned the broken CLI forever and the
whole node channel silently never dialed the gateway. Startup exits now
consume the crash retry budget and carry the worker's stderr into the start
error; every non-transient worker failure degrades the connect to native
capabilities instead of aborting it; and the menu bar surfaces the recorded
node-channel state with the concrete reason.

* fix(macos): render node-channel status as a top-level menu view

The native-menu extra style flattens multi-view Toggle labels to their first
Text, so status sublines inside the label never rendered — the original
'zero indication' report. Top-level menu views render (exec-approval error
pattern).
2026-08-26 00:02:52 -07:00

51 lines
1.9 KiB
Swift

import Foundation
import Observation
/// Recorded fact for the Mac node channel, written by MacNodeModeCoordinator at
/// the connect boundary. The menu bar reads this instead of inferring node
/// health from gateway node listings, so a node channel that never dials still
/// surfaces its reason to the operator.
enum MacNodeChannelState: Equatable, Sendable {
/// 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
/// unavailable and only native capabilities are advertised.
case connected(workerUnavailableReason: String?)
/// The last connect attempt failed; the coordinator keeps retrying.
case unavailable(reason: String)
var operatorStatusLine: (label: String, isDegraded: Bool)? {
switch self {
case .idle, .connected(workerUnavailableReason: nil):
nil
case let .connected(workerUnavailableReason: .some(reason)):
("Mac node degraded — \(Self.condense(reason))", true)
case let .unavailable(reason):
("Mac node unavailable — \(Self.condense(reason))", false)
}
}
/// 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 {
let firstLine = reason
.split(separator: "\n", omittingEmptySubsequences: true)
.first
.map { $0.trimmingCharacters(in: .whitespaces) } ?? reason
return firstLine.count > 220 ? firstLine.prefix(220) + "…" : firstLine
}
}
@MainActor
@Observable
final class MacNodeChannelStatusStore {
static let shared = MacNodeChannelStatusStore()
private(set) var state: MacNodeChannelState = .idle
func record(_ state: MacNodeChannelState) {
guard self.state != state else { return }
self.state = state
}
}