Skip to content

Commit e806c47

Browse files
authored
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`
1 parent 38543d8 commit e806c47

File tree

12 files changed

+604
-1
lines changed

12 files changed

+604
-1
lines changed

apps/ios/Sources/Model/NodeAppModel.swift

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ final class NodeAppModel {
5757

5858
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
5959
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
60+
private let pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction")
6061
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
6162
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
6263
enum CameraHUDKind {
@@ -130,6 +131,7 @@ final class NodeAppModel {
130131
private var backgroundReconnectLeaseUntil: Date?
131132
private var lastSignificantLocationWakeAt: Date?
132133
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
134+
private var pendingForegroundActionDrainInFlight = false
133135

134136
private var gatewayConnected = false
135137
private var operatorConnected = false
@@ -329,6 +331,9 @@ final class NodeAppModel {
329331
}
330332
await self.talkMode.resumeAfterBackground(wasSuspended: suspended, wasKeptActive: keptActive)
331333
}
334+
Task { [weak self] in
335+
await self?.resumePendingForegroundNodeActionsIfNeeded(trigger: "scene_active")
336+
}
332337
}
333338
if phase == .active, self.reconnectAfterBackgroundArmed {
334339
self.reconnectAfterBackgroundArmed = false
@@ -2098,6 +2103,22 @@ private extension NodeAppModel {
20982103
}
20992104

21002105
extension NodeAppModel {
2106+
private struct PendingForegroundNodeAction: Decodable {
2107+
var id: String
2108+
var command: String
2109+
var paramsJSON: String?
2110+
var enqueuedAtMs: Int?
2111+
}
2112+
2113+
private struct PendingForegroundNodeActionsResponse: Decodable {
2114+
var nodeId: String?
2115+
var actions: [PendingForegroundNodeAction]
2116+
}
2117+
2118+
private struct PendingForegroundNodeActionsAckRequest: Encodable {
2119+
var ids: [String]
2120+
}
2121+
21012122
private func refreshShareRouteFromGateway() async {
21022123
struct Params: Codable {
21032124
var includeGlobal: Bool
@@ -2195,6 +2216,83 @@ extension NodeAppModel {
21952216
func onNodeGatewayConnected() async {
21962217
await self.registerAPNsTokenIfNeeded()
21972218
await self.flushQueuedWatchRepliesIfConnected()
2219+
await self.resumePendingForegroundNodeActionsIfNeeded(trigger: "node_connected")
2220+
}
2221+
2222+
private func resumePendingForegroundNodeActionsIfNeeded(trigger: String) async {
2223+
guard !self.isBackgrounded else { return }
2224+
guard await self.isGatewayConnected() else { return }
2225+
guard !self.pendingForegroundActionDrainInFlight else { return }
2226+
2227+
self.pendingForegroundActionDrainInFlight = true
2228+
defer { self.pendingForegroundActionDrainInFlight = false }
2229+
2230+
do {
2231+
let payload = try await self.nodeGateway.request(
2232+
method: "node.pending.pull",
2233+
paramsJSON: "{}",
2234+
timeoutSeconds: 6)
2235+
let decoded = try JSONDecoder().decode(
2236+
PendingForegroundNodeActionsResponse.self,
2237+
from: payload)
2238+
guard !decoded.actions.isEmpty else { return }
2239+
self.pendingActionLogger.info(
2240+
"Pending actions pulled trigger=\(trigger, privacy: .public) "
2241+
+ "count=\(decoded.actions.count, privacy: .public)")
2242+
await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger)
2243+
} catch {
2244+
// Best-effort only.
2245+
}
2246+
}
2247+
2248+
private func applyPendingForegroundNodeActions(
2249+
_ actions: [PendingForegroundNodeAction],
2250+
trigger: String) async
2251+
{
2252+
for action in actions {
2253+
guard !self.isBackgrounded else {
2254+
self.pendingActionLogger.info(
2255+
"Pending action replay paused trigger=\(trigger, privacy: .public): app backgrounded")
2256+
return
2257+
}
2258+
let req = BridgeInvokeRequest(
2259+
id: action.id,
2260+
command: action.command,
2261+
paramsJSON: action.paramsJSON)
2262+
let result = await self.handleInvoke(req)
2263+
self.pendingActionLogger.info(
2264+
"Pending action replay trigger=\(trigger, privacy: .public) "
2265+
+ "id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) "
2266+
+ "ok=\(result.ok, privacy: .public)")
2267+
guard result.ok else { return }
2268+
let acked = await self.ackPendingForegroundNodeAction(
2269+
id: action.id,
2270+
trigger: trigger,
2271+
command: action.command)
2272+
guard acked else { return }
2273+
}
2274+
}
2275+
2276+
private func ackPendingForegroundNodeAction(
2277+
id: String,
2278+
trigger: String,
2279+
command: String) async -> Bool
2280+
{
2281+
do {
2282+
let payload = try JSONEncoder().encode(PendingForegroundNodeActionsAckRequest(ids: [id]))
2283+
let paramsJSON = String(decoding: payload, as: UTF8.self)
2284+
_ = try await self.nodeGateway.request(
2285+
method: "node.pending.ack",
2286+
paramsJSON: paramsJSON,
2287+
timeoutSeconds: 6)
2288+
return true
2289+
} catch {
2290+
self.pendingActionLogger.error(
2291+
"Pending action ack failed trigger=\(trigger, privacy: .public) "
2292+
+ "id=\(id, privacy: .public) command=\(command, privacy: .public) "
2293+
+ "error=\(String(describing: error), privacy: .public)")
2294+
return false
2295+
}
21982296
}
21992297

22002298
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
@@ -2843,6 +2941,19 @@ extension NodeAppModel {
28432941
self.gatewayConnected = connected
28442942
}
28452943

2944+
func _test_applyPendingForegroundNodeActions(
2945+
_ actions: [(id: String, command: String, paramsJSON: String?)]) async
2946+
{
2947+
let mapped = actions.map { action in
2948+
PendingForegroundNodeAction(
2949+
id: action.id,
2950+
command: action.command,
2951+
paramsJSON: action.paramsJSON,
2952+
enqueuedAtMs: nil)
2953+
}
2954+
await self.applyPendingForegroundNodeActions(mapped, trigger: "test")
2955+
}
2956+
28462957
static func _test_currentDeepLinkKey() -> String {
28472958
self.expectedDeepLinkKey()
28482959
}

apps/ios/Tests/NodeAppModelInvokeTests.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,41 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
179179
#expect(payload?["result"] as? String == "2")
180180
}
181181

182+
@Test @MainActor func pendingForegroundActionsReplayCanvasNavigate() async throws {
183+
let appModel = NodeAppModel()
184+
let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/")
185+
let navData = try JSONEncoder().encode(navigateParams)
186+
let navJSON = String(decoding: navData, as: UTF8.self)
187+
188+
await appModel._test_applyPendingForegroundNodeActions([
189+
(
190+
id: "pending-nav-1",
191+
command: OpenClawCanvasCommand.navigate.rawValue,
192+
paramsJSON: navJSON
193+
),
194+
])
195+
196+
#expect(appModel.screen.urlString == "http://example.com/")
197+
}
198+
199+
@Test @MainActor func pendingForegroundActionsDoNotApplyWhileBackgrounded() async throws {
200+
let appModel = NodeAppModel()
201+
appModel.setScenePhase(.background)
202+
let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/")
203+
let navData = try JSONEncoder().encode(navigateParams)
204+
let navJSON = String(decoding: navData, as: UTF8.self)
205+
206+
await appModel._test_applyPendingForegroundNodeActions([
207+
(
208+
id: "pending-nav-bg",
209+
command: OpenClawCanvasCommand.navigate.rawValue,
210+
paramsJSON: navJSON
211+
),
212+
])
213+
214+
#expect(appModel.screen.urlString.isEmpty)
215+
}
216+
182217
@Test @MainActor func handleInvokeA2UICommandsFailWhenHostMissing() async throws {
183218
let appModel = NodeAppModel()
184219

apps/macos/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,20 @@ public struct NodeRenameParams: Codable, Sendable {
836836

837837
public struct NodeListParams: Codable, Sendable {}
838838

839+
public struct NodePendingAckParams: Codable, Sendable {
840+
public let ids: [String]
841+
842+
public init(
843+
ids: [String])
844+
{
845+
self.ids = ids
846+
}
847+
848+
private enum CodingKeys: String, CodingKey {
849+
case ids
850+
}
851+
}
852+
839853
public struct NodeDescribeParams: Codable, Sendable {
840854
public let nodeid: String
841855

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,20 @@ public struct NodeRenameParams: Codable, Sendable {
836836

837837
public struct NodeListParams: Codable, Sendable {}
838838

839+
public struct NodePendingAckParams: Codable, Sendable {
840+
public let ids: [String]
841+
842+
public init(
843+
ids: [String])
844+
{
845+
self.ids = ids
846+
}
847+
848+
private enum CodingKeys: String, CodingKey {
849+
case ids
850+
}
851+
}
852+
839853
public struct NodeDescribeParams: Codable, Sendable {
840854
public let nodeid: String
841855

src/gateway/method-scopes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const NODE_ROLE_METHODS = new Set([
2323
"node.invoke.result",
2424
"node.event",
2525
"node.canvas.capability.refresh",
26+
"node.pending.pull",
27+
"node.pending.ack",
2628
"skills.bins",
2729
]);
2830

src/gateway/protocol/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ import {
146146
NodeInvokeResultParamsSchema,
147147
type NodeListParams,
148148
NodeListParamsSchema,
149+
type NodePendingAckParams,
150+
NodePendingAckParamsSchema,
149151
type NodePairApproveParams,
150152
NodePairApproveParamsSchema,
151153
type NodePairListParams,
@@ -285,6 +287,9 @@ export const validateNodePairVerifyParams = ajv.compile<NodePairVerifyParams>(
285287
);
286288
export const validateNodeRenameParams = ajv.compile<NodeRenameParams>(NodeRenameParamsSchema);
287289
export const validateNodeListParams = ajv.compile<NodeListParams>(NodeListParamsSchema);
290+
export const validateNodePendingAckParams = ajv.compile<NodePendingAckParams>(
291+
NodePendingAckParamsSchema,
292+
);
288293
export const validateNodeDescribeParams = ajv.compile<NodeDescribeParams>(NodeDescribeParamsSchema);
289294
export const validateNodeInvokeParams = ajv.compile<NodeInvokeParams>(NodeInvokeParamsSchema);
290295
export const validateNodeInvokeResultParams = ajv.compile<NodeInvokeResultParams>(
@@ -465,6 +470,7 @@ export {
465470
NodePairRejectParamsSchema,
466471
NodePairVerifyParamsSchema,
467472
NodeListParamsSchema,
473+
NodePendingAckParamsSchema,
468474
NodeInvokeParamsSchema,
469475
SessionsListParamsSchema,
470476
SessionsPreviewParamsSchema,

src/gateway/protocol/schema/nodes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ export const NodeRenameParamsSchema = Type.Object(
4343

4444
export const NodeListParamsSchema = Type.Object({}, { additionalProperties: false });
4545

46+
export const NodePendingAckParamsSchema = Type.Object(
47+
{
48+
ids: Type.Array(NonEmptyString, { minItems: 1 }),
49+
},
50+
{ additionalProperties: false },
51+
);
52+
4653
export const NodeDescribeParamsSchema = Type.Object(
4754
{ nodeId: NonEmptyString },
4855
{ additionalProperties: false },

src/gateway/protocol/schema/protocol-schemas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ import {
118118
NodeInvokeResultParamsSchema,
119119
NodeInvokeRequestEventSchema,
120120
NodeListParamsSchema,
121+
NodePendingAckParamsSchema,
121122
NodePairApproveParamsSchema,
122123
NodePairListParamsSchema,
123124
NodePairRejectParamsSchema,
@@ -180,6 +181,7 @@ export const ProtocolSchemas = {
180181
NodePairVerifyParams: NodePairVerifyParamsSchema,
181182
NodeRenameParams: NodeRenameParamsSchema,
182183
NodeListParams: NodeListParamsSchema,
184+
NodePendingAckParams: NodePendingAckParamsSchema,
183185
NodeDescribeParams: NodeDescribeParamsSchema,
184186
NodeInvokeParams: NodeInvokeParamsSchema,
185187
NodeInvokeResultParams: NodeInvokeResultParamsSchema,

src/gateway/protocol/schema/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export type NodePairRejectParams = SchemaType<"NodePairRejectParams">;
2727
export type NodePairVerifyParams = SchemaType<"NodePairVerifyParams">;
2828
export type NodeRenameParams = SchemaType<"NodeRenameParams">;
2929
export type NodeListParams = SchemaType<"NodeListParams">;
30+
export type NodePendingAckParams = SchemaType<"NodePendingAckParams">;
3031
export type NodeDescribeParams = SchemaType<"NodeDescribeParams">;
3132
export type NodeInvokeParams = SchemaType<"NodeInvokeParams">;
3233
export type NodeInvokeResultParams = SchemaType<"NodeInvokeResultParams">;

src/gateway/server-methods-list.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ const BASE_METHODS = [
7777
"node.list",
7878
"node.describe",
7979
"node.invoke",
80+
"node.pending.pull",
81+
"node.pending.ack",
8082
"node.invoke.result",
8183
"node.event",
8284
"node.canvas.capability.refresh",

0 commit comments

Comments
 (0)