Gateway/iOS: replay queued foreground actions safely after resume (#40281)

Merged via squash.

- Local validation: `pnpm exec vitest run --config vitest.gateway.config.ts src/gateway/server-methods/nodes.invoke-wake.test.ts`
- Local validation: `pnpm build`
- mb-server validation: `pnpm exec vitest run --config vitest.gateway.config.ts src/gateway/server-methods/nodes.invoke-wake.test.ts`
- mb-server validation: `pnpm build`
- mb-server validation: `pnpm protocol:check`
This commit is contained in:
Mariano
2026-03-08 22:46:54 +01:00
committed by GitHub
parent 38543d8196
commit e806c479f5
12 changed files with 604 additions and 1 deletions

View File

@@ -57,6 +57,7 @@ final class NodeAppModel {
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
private let pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction")
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
enum CameraHUDKind {
@@ -130,6 +131,7 @@ final class NodeAppModel {
private var backgroundReconnectLeaseUntil: Date?
private var lastSignificantLocationWakeAt: Date?
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
private var pendingForegroundActionDrainInFlight = false
private var gatewayConnected = false
private var operatorConnected = false
@@ -329,6 +331,9 @@ final class NodeAppModel {
}
await self.talkMode.resumeAfterBackground(wasSuspended: suspended, wasKeptActive: keptActive)
}
Task { [weak self] in
await self?.resumePendingForegroundNodeActionsIfNeeded(trigger: "scene_active")
}
}
if phase == .active, self.reconnectAfterBackgroundArmed {
self.reconnectAfterBackgroundArmed = false
@@ -2098,6 +2103,22 @@ private extension NodeAppModel {
}
extension NodeAppModel {
private struct PendingForegroundNodeAction: Decodable {
var id: String
var command: String
var paramsJSON: String?
var enqueuedAtMs: Int?
}
private struct PendingForegroundNodeActionsResponse: Decodable {
var nodeId: String?
var actions: [PendingForegroundNodeAction]
}
private struct PendingForegroundNodeActionsAckRequest: Encodable {
var ids: [String]
}
private func refreshShareRouteFromGateway() async {
struct Params: Codable {
var includeGlobal: Bool
@@ -2195,6 +2216,83 @@ extension NodeAppModel {
func onNodeGatewayConnected() async {
await self.registerAPNsTokenIfNeeded()
await self.flushQueuedWatchRepliesIfConnected()
await self.resumePendingForegroundNodeActionsIfNeeded(trigger: "node_connected")
}
private func resumePendingForegroundNodeActionsIfNeeded(trigger: String) async {
guard !self.isBackgrounded else { return }
guard await self.isGatewayConnected() else { return }
guard !self.pendingForegroundActionDrainInFlight else { return }
self.pendingForegroundActionDrainInFlight = true
defer { self.pendingForegroundActionDrainInFlight = false }
do {
let payload = try await self.nodeGateway.request(
method: "node.pending.pull",
paramsJSON: "{}",
timeoutSeconds: 6)
let decoded = try JSONDecoder().decode(
PendingForegroundNodeActionsResponse.self,
from: payload)
guard !decoded.actions.isEmpty else { return }
self.pendingActionLogger.info(
"Pending actions pulled trigger=\(trigger, privacy: .public) "
+ "count=\(decoded.actions.count, privacy: .public)")
await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger)
} catch {
// Best-effort only.
}
}
private func applyPendingForegroundNodeActions(
_ actions: [PendingForegroundNodeAction],
trigger: String) async
{
for action in actions {
guard !self.isBackgrounded else {
self.pendingActionLogger.info(
"Pending action replay paused trigger=\(trigger, privacy: .public): app backgrounded")
return
}
let req = BridgeInvokeRequest(
id: action.id,
command: action.command,
paramsJSON: action.paramsJSON)
let result = await self.handleInvoke(req)
self.pendingActionLogger.info(
"Pending action replay trigger=\(trigger, privacy: .public) "
+ "id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) "
+ "ok=\(result.ok, privacy: .public)")
guard result.ok else { return }
let acked = await self.ackPendingForegroundNodeAction(
id: action.id,
trigger: trigger,
command: action.command)
guard acked else { return }
}
}
private func ackPendingForegroundNodeAction(
id: String,
trigger: String,
command: String) async -> Bool
{
do {
let payload = try JSONEncoder().encode(PendingForegroundNodeActionsAckRequest(ids: [id]))
let paramsJSON = String(decoding: payload, as: UTF8.self)
_ = try await self.nodeGateway.request(
method: "node.pending.ack",
paramsJSON: paramsJSON,
timeoutSeconds: 6)
return true
} catch {
self.pendingActionLogger.error(
"Pending action ack failed trigger=\(trigger, privacy: .public) "
+ "id=\(id, privacy: .public) command=\(command, privacy: .public) "
+ "error=\(String(describing: error), privacy: .public)")
return false
}
}
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
@@ -2843,6 +2941,19 @@ extension NodeAppModel {
self.gatewayConnected = connected
}
func _test_applyPendingForegroundNodeActions(
_ actions: [(id: String, command: String, paramsJSON: String?)]) async
{
let mapped = actions.map { action in
PendingForegroundNodeAction(
id: action.id,
command: action.command,
paramsJSON: action.paramsJSON,
enqueuedAtMs: nil)
}
await self.applyPendingForegroundNodeActions(mapped, trigger: "test")
}
static func _test_currentDeepLinkKey() -> String {
self.expectedDeepLinkKey()
}