mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(apps): native session branch switcher with branch-safe durable outbox (#112056)
Branch menu (list/switch) for macOS+iOS shared chat via sessions.branches.*; rewind/fork/switch gated on run activity and pending outbox work through a durable session-mutation lease; outbox rows carry branch-epoch ownership with local-only flush checks, atomic confirm/park transitions, attempt-versioned delivery callbacks, fresh retry identity for possibly-accepted rows, and epoch-guarded branch evidence; 21-locale native translations for new strings.
This commit is contained in:
committed by
GitHub
parent
f0793b7e60
commit
8f31892b55
@@ -38243,7 +38243,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 854,
|
||||
"line": 855,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Talk mode uses the primary Gateway window",
|
||||
"surface": "apple",
|
||||
@@ -38251,7 +38251,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 856,
|
||||
"line": 857,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Talk mode off",
|
||||
"surface": "apple",
|
||||
@@ -38259,7 +38259,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 858,
|
||||
"line": 859,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Talk mode paused",
|
||||
"surface": "apple",
|
||||
@@ -38267,7 +38267,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 861,
|
||||
"line": 862,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Talk mode ready",
|
||||
"surface": "apple",
|
||||
@@ -38275,7 +38275,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 862,
|
||||
"line": 863,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Listening",
|
||||
"surface": "apple",
|
||||
@@ -38283,7 +38283,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 863,
|
||||
"line": 864,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Thinking",
|
||||
"surface": "apple",
|
||||
@@ -38291,7 +38291,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 864,
|
||||
"line": 865,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Speaking",
|
||||
"surface": "apple",
|
||||
@@ -38299,7 +38299,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 868,
|
||||
"line": 869,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "What would you like to work on?",
|
||||
"surface": "apple",
|
||||
@@ -38307,7 +38307,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 872,
|
||||
"line": 873,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Check OpenClaw status",
|
||||
"surface": "apple",
|
||||
@@ -38315,7 +38315,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 873,
|
||||
"line": 874,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Summarize the current OpenClaw status and tell me what needs attention.",
|
||||
"surface": "apple",
|
||||
@@ -38323,7 +38323,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 876,
|
||||
"line": 877,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "What can you do?",
|
||||
"surface": "apple",
|
||||
@@ -38331,7 +38331,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 877,
|
||||
"line": 878,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Show me what you can help with on this Mac right now.",
|
||||
"surface": "apple",
|
||||
@@ -38339,7 +38339,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 880,
|
||||
"line": 881,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Catch me up",
|
||||
"surface": "apple",
|
||||
@@ -38347,7 +38347,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 881,
|
||||
"line": 882,
|
||||
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
|
||||
"source": "Summarize what happened in my threads since yesterday.",
|
||||
"surface": "apple",
|
||||
@@ -38507,7 +38507,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 415,
|
||||
"line": 418,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Context %@%% used",
|
||||
"surface": "apple",
|
||||
@@ -38515,7 +38515,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 429,
|
||||
"line": 432,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "%@ (override)",
|
||||
"surface": "apple",
|
||||
@@ -38523,7 +38523,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 435,
|
||||
"line": 438,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Thinking",
|
||||
"surface": "apple",
|
||||
@@ -38531,7 +38531,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 455,
|
||||
"line": 458,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Full",
|
||||
"surface": "apple",
|
||||
@@ -38539,7 +38539,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 462,
|
||||
"line": 465,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Verbosity",
|
||||
"surface": "apple",
|
||||
@@ -38547,7 +38547,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 471,
|
||||
"line": 474,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Default (inherited)",
|
||||
"surface": "apple",
|
||||
@@ -38555,7 +38555,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 473,
|
||||
"line": 476,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "On",
|
||||
"surface": "apple",
|
||||
@@ -38563,7 +38563,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 474,
|
||||
"line": 477,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Off",
|
||||
"surface": "apple",
|
||||
@@ -38571,7 +38571,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 476,
|
||||
"line": 479,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Fast",
|
||||
"surface": "apple",
|
||||
@@ -38579,7 +38579,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 481,
|
||||
"line": 484,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Fast responses",
|
||||
"surface": "apple",
|
||||
@@ -38587,7 +38587,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 500,
|
||||
"line": 503,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Pinned",
|
||||
"surface": "apple",
|
||||
@@ -38595,7 +38595,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 508,
|
||||
"line": 511,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Recent",
|
||||
"surface": "apple",
|
||||
@@ -38603,7 +38603,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 527,
|
||||
"line": 530,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Model",
|
||||
"surface": "apple",
|
||||
@@ -38611,7 +38611,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 544,
|
||||
"line": 547,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Default",
|
||||
"surface": "apple",
|
||||
@@ -38619,7 +38619,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 561,
|
||||
"line": 564,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Pin model",
|
||||
"surface": "apple",
|
||||
@@ -38627,7 +38627,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 561,
|
||||
"line": 564,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Unpin model",
|
||||
"surface": "apple",
|
||||
@@ -38635,7 +38635,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 575,
|
||||
"line": 578,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Thread",
|
||||
"surface": "apple",
|
||||
@@ -38643,7 +38643,31 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 631,
|
||||
"line": 619,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Branches",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.02f0965fdca227b2"
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 625,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Untitled branch",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.82ca9989fa432b38"
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 639,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "^[\\(count) message](inflect: true)",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.8d0ae98818aa39e8"
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 692,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Add Image",
|
||||
"surface": "apple",
|
||||
@@ -38651,7 +38675,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 632,
|
||||
"line": 693,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Attachments",
|
||||
"surface": "apple",
|
||||
@@ -38659,7 +38683,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 821,
|
||||
"line": 886,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Start",
|
||||
"surface": "apple",
|
||||
@@ -38667,7 +38691,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 821,
|
||||
"line": 886,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Stop",
|
||||
"surface": "apple",
|
||||
@@ -38675,7 +38699,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 932,
|
||||
"line": 997,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Loading commands",
|
||||
"surface": "apple",
|
||||
@@ -38683,7 +38707,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 941,
|
||||
"line": 1006,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Commands unavailable",
|
||||
"surface": "apple",
|
||||
@@ -38691,7 +38715,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 950,
|
||||
"line": 1015,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Retry",
|
||||
"surface": "apple",
|
||||
@@ -38699,7 +38723,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 959,
|
||||
"line": 1024,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "No matching commands",
|
||||
"surface": "apple",
|
||||
@@ -38707,7 +38731,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 1191,
|
||||
"line": 1256,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Stop response",
|
||||
"surface": "apple",
|
||||
@@ -38715,7 +38739,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 1216,
|
||||
"line": 1281,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Send message",
|
||||
"surface": "apple",
|
||||
@@ -38723,7 +38747,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 1230,
|
||||
"line": 1295,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Refresh",
|
||||
"surface": "apple",
|
||||
@@ -38731,7 +38755,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1356,
|
||||
"line": 1421,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Message…",
|
||||
"surface": "apple",
|
||||
@@ -40233,6 +40257,22 @@
|
||||
"surface": "apple",
|
||||
"id": "native.apple.f80bb83a659d23f4"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1248,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift",
|
||||
"source": "'queued', 'sending', 'awaiting_confirmation', 'failed'",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.c83fc1ba73fd0776"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1249,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift",
|
||||
"source": "'queued', 'sending', 'awaiting_confirmation'",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.ee4ea4d701fa5d07"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 80,
|
||||
@@ -40339,7 +40379,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1235,
|
||||
"line": 1233,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Reply",
|
||||
"surface": "apple",
|
||||
@@ -40347,7 +40387,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1245,
|
||||
"line": 1243,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "You",
|
||||
"surface": "apple",
|
||||
@@ -40355,7 +40395,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1247,
|
||||
"line": 1245,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Assistant",
|
||||
"surface": "apple",
|
||||
@@ -40363,7 +40403,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 1313,
|
||||
"line": 1311,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Loading chat",
|
||||
"surface": "apple",
|
||||
@@ -40371,7 +40411,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 1393,
|
||||
"line": 1391,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
|
||||
"source": "Dismiss",
|
||||
"surface": "apple",
|
||||
@@ -40451,7 +40491,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 743,
|
||||
"line": 742,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Sending.swift",
|
||||
"source": "delivery unconfirmed",
|
||||
"surface": "apple",
|
||||
@@ -40459,7 +40499,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 743,
|
||||
"line": 742,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Sending.swift",
|
||||
"source": "queued after route change",
|
||||
"surface": "apple",
|
||||
@@ -40467,7 +40507,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 84,
|
||||
"line": 90,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+SessionActions.swift",
|
||||
"source": "Remove attachments or wait for delivery to resolve before starting a new chat.",
|
||||
"surface": "apple",
|
||||
@@ -40475,7 +40515,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 229,
|
||||
"line": 235,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+SessionActions.swift",
|
||||
"source": "Gateway changed before the thread operation started.",
|
||||
"surface": "apple",
|
||||
@@ -40483,7 +40523,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1085,
|
||||
"line": 1212,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift",
|
||||
"source": "Remove attachments or wait for delivery to resolve before switching chats.",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -439,6 +439,27 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
return try JSONDecoder().decode(OpenClawChatForkAtMessageResponse.self, from: response)
|
||||
}
|
||||
|
||||
func listSessionBranches(
|
||||
sessionKey: String,
|
||||
agentID: String?) async throws -> OpenClawChatSessionBranchesResponse
|
||||
{
|
||||
let target = self.sessionTarget(for: sessionKey, overrideAgentID: agentID)
|
||||
let request = OpenClawChatGatewayRequests.listSessionBranches(
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: target.agentID)
|
||||
let response = try await self.gateway.request(request)
|
||||
return try JSONDecoder().decode(OpenClawChatSessionBranchesResponse.self, from: response)
|
||||
}
|
||||
|
||||
func switchSessionBranch(sessionKey: String, agentID: String?, leafEntryId: String) async throws {
|
||||
let target = self.sessionTarget(for: sessionKey)
|
||||
let request = OpenClawChatGatewayRequests.switchSessionBranch(
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: agentID ?? target.agentID,
|
||||
leafEntryId: leafEntryId)
|
||||
_ = try await self.requestSessionMutation(request)
|
||||
}
|
||||
|
||||
func setActiveSessionKey(_ sessionKey: String) async throws {
|
||||
let target = self.sessionTarget(for: sessionKey)
|
||||
let request = OpenClawChatGatewayRequests.subscribeSessionMessages(
|
||||
|
||||
@@ -152,6 +152,27 @@ extension MacGatewayChatTransport {
|
||||
return try JSONDecoder().decode(OpenClawChatForkAtMessageResponse.self, from: data)
|
||||
}
|
||||
|
||||
func listSessionBranches(
|
||||
sessionKey: String,
|
||||
agentID: String?) async throws -> OpenClawChatSessionBranchesResponse
|
||||
{
|
||||
let target = self.sessionTarget(for: sessionKey, overrideAgentID: agentID)
|
||||
let request = OpenClawChatGatewayRequests.listSessionBranches(
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: target.agentID)
|
||||
let data = try await self.requestSessionAction(request)
|
||||
return try JSONDecoder().decode(OpenClawChatSessionBranchesResponse.self, from: data)
|
||||
}
|
||||
|
||||
func switchSessionBranch(sessionKey: String, agentID: String?, leafEntryId: String) async throws {
|
||||
let target = self.sessionTarget(for: sessionKey)
|
||||
let request = OpenClawChatGatewayRequests.switchSessionBranch(
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: agentID ?? target.agentID,
|
||||
leafEntryId: leafEntryId)
|
||||
_ = try await self.requestSessionAction(request)
|
||||
}
|
||||
|
||||
static func rewindSessionRequest(
|
||||
sessionKey: String,
|
||||
agentID: String?,
|
||||
|
||||
@@ -128,10 +128,11 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
}
|
||||
}
|
||||
|
||||
func sessionTarget(for sessionKey: String) -> SessionTarget {
|
||||
func sessionTarget(for sessionKey: String, overrideAgentID: String? = nil) -> SessionTarget {
|
||||
OpenClawChatSessionTarget.resolve(
|
||||
sessionKey,
|
||||
selectedAgentID: self.routingIdentity.currentAgentID(),
|
||||
overrideAgentID: overrideAgentID,
|
||||
policy: .preserveBareKeys)
|
||||
}
|
||||
|
||||
|
||||
@@ -960,7 +960,7 @@ struct GatewayProcessManagerTests {
|
||||
task.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id)))
|
||||
})
|
||||
})
|
||||
let url = try #require(URL(string: "ws://example.invalid"))
|
||||
let url = try #require(URL(string: "ws://127.0.0.1:9"))
|
||||
let connection = GatewayConnection(
|
||||
configProvider: { (url: url, token: nil, password: nil) },
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
@@ -1120,7 +1120,7 @@ struct GatewayProcessManagerTests {
|
||||
task.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id)))
|
||||
})
|
||||
})
|
||||
let url = try #require(URL(string: "ws://example.invalid"))
|
||||
let url = try #require(URL(string: "ws://127.0.0.1:9"))
|
||||
let connection = GatewayConnection(
|
||||
configProvider: { (url: url, token: nil, password: nil) },
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
|
||||
@@ -355,6 +355,9 @@ struct OpenClawChatComposer: View {
|
||||
HStack(spacing: 8) {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 5) {
|
||||
if self.viewModel.sessionBranches.count > 1 {
|
||||
self.branchMenu
|
||||
}
|
||||
if self.showsSessionSwitcher {
|
||||
self.sessionPicker
|
||||
if self.viewModel.showsThinkingPicker {
|
||||
@@ -582,6 +585,64 @@ struct OpenClawChatComposer: View {
|
||||
.help("Thread")
|
||||
}
|
||||
|
||||
private var branchMenu: some View {
|
||||
Menu {
|
||||
ForEach(self.viewModel.sessionBranches) { branch in
|
||||
Button {
|
||||
guard !branch.active else { return }
|
||||
Task { await self.viewModel.switchToBranch(branch.leafEntryId) }
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.branchTitle(branch))
|
||||
.font(OpenClawChatTypography.captionSemiBold)
|
||||
Text(self.branchMetadata(branch))
|
||||
.font(OpenClawChatTypography.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if branch.active {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(branch.active)
|
||||
}
|
||||
.task {
|
||||
await self.viewModel.refreshSessionBranchesForMenuPresentation()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.triangle.branch")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.help("Branches")
|
||||
.accessibilityLabel("Branches")
|
||||
.disabled(!self.viewModel.canSwitchSessionBranch)
|
||||
}
|
||||
|
||||
private func branchTitle(_ branch: OpenClawChatSessionBranch) -> String {
|
||||
let headline = branch.headline.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return headline.isEmpty ? String(localized: "Untitled branch") : headline
|
||||
}
|
||||
|
||||
private func branchMetadata(_ branch: OpenClawChatSessionBranch) -> String {
|
||||
var parts = [Self.branchMessageCount(branch.messageCount)]
|
||||
if let updatedAt = branch.updatedAt,
|
||||
let date = try? Date(updatedAt, strategy: .iso8601)
|
||||
{
|
||||
parts.append(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
static func branchMessageCount(_ count: Int) -> String {
|
||||
String(AttributedString(localized: "^[\(count) message](inflect: true)").characters)
|
||||
}
|
||||
|
||||
private var messageSessionActionsDisabled: Bool {
|
||||
!self.viewModel.canPerformMessageSessionAction
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var attachmentPicker: some View {
|
||||
#if os(macOS)
|
||||
@@ -751,6 +812,10 @@ struct OpenClawChatComposer: View {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
self.cleanAttachmentMenu
|
||||
|
||||
if self.viewModel.sessionBranches.count > 1 {
|
||||
self.branchMenu
|
||||
}
|
||||
|
||||
self.editorOverlay
|
||||
.frame(maxWidth: .infinity, minHeight: self.cleanControlHeight, alignment: .leading)
|
||||
.layoutPriority(1)
|
||||
|
||||
@@ -359,6 +359,36 @@ public enum OpenClawChatGatewayRequests {
|
||||
timeoutMs: self.mutationTimeoutMs)
|
||||
}
|
||||
|
||||
public static func listSessionBranches(
|
||||
sessionKey: String,
|
||||
agentID: String?) -> OpenClawChatGatewayRequest
|
||||
{
|
||||
let params = self.sessionParams(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID,
|
||||
key: "sessionKey")
|
||||
return OpenClawChatGatewayRequest(
|
||||
method: "sessions.branches.list",
|
||||
params: params,
|
||||
timeoutMs: self.defaultTimeoutMs)
|
||||
}
|
||||
|
||||
public static func switchSessionBranch(
|
||||
sessionKey: String,
|
||||
agentID: String?,
|
||||
leafEntryId: String) -> OpenClawChatGatewayRequest
|
||||
{
|
||||
var params = self.sessionParams(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID,
|
||||
key: "sessionKey")
|
||||
self.add(leafEntryId, to: ¶ms, key: "leafEntryId")
|
||||
return OpenClawChatGatewayRequest(
|
||||
method: "sessions.branches.switch",
|
||||
params: params,
|
||||
timeoutMs: self.mutationTimeoutMs)
|
||||
}
|
||||
|
||||
public static func subscribeSessionMessages(
|
||||
sessionKey: String,
|
||||
agentID: String?) -> OpenClawChatGatewayRequest
|
||||
|
||||
@@ -571,6 +571,40 @@ public struct OpenClawChatForkAtMessageResponse: Codable, Sendable {
|
||||
public let editorText: String?
|
||||
}
|
||||
|
||||
public struct OpenClawChatSessionBranch: Codable, Sendable, Equatable, Identifiable {
|
||||
public let leafEntryId: String
|
||||
public let headline: String
|
||||
public let messageCount: Int
|
||||
public let updatedAt: String?
|
||||
public let active: Bool
|
||||
|
||||
public var id: String {
|
||||
self.leafEntryId
|
||||
}
|
||||
|
||||
public init(
|
||||
leafEntryId: String,
|
||||
headline: String,
|
||||
messageCount: Int,
|
||||
updatedAt: String?,
|
||||
active: Bool)
|
||||
{
|
||||
self.leafEntryId = leafEntryId
|
||||
self.headline = headline
|
||||
self.messageCount = messageCount
|
||||
self.updatedAt = updatedAt
|
||||
self.active = active
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawChatSessionBranchesResponse: Codable, Sendable {
|
||||
public let branches: [OpenClawChatSessionBranch]
|
||||
|
||||
public init(branches: [OpenClawChatSessionBranch]) {
|
||||
self.branches = branches
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawChatEventPayload: Codable, Sendable {
|
||||
public let runId: String?
|
||||
public let sessionKey: String?
|
||||
|
||||
@@ -5,7 +5,7 @@ import OSLog
|
||||
|
||||
private let cacheLogger = Logger(subsystem: "ai.openclaw", category: "OpenClawChatTranscriptCache")
|
||||
|
||||
private final class OutboxChangeHub: @unchecked Sendable {
|
||||
final class OutboxChangeHub: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var continuations: [UUID: AsyncStream<OpenClawChatOutboxChange>.Continuation] = [:]
|
||||
|
||||
@@ -96,16 +96,42 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache,
|
||||
public static let outboxUnknownTargetError = "delivery_target_unknown"
|
||||
public static let outboxChangedTargetError = "delivery_target_changed"
|
||||
|
||||
static func outboxDisplayError(_ lastError: String?) -> String? {
|
||||
guard let lastError,
|
||||
let marker = lastError.range(of: "\n# branch-park:")
|
||||
else { return lastError }
|
||||
return String(lastError[..<marker.lowerBound])
|
||||
}
|
||||
|
||||
private let databases: OpenClawClientDatabases
|
||||
public nonisolated let gatewayID: String
|
||||
private var isRetired = false
|
||||
private var hasRecoveredInterruptedSends = false
|
||||
private nonisolated let outboxChangeHub = OutboxChangeHub()
|
||||
private nonisolated var outboxChangeHub: OutboxChangeHub {
|
||||
self.databases.outboxChangeHub
|
||||
}
|
||||
|
||||
private nonisolated let storeChangeHub: OutboxChangeHub
|
||||
private nonisolated let storeChangeRelay: Task<Void, Never>
|
||||
|
||||
private nonisolated let canonicalMessageProofHub = CanonicalMessageProofHub()
|
||||
|
||||
init(databases: OpenClawClientDatabases, gatewayID: String) {
|
||||
self.databases = databases
|
||||
self.gatewayID = gatewayID
|
||||
let storeChangeHub = OutboxChangeHub()
|
||||
self.storeChangeHub = storeChangeHub
|
||||
let upstream = databases.outboxChangeHub.stream()
|
||||
self.storeChangeRelay = Task {
|
||||
for await change in upstream where change.gatewayID == gatewayID {
|
||||
storeChangeHub.yield(change)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
storeChangeRelay.cancel()
|
||||
storeChangeHub.finish()
|
||||
}
|
||||
|
||||
// MARK: - Gateway cache
|
||||
@@ -235,7 +261,7 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache,
|
||||
else { return true }
|
||||
return canonicalMessageIdempotencyKeys.contains(key)
|
||||
}
|
||||
await self.writeTranscript(sessionKey: sessionKey, agentID: agentID, messages: canonicalOnly)
|
||||
await writeTranscript(sessionKey: sessionKey, agentID: agentID, messages: canonicalOnly)
|
||||
}
|
||||
|
||||
public func mergeCanonicalTranscriptMessage(
|
||||
@@ -328,7 +354,8 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache,
|
||||
|
||||
public func retire() async {
|
||||
self.isRetired = true
|
||||
self.outboxChangeHub.finish()
|
||||
self.storeChangeRelay.cancel()
|
||||
self.storeChangeHub.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,8 +389,8 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
agentID: String,
|
||||
messages: [OpenClawChatMessage]) throws
|
||||
{
|
||||
let bounded = self.cacheableMessages(messages)
|
||||
let encoded = try bounded.map(self.encodeJSON)
|
||||
let bounded = cacheableMessages(messages)
|
||||
let encoded = try bounded.map(encodeJSON)
|
||||
try db.execute(
|
||||
sql: """
|
||||
DELETE FROM cached_transcripts
|
||||
@@ -421,7 +448,7 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
// MARK: - Client-state outbox
|
||||
|
||||
public nonisolated func changes() -> AsyncStream<OpenClawChatOutboxChange> {
|
||||
self.outboxChangeHub.stream()
|
||||
self.storeChangeHub.stream()
|
||||
}
|
||||
|
||||
public func enqueueCommand(_ command: OpenClawChatOutboxCommand) async -> Bool {
|
||||
@@ -448,13 +475,18 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
commandBytes: attachmentByteCount,
|
||||
queuedBytes: queuedBytes)
|
||||
else { return false }
|
||||
let scope = OpenClawChatOutboxScope(
|
||||
sessionKey: command.sessionKey,
|
||||
agentID: command.agentID)
|
||||
try Self.ensureBranchScope(db, gatewayID: gatewayID, scope: scope)
|
||||
let branchState = try Self.readBranchState(db, gatewayID: gatewayID, scope: scope)
|
||||
try db.execute(
|
||||
sql: """
|
||||
INSERT INTO outbox_commands(
|
||||
gateway_id, client_uuid, session_key, delivery_session_key,
|
||||
routing_contract, agent_id, text, thinking, created_at,
|
||||
status, retry_count, last_error, attachment_bytes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
status, attempt_version, branch_epoch, retry_count, last_error, attachment_bytes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
arguments: [
|
||||
gatewayID,
|
||||
@@ -462,11 +494,13 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
command.sessionKey,
|
||||
command.deliverySessionKey,
|
||||
command.routingContract ?? "",
|
||||
command.agentID ?? "",
|
||||
Self.normalizedAgentID(command.agentID),
|
||||
command.text,
|
||||
command.thinking,
|
||||
command.createdAt,
|
||||
command.status.rawValue,
|
||||
command.attemptVersion,
|
||||
branchState.epoch,
|
||||
command.retryCount,
|
||||
command.lastError ?? "",
|
||||
attachmentByteCount,
|
||||
@@ -519,13 +553,18 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
@discardableResult
|
||||
public func recoverInterruptedSends() async -> Bool {
|
||||
guard !self.isRetired else { return false }
|
||||
if self.hasRecoveredInterruptedSends { return true }
|
||||
if self.hasRecoveredInterruptedSends {
|
||||
return true
|
||||
}
|
||||
let gatewayID = self.gatewayID
|
||||
do {
|
||||
try await self.databases.stateQueue.write { db in
|
||||
// A send interrupted by process death may have reached the gateway;
|
||||
// keep that uncertainty so a post-park retry mints a fresh identity.
|
||||
try db.execute(
|
||||
sql: """
|
||||
UPDATE outbox_commands SET status = 'failed', last_error = ?
|
||||
UPDATE outbox_commands
|
||||
SET status = 'failed', last_error = ?, had_unacknowledged_send = 1
|
||||
WHERE gateway_id = ? AND status = 'sending'
|
||||
""",
|
||||
arguments: [Self.outboxUnconfirmedError, gatewayID])
|
||||
@@ -541,8 +580,11 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
guard !self.isRetired else { return nil }
|
||||
let gatewayID = self.gatewayID
|
||||
do {
|
||||
return try await self.databases.stateQueue.write { db in
|
||||
let result = try await databases.stateQueue.write { db -> (
|
||||
OpenClawChatOutboxCommand?,
|
||||
[OpenClawChatOutboxScope]) in
|
||||
try Self.applyOutboxStaleness(db, gatewayID: gatewayID)
|
||||
let expiredScopes = try Self.expireBranchSwitchLeases(db, gatewayID: gatewayID)
|
||||
let active = try Int.fetchOne(
|
||||
db,
|
||||
sql: """
|
||||
@@ -554,12 +596,18 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
let row = try Row.fetchOne(
|
||||
db,
|
||||
sql: """
|
||||
SELECT * FROM outbox_commands
|
||||
WHERE gateway_id = ? AND status = 'queued'
|
||||
SELECT c.*, s.branch_epoch AS scope_branch_epoch
|
||||
FROM outbox_commands c
|
||||
LEFT JOIN outbox_branch_scopes s
|
||||
ON s.gateway_id = c.gateway_id AND s.session_key = c.session_key
|
||||
AND s.agent_id = c.agent_id
|
||||
WHERE c.gateway_id = ? AND c.status = 'queued'
|
||||
AND s.switch_pending_since IS NULL
|
||||
AND COALESCE(s.needs_reconciliation, 1) = 0
|
||||
ORDER BY created_at, enqueue_sequence LIMIT 1
|
||||
""",
|
||||
arguments: [gatewayID])
|
||||
else { return nil }
|
||||
else { return (nil, expiredScopes) }
|
||||
let id: String = row["client_uuid"]
|
||||
try db.execute(
|
||||
sql: """
|
||||
@@ -567,24 +615,42 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
WHERE gateway_id = ? AND client_uuid = ? AND status = 'queued'
|
||||
""",
|
||||
arguments: [gatewayID, id])
|
||||
guard db.changesCount > 0 else { return nil }
|
||||
guard db.changesCount > 0 else { return (nil, expiredScopes) }
|
||||
var command = try Self.command(from: row, in: db, gatewayID: gatewayID)
|
||||
command.status = .sending
|
||||
return command
|
||||
return (command, expiredScopes)
|
||||
}
|
||||
for scope in result.1 {
|
||||
self.outboxChangeHub.yield(.invalidated(gatewayID: gatewayID, scope: scope))
|
||||
}
|
||||
return result.0
|
||||
} catch {
|
||||
cacheLogger.error("outbox claim failed: \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func markCommandQueued(id: String, retryCount: Int, lastError: String?) async {
|
||||
await self.updateCommandStatus(id: id, status: .queued, retryCount: retryCount, lastError: lastError)
|
||||
public func markCommandQueued(
|
||||
id: String,
|
||||
attemptVersion: Int,
|
||||
retryCount: Int,
|
||||
lastError: String?) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
await transitionClaimedCommand(
|
||||
id: id,
|
||||
attemptVersion: attemptVersion,
|
||||
status: .queued,
|
||||
retryCount: retryCount,
|
||||
lastError: lastError)
|
||||
}
|
||||
|
||||
public func markCommandAwaitingConfirmation(id: String) async -> OpenClawChatOutboxUpdateResult {
|
||||
await self.transitionClaimedCommand(
|
||||
public func markCommandAwaitingConfirmation(
|
||||
id: String,
|
||||
attemptVersion: Int) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
await transitionClaimedCommand(
|
||||
id: id,
|
||||
attemptVersion: attemptVersion,
|
||||
status: .awaitingConfirmation,
|
||||
retryCount: 0,
|
||||
lastError: nil)
|
||||
@@ -592,11 +658,13 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
|
||||
public func markCommandFailedIfPresent(
|
||||
id: String,
|
||||
attemptVersion: Int,
|
||||
retryCount: Int,
|
||||
lastError: String?) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
await self.transitionClaimedCommand(
|
||||
await transitionClaimedCommand(
|
||||
id: id,
|
||||
attemptVersion: attemptVersion,
|
||||
status: .failed,
|
||||
retryCount: retryCount,
|
||||
lastError: lastError)
|
||||
@@ -604,9 +672,11 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
|
||||
public func markCommandRetriedIfPresent(
|
||||
id: String,
|
||||
expectation: OpenClawChatOutboxRetryExpectation,
|
||||
agentID: String?,
|
||||
deliverySessionKey: String,
|
||||
routingContract: String) async -> OpenClawChatOutboxUpdateResult
|
||||
routingContract: String,
|
||||
replacementID: String?) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
guard !self.isRetired else { return .unavailable }
|
||||
let normalizedAgentID = agentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
||||
@@ -621,22 +691,63 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
let gatewayID = self.gatewayID
|
||||
do {
|
||||
return try await self.databases.stateQueue.write { db in
|
||||
guard let row = try Row.fetchOne(
|
||||
db,
|
||||
sql: """
|
||||
SELECT session_key, parked_was_accepted, had_unacknowledged_send, last_error
|
||||
FROM outbox_commands
|
||||
WHERE gateway_id = ? AND client_uuid = ? AND status = 'failed'
|
||||
AND attempt_version = ? AND retry_count = ? AND last_error = ?
|
||||
""",
|
||||
arguments: [
|
||||
gatewayID,
|
||||
id,
|
||||
expectation.attemptVersion,
|
||||
expectation.retryCount,
|
||||
expectation.lastError ?? "",
|
||||
])
|
||||
else { return .superseded }
|
||||
let previousSessionKey: String = row["session_key"]
|
||||
let retryScope = OpenClawChatOutboxScope(
|
||||
sessionKey: previousSessionKey,
|
||||
agentID: normalizedAgentID)
|
||||
try Self.ensureBranchScope(db, gatewayID: gatewayID, scope: retryScope)
|
||||
let branchState = try Self.readBranchState(db, gatewayID: gatewayID, scope: retryScope)
|
||||
let lastError: String = row["last_error"]
|
||||
let wasBranchParked = lastError.contains("\n# branch-park:")
|
||||
let parkedWasAccepted: Int = row["parked_was_accepted"]
|
||||
let hadUnacknowledgedSend: Int = row["had_unacknowledged_send"]
|
||||
let wasPossiblyAccepted = wasBranchParked &&
|
||||
(parkedWasAccepted != 0 || hadUnacknowledgedSend != 0)
|
||||
let normalizedReplacementID = replacementID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let nextID = normalizedReplacementID?.isEmpty == false ? normalizedReplacementID! : UUID().uuidString
|
||||
let updateID = wasPossiblyAccepted ? nextID : id
|
||||
try db.execute(
|
||||
sql: """
|
||||
UPDATE outbox_commands
|
||||
SET status = 'queued', retry_count = 0, last_error = '', created_at = ?,
|
||||
SET client_uuid = ?, status = 'queued',
|
||||
attempt_version = ?,
|
||||
branch_epoch = ?, parked_was_accepted = 0, had_unacknowledged_send = 0,
|
||||
retry_count = 0, last_error = '', created_at = ?,
|
||||
agent_id = ?, delivery_session_key = ?, routing_contract = ?
|
||||
WHERE gateway_id = ? AND client_uuid = ? AND status = 'failed'
|
||||
AND attempt_version = ? AND retry_count = ? AND last_error = ?
|
||||
""",
|
||||
arguments: [
|
||||
updateID,
|
||||
wasPossiblyAccepted ? 1 : expectation.attemptVersion + 1,
|
||||
branchState.epoch,
|
||||
Date().timeIntervalSince1970,
|
||||
normalizedAgentID,
|
||||
normalizedDeliverySessionKey,
|
||||
normalizedRoutingContract,
|
||||
gatewayID,
|
||||
id,
|
||||
expectation.attemptVersion,
|
||||
expectation.retryCount,
|
||||
expectation.lastError ?? "",
|
||||
])
|
||||
return db.changesCount > 0 ? .updated : .missing
|
||||
return db.changesCount > 0 ? .updated : .superseded
|
||||
}
|
||||
} catch {
|
||||
return .unavailable
|
||||
@@ -649,7 +760,7 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
let gatewayID = self.gatewayID
|
||||
let proofHub = self.canonicalMessageProofHub
|
||||
do {
|
||||
let (deleted, isProven) = try await self.databases.stateQueue.writeWithoutTransaction { db in
|
||||
let (deleted, isProven) = try await databases.stateQueue.writeWithoutTransaction { db in
|
||||
let isProven = proofHub.lockProofDecision(for: messageKey)
|
||||
defer { proofHub.unlockProofDecision() }
|
||||
var deleted = false
|
||||
@@ -667,38 +778,292 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
}
|
||||
guard deleted else { return isProven ? .confirmed : .missing }
|
||||
if isProven {
|
||||
self.outboxChangeHub.yield(.confirmed(id: id))
|
||||
self.outboxChangeHub.yield(.confirmed(gatewayID: gatewayID, id: id))
|
||||
return .confirmed
|
||||
}
|
||||
self.outboxChangeHub.yield(.canceled(id: id))
|
||||
self.outboxChangeHub.yield(.canceled(gatewayID: gatewayID, id: id))
|
||||
return .updated
|
||||
} catch {
|
||||
return .unavailable
|
||||
}
|
||||
}
|
||||
|
||||
public func confirmCommand(id: String) async -> OpenClawChatOutboxUpdateResult {
|
||||
public func confirmCommand(
|
||||
id: String,
|
||||
attemptVersion: Int) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
guard !self.isRetired else { return .unavailable }
|
||||
let gatewayID = self.gatewayID
|
||||
do {
|
||||
let deleted = try await self.databases.stateQueue.write { db in
|
||||
let deleted = try await databases.stateQueue.write { db in
|
||||
try db.execute(
|
||||
sql: "DELETE FROM outbox_commands WHERE gateway_id = ? AND client_uuid = ?",
|
||||
arguments: [gatewayID, id])
|
||||
sql: """
|
||||
DELETE FROM outbox_commands
|
||||
WHERE gateway_id = ? AND client_uuid = ? AND attempt_version = ?
|
||||
""",
|
||||
arguments: [gatewayID, id, attemptVersion])
|
||||
return db.changesCount > 0
|
||||
}
|
||||
guard deleted else { return .missing }
|
||||
self.outboxChangeHub.yield(.confirmed(id: id))
|
||||
self.outboxChangeHub.yield(.confirmed(gatewayID: gatewayID, id: id))
|
||||
return .updated
|
||||
} catch {
|
||||
return .unavailable
|
||||
}
|
||||
}
|
||||
|
||||
public func branchState(
|
||||
for scope: OpenClawChatOutboxScope) async -> OpenClawChatOutboxBranchState?
|
||||
{
|
||||
guard !self.isRetired else { return nil }
|
||||
let gatewayID = self.gatewayID
|
||||
do {
|
||||
return try await self.databases.stateQueue.write { db in
|
||||
try Self.ensureBranchScope(db, gatewayID: gatewayID, scope: scope)
|
||||
var state = try Self.readBranchState(db, gatewayID: gatewayID, scope: scope)
|
||||
state = try OpenClawChatOutboxBranchState(
|
||||
epoch: state.epoch,
|
||||
lastActiveLeafEntryID: state.lastActiveLeafEntryID,
|
||||
hadPendingCommands: Self.unconfirmedCommandCount(
|
||||
db, gatewayID: gatewayID, scope: scope, includingFailed: true) > 0,
|
||||
switchPendingSince: state.switchPendingSince,
|
||||
needsReconciliation: state.needsReconciliation,
|
||||
revision: state.revision)
|
||||
return state
|
||||
}
|
||||
} catch { return nil }
|
||||
}
|
||||
|
||||
public func beginBranchSwitch(_ scope: OpenClawChatOutboxScope) async -> Bool {
|
||||
guard !self.isRetired else { return false }
|
||||
let gatewayID = self.gatewayID
|
||||
do {
|
||||
let result = try await databases.stateQueue.write { db -> Int in
|
||||
try Self.ensureBranchScope(db, gatewayID: gatewayID, scope: scope)
|
||||
let expired = try Self.expireBranchSwitchLeases(db, gatewayID: gatewayID, scope: scope)
|
||||
guard expired.isEmpty else { return 1 }
|
||||
let state = try Self.readBranchState(db, gatewayID: gatewayID, scope: scope)
|
||||
guard !state.needsReconciliation,
|
||||
state.switchPendingSince == nil,
|
||||
try Self.unconfirmedCommandCount(db, gatewayID: gatewayID, scope: scope) == 0
|
||||
else { return 0 }
|
||||
try db.execute(
|
||||
sql: """
|
||||
UPDATE outbox_branch_scopes
|
||||
SET switch_pending_since = ?, branch_state_revision = branch_state_revision + 1
|
||||
WHERE gateway_id = ? AND session_key = ? AND agent_id = ?
|
||||
AND switch_pending_since IS NULL
|
||||
""",
|
||||
arguments: [
|
||||
Date().timeIntervalSince1970,
|
||||
gatewayID,
|
||||
scope.sessionKey,
|
||||
Self.normalizedAgentID(scope.agentID),
|
||||
])
|
||||
return db.changesCount > 0 ? 2 : 0
|
||||
}
|
||||
if result == 1 {
|
||||
self.outboxChangeHub.yield(.invalidated(gatewayID: gatewayID, scope: scope))
|
||||
}
|
||||
return result == 2
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
public func cancelBranchSwitch(_ scope: OpenClawChatOutboxScope) async -> Bool {
|
||||
guard !self.isRetired else { return false }
|
||||
let gatewayID = self.gatewayID
|
||||
do {
|
||||
let changed = try await databases.stateQueue.write { db -> Bool in
|
||||
try Self.ensureBranchScope(db, gatewayID: gatewayID, scope: scope)
|
||||
try db.execute(
|
||||
sql: """
|
||||
UPDATE outbox_branch_scopes
|
||||
SET switch_pending_since = NULL, branch_state_revision = branch_state_revision + 1
|
||||
WHERE gateway_id = ? AND session_key = ? AND agent_id = ?
|
||||
""",
|
||||
arguments: [gatewayID, scope.sessionKey, Self.normalizedAgentID(scope.agentID)])
|
||||
return db.changesCount > 0
|
||||
}
|
||||
if changed {
|
||||
self.outboxChangeHub.yield(.invalidated(gatewayID: gatewayID, scope: scope))
|
||||
}
|
||||
return true
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
public func demoteBranchSwitchToReconcile(_ scope: OpenClawChatOutboxScope) async -> Bool {
|
||||
guard !self.isRetired else { return false }
|
||||
let gatewayID = self.gatewayID
|
||||
do {
|
||||
let changed = try await databases.stateQueue.write { db -> Bool in
|
||||
try Self.ensureBranchScope(db, gatewayID: gatewayID, scope: scope)
|
||||
try db.execute(
|
||||
sql: """
|
||||
UPDATE outbox_branch_scopes
|
||||
SET switch_pending_since = NULL, needs_reconciliation = 1,
|
||||
branch_state_revision = branch_state_revision + 1
|
||||
WHERE gateway_id = ? AND session_key = ? AND agent_id = ?
|
||||
""",
|
||||
arguments: [gatewayID, scope.sessionKey, Self.normalizedAgentID(scope.agentID)])
|
||||
return db.changesCount > 0
|
||||
}
|
||||
if changed {
|
||||
self.outboxChangeHub.yield(.invalidated(gatewayID: gatewayID, scope: scope))
|
||||
}
|
||||
return true
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
public func updateLastActiveLeafEntryID(
|
||||
_ leafEntryID: String,
|
||||
expectedEpoch: Int,
|
||||
for scope: OpenClawChatOutboxScope) async -> Bool
|
||||
{
|
||||
let leaf = leafEntryID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !self.isRetired, !leaf.isEmpty else { return false }
|
||||
let gatewayID = self.gatewayID
|
||||
do {
|
||||
return try await self.databases.stateQueue.write { db in
|
||||
try Self.ensureBranchScope(db, gatewayID: gatewayID, scope: scope)
|
||||
try db.execute(
|
||||
sql: """
|
||||
UPDATE outbox_branch_scopes
|
||||
SET last_active_leaf_id = ?, branch_state_revision = branch_state_revision + 1
|
||||
WHERE gateway_id = ? AND session_key = ? AND agent_id = ?
|
||||
AND branch_epoch = ? AND switch_pending_since IS NULL
|
||||
AND needs_reconciliation = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM outbox_commands
|
||||
WHERE gateway_id = ? AND session_key = ? AND agent_id = ?
|
||||
AND status IN ('queued', 'sending', 'awaiting_confirmation')
|
||||
)
|
||||
""",
|
||||
arguments: [
|
||||
leaf,
|
||||
gatewayID,
|
||||
scope.sessionKey,
|
||||
Self.normalizedAgentID(scope.agentID),
|
||||
expectedEpoch,
|
||||
gatewayID,
|
||||
scope.sessionKey,
|
||||
Self.normalizedAgentID(scope.agentID),
|
||||
])
|
||||
return db.changesCount > 0
|
||||
}
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
public func reconcileBranchScope(
|
||||
_ scope: OpenClawChatOutboxScope,
|
||||
previousState: OpenClawChatOutboxBranchState,
|
||||
activeLeafEntryID: String?,
|
||||
branchLeafEntryIDs: Set<String>,
|
||||
activeTranscriptEntryIDs: Set<String> = [],
|
||||
lastError: String) async -> [OpenClawChatOutboxCommand]?
|
||||
{
|
||||
let leaf = activeLeafEntryID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !self.isRetired, activeLeafEntryID == nil || leaf?.isEmpty == false else { return nil }
|
||||
let gatewayID = self.gatewayID
|
||||
do {
|
||||
let result = try await databases.stateQueue.write { db -> ([OpenClawChatOutboxCommand], Bool) in
|
||||
try Self.ensureBranchScope(db, gatewayID: gatewayID, scope: scope)
|
||||
_ = try Self.expireBranchSwitchLeases(db, gatewayID: gatewayID, scope: scope)
|
||||
let state = try Self.readBranchState(db, gatewayID: gatewayID, scope: scope)
|
||||
guard state.revision == previousState.revision, state.switchPendingSince == nil else {
|
||||
throw DatabaseError(message: "stale branch reconciliation")
|
||||
}
|
||||
let pending = try Self.unconfirmedCommandCount(
|
||||
db, gatewayID: gatewayID, scope: scope, includingFailed: true)
|
||||
var invalidated = false
|
||||
// A cross-client switch can win after this reconciliation but before send reaches the gateway.
|
||||
// Sends, like the web client, carry no branch precondition and land on the active branch at arrival.
|
||||
// Close this only with a protocol-level expectedActiveLeaf precondition.
|
||||
if let leaf,
|
||||
let lastLeaf = previousState.lastActiveLeafEntryID,
|
||||
lastLeaf != leaf,
|
||||
branchLeafEntryIDs.contains(lastLeaf)
|
||||
{
|
||||
try Self.installConfirmedBranchChange(
|
||||
db,
|
||||
gatewayID: gatewayID,
|
||||
scope: scope,
|
||||
previousEpoch: state.epoch,
|
||||
activeLeafEntryID: leaf,
|
||||
lastError: lastError)
|
||||
invalidated = true
|
||||
} else {
|
||||
let advancedOnActivePath = previousState.lastActiveLeafEntryID.map {
|
||||
activeTranscriptEntryIDs.contains($0)
|
||||
} ?? false
|
||||
if previousState.lastActiveLeafEntryID != leaf,
|
||||
!advancedOnActivePath,
|
||||
pending > 0 || leaf == nil
|
||||
{
|
||||
try Self.parkPendingCommands(db, gatewayID: gatewayID, scope: scope, lastError: lastError)
|
||||
invalidated = true
|
||||
}
|
||||
try Self.writeBranchState(
|
||||
db,
|
||||
gatewayID: gatewayID,
|
||||
scope: scope,
|
||||
epoch: state.epoch,
|
||||
lastActiveLeafEntryID: leaf,
|
||||
expectedRevision: previousState.revision)
|
||||
}
|
||||
return try (Self.readCommands(db, gatewayID: gatewayID), invalidated)
|
||||
}
|
||||
if result.1 {
|
||||
self.outboxChangeHub.yield(.invalidated(gatewayID: gatewayID, scope: scope))
|
||||
}
|
||||
return result.0
|
||||
} catch { return nil }
|
||||
}
|
||||
|
||||
public func confirmBranchChange(
|
||||
_ scope: OpenClawChatOutboxScope,
|
||||
activeLeafEntryID: String,
|
||||
lastError: String) async -> [OpenClawChatOutboxCommand]?
|
||||
{
|
||||
let leaf = activeLeafEntryID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !self.isRetired, !leaf.isEmpty else { return nil }
|
||||
let gatewayID = self.gatewayID
|
||||
do {
|
||||
let result = try await databases.stateQueue.write { db -> ([OpenClawChatOutboxCommand], Bool) in
|
||||
try Self.ensureBranchScope(db, gatewayID: gatewayID, scope: scope)
|
||||
let state = try Self.readBranchState(db, gatewayID: gatewayID, scope: scope)
|
||||
let invalidated: Bool
|
||||
if state.lastActiveLeafEntryID == leaf {
|
||||
try Self.writeBranchState(
|
||||
db,
|
||||
gatewayID: gatewayID,
|
||||
scope: scope,
|
||||
epoch: state.epoch,
|
||||
lastActiveLeafEntryID: leaf)
|
||||
invalidated = state.switchPendingSince != nil
|
||||
} else {
|
||||
try Self.installConfirmedBranchChange(
|
||||
db,
|
||||
gatewayID: gatewayID,
|
||||
scope: scope,
|
||||
previousEpoch: state.epoch,
|
||||
activeLeafEntryID: leaf,
|
||||
lastError: lastError)
|
||||
invalidated = true
|
||||
}
|
||||
return try (Self.readCommands(db, gatewayID: gatewayID), invalidated)
|
||||
}
|
||||
if result.1 {
|
||||
self.outboxChangeHub.yield(.invalidated(gatewayID: gatewayID, scope: scope))
|
||||
}
|
||||
return result.0
|
||||
} catch { return nil }
|
||||
}
|
||||
}
|
||||
|
||||
extension OpenClawChatSQLiteTranscriptCache {
|
||||
private func transitionClaimedCommand(
|
||||
id: String,
|
||||
attemptVersion: Int,
|
||||
status: OpenClawChatOutboxCommand.Status,
|
||||
retryCount: Int,
|
||||
lastError: String?) async -> OpenClawChatOutboxUpdateResult
|
||||
@@ -709,19 +1074,24 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
}
|
||||
let gatewayID = self.gatewayID
|
||||
do {
|
||||
let updated = try await self.databases.stateQueue.write { db in
|
||||
let updated = try await databases.stateQueue.write { db in
|
||||
try db.execute(
|
||||
sql: """
|
||||
UPDATE outbox_commands
|
||||
SET status = ?, retry_count = ?, last_error = ?
|
||||
WHERE gateway_id = ? AND client_uuid = ? AND status = 'sending'
|
||||
SET status = ?, had_unacknowledged_send = 1,
|
||||
attempt_version = CASE WHEN ? = 'queued' THEN attempt_version + 1 ELSE attempt_version END,
|
||||
retry_count = ?, last_error = ?
|
||||
WHERE gateway_id = ? AND client_uuid = ? AND attempt_version = ?
|
||||
AND status = 'sending'
|
||||
""",
|
||||
arguments: [
|
||||
status.rawValue,
|
||||
status.rawValue,
|
||||
retryCount,
|
||||
lastError ?? "",
|
||||
gatewayID,
|
||||
id,
|
||||
attemptVersion,
|
||||
])
|
||||
return db.changesCount > 0
|
||||
}
|
||||
@@ -732,38 +1102,6 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCommandStatus(
|
||||
id: String,
|
||||
status: OpenClawChatOutboxCommand.Status,
|
||||
retryCount: Int,
|
||||
lastError: String?) async
|
||||
{
|
||||
guard !self.isRetired else {
|
||||
self.hasRecoveredInterruptedSends = false
|
||||
return
|
||||
}
|
||||
let gatewayID = self.gatewayID
|
||||
do {
|
||||
try await self.databases.stateQueue.write { db in
|
||||
try db.execute(
|
||||
sql: """
|
||||
UPDATE outbox_commands
|
||||
SET status = ?, retry_count = ?, last_error = ?
|
||||
WHERE gateway_id = ? AND client_uuid = ?
|
||||
""",
|
||||
arguments: [
|
||||
status.rawValue,
|
||||
retryCount,
|
||||
lastError ?? "",
|
||||
gatewayID,
|
||||
id,
|
||||
])
|
||||
}
|
||||
} catch {
|
||||
self.hasRecoveredInterruptedSends = false
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func applyOutboxStaleness(
|
||||
_ db: Database,
|
||||
gatewayID: String) throws
|
||||
@@ -794,8 +1132,13 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
let rows = try Row.fetchAll(
|
||||
db,
|
||||
sql: """
|
||||
SELECT * FROM outbox_commands WHERE gateway_id = ?
|
||||
ORDER BY created_at, enqueue_sequence
|
||||
SELECT c.*, s.branch_epoch AS scope_branch_epoch
|
||||
FROM outbox_commands c
|
||||
LEFT JOIN outbox_branch_scopes s
|
||||
ON s.gateway_id = c.gateway_id AND s.session_key = c.session_key
|
||||
AND s.agent_id = c.agent_id
|
||||
WHERE c.gateway_id = ?
|
||||
ORDER BY c.created_at, c.enqueue_sequence
|
||||
""",
|
||||
arguments: [gatewayID])
|
||||
return try rows.map { try self.command(from: $0, in: db, gatewayID: gatewayID) }
|
||||
@@ -833,15 +1176,215 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
sessionKey: row["session_key"],
|
||||
deliverySessionKey: row["delivery_session_key"],
|
||||
routingContract: row["routing_contract"],
|
||||
agentID: row["agent_id"],
|
||||
agentID: Self.optionalAgentID(row["agent_id"]),
|
||||
branchEpoch: row["branch_epoch"],
|
||||
scopeBranchEpoch: row["scope_branch_epoch"],
|
||||
text: row["text"],
|
||||
attachments: attachments,
|
||||
thinking: row["thinking"],
|
||||
createdAt: row["created_at"],
|
||||
status: status,
|
||||
attemptVersion: row["attempt_version"],
|
||||
retryCount: row["retry_count"],
|
||||
lastError: lastError.isEmpty ? nil : lastError)
|
||||
}
|
||||
|
||||
private nonisolated static func normalizedAgentID(_ agentID: String?) -> String {
|
||||
agentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
||||
}
|
||||
|
||||
private nonisolated static func optionalAgentID(_ agentID: String) -> String? {
|
||||
let normalized = self.normalizedAgentID(agentID)
|
||||
return normalized.isEmpty ? nil : normalized
|
||||
}
|
||||
|
||||
private nonisolated static func ensureBranchScope(
|
||||
_ db: Database,
|
||||
gatewayID: String,
|
||||
scope: OpenClawChatOutboxScope) throws
|
||||
{
|
||||
try db.execute(
|
||||
sql: """
|
||||
INSERT OR IGNORE INTO outbox_branch_scopes(
|
||||
gateway_id, session_key, agent_id, branch_epoch, last_active_leaf_id, needs_reconciliation
|
||||
) VALUES (?, ?, ?, 0, NULL, 0)
|
||||
""",
|
||||
arguments: [gatewayID, scope.sessionKey, self.normalizedAgentID(scope.agentID)])
|
||||
}
|
||||
|
||||
private nonisolated static func readBranchState(
|
||||
_ db: Database,
|
||||
gatewayID: String,
|
||||
scope: OpenClawChatOutboxScope) throws -> OpenClawChatOutboxBranchState
|
||||
{
|
||||
guard let row = try Row.fetchOne(
|
||||
db,
|
||||
sql: """
|
||||
SELECT branch_epoch, last_active_leaf_id, switch_pending_since, needs_reconciliation, branch_state_revision
|
||||
FROM outbox_branch_scopes
|
||||
WHERE gateway_id = ? AND session_key = ? AND agent_id = ?
|
||||
""",
|
||||
arguments: [gatewayID, scope.sessionKey, normalizedAgentID(scope.agentID)])
|
||||
else { throw DatabaseError(message: "missing branch scope") }
|
||||
return OpenClawChatOutboxBranchState(
|
||||
epoch: row["branch_epoch"],
|
||||
lastActiveLeafEntryID: row["last_active_leaf_id"],
|
||||
switchPendingSince: row["switch_pending_since"],
|
||||
needsReconciliation: row["needs_reconciliation"],
|
||||
revision: row["branch_state_revision"])
|
||||
}
|
||||
|
||||
/// `includingFailed` widens the count for branch-reconcile decisions: failed rows
|
||||
/// still hold a retryable idempotency identity, so a branch change must observe and
|
||||
/// park them. The mutation lease gate stays narrow — a visible failed message must
|
||||
/// not block rewind/fork/switch.
|
||||
private nonisolated static func unconfirmedCommandCount(
|
||||
_ db: Database,
|
||||
gatewayID: String,
|
||||
scope: OpenClawChatOutboxScope,
|
||||
includingFailed: Bool = false) throws -> Int
|
||||
{
|
||||
let statuses = includingFailed
|
||||
? "'queued', 'sending', 'awaiting_confirmation', 'failed'"
|
||||
: "'queued', 'sending', 'awaiting_confirmation'"
|
||||
return try Int.fetchOne(
|
||||
db,
|
||||
sql: """
|
||||
SELECT COUNT(*) FROM outbox_commands
|
||||
WHERE gateway_id = ? AND session_key = ? AND agent_id = ?
|
||||
AND status IN (\(statuses))
|
||||
""",
|
||||
arguments: [gatewayID, scope.sessionKey, self.normalizedAgentID(scope.agentID)]) ?? 0
|
||||
}
|
||||
|
||||
private nonisolated static func expireBranchSwitchLeases(
|
||||
_ db: Database,
|
||||
gatewayID: String,
|
||||
scope: OpenClawChatOutboxScope? = nil) throws -> [OpenClawChatOutboxScope]
|
||||
{
|
||||
let cutoff = Date().timeIntervalSince1970 - 5 * 60
|
||||
// if/else keeps these SQL literals out of the ternary shape the native
|
||||
// i18n extractor treats as user-facing conditional text.
|
||||
let sql: String
|
||||
let arguments: StatementArguments
|
||||
if let scope {
|
||||
sql = """
|
||||
SELECT session_key, agent_id FROM outbox_branch_scopes
|
||||
WHERE gateway_id = ? AND session_key = ? AND agent_id = ? AND switch_pending_since <= ?
|
||||
"""
|
||||
arguments = [gatewayID, scope.sessionKey, Self.normalizedAgentID(scope.agentID), cutoff]
|
||||
} else {
|
||||
sql = """
|
||||
SELECT session_key, agent_id FROM outbox_branch_scopes
|
||||
WHERE gateway_id = ? AND switch_pending_since <= ?
|
||||
"""
|
||||
arguments = [gatewayID, cutoff]
|
||||
}
|
||||
let rows = try Row.fetchAll(db, sql: sql, arguments: arguments)
|
||||
guard !rows.isEmpty else { return [] }
|
||||
for row in rows {
|
||||
let sessionKey: String = row["session_key"]
|
||||
let agentID: String = row["agent_id"]
|
||||
try db.execute(
|
||||
sql: """
|
||||
UPDATE outbox_branch_scopes
|
||||
SET switch_pending_since = NULL, needs_reconciliation = 1,
|
||||
branch_state_revision = branch_state_revision + 1
|
||||
WHERE gateway_id = ? AND session_key = ? AND agent_id = ?
|
||||
""",
|
||||
arguments: [gatewayID, sessionKey, agentID])
|
||||
}
|
||||
return rows.map { row in
|
||||
OpenClawChatOutboxScope(sessionKey: row["session_key"], agentID: row["agent_id"])
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func writeBranchState(
|
||||
_ db: Database,
|
||||
gatewayID: String,
|
||||
scope: OpenClawChatOutboxScope,
|
||||
epoch: Int,
|
||||
lastActiveLeafEntryID: String?,
|
||||
expectedRevision: Int? = nil) throws
|
||||
{
|
||||
try db.execute(
|
||||
sql: """
|
||||
UPDATE outbox_branch_scopes
|
||||
SET branch_epoch = ?, last_active_leaf_id = ?, switch_pending_since = NULL,
|
||||
needs_reconciliation = 0, branch_state_revision = branch_state_revision + 1
|
||||
WHERE gateway_id = ? AND session_key = ? AND agent_id = ?
|
||||
AND (? IS NULL OR branch_state_revision = ?)
|
||||
""",
|
||||
arguments: [
|
||||
epoch,
|
||||
lastActiveLeafEntryID,
|
||||
gatewayID,
|
||||
scope.sessionKey,
|
||||
self.normalizedAgentID(scope.agentID),
|
||||
expectedRevision,
|
||||
expectedRevision,
|
||||
])
|
||||
guard db.changesCount > 0 else { throw DatabaseError(message: "stale branch state") }
|
||||
}
|
||||
|
||||
private nonisolated static func installConfirmedBranchChange(
|
||||
_ db: Database,
|
||||
gatewayID: String,
|
||||
scope: OpenClawChatOutboxScope,
|
||||
previousEpoch: Int,
|
||||
activeLeafEntryID: String,
|
||||
lastError: String) throws
|
||||
{
|
||||
let nextEpoch = previousEpoch + 1
|
||||
try Self.writeBranchState(
|
||||
db,
|
||||
gatewayID: gatewayID,
|
||||
scope: scope,
|
||||
epoch: nextEpoch,
|
||||
lastActiveLeafEntryID: activeLeafEntryID)
|
||||
try db.execute(
|
||||
sql: """
|
||||
UPDATE outbox_commands
|
||||
SET parked_was_accepted = CASE
|
||||
WHEN status IN ('sending', 'awaiting_confirmation') OR had_unacknowledged_send = 1
|
||||
THEN 1 ELSE parked_was_accepted END,
|
||||
status = 'failed', last_error = ?
|
||||
WHERE gateway_id = ? AND session_key = ? AND agent_id = ?
|
||||
AND branch_epoch <> ?
|
||||
AND status IN ('queued', 'sending', 'awaiting_confirmation', 'failed')
|
||||
""",
|
||||
arguments: [
|
||||
lastError + "\n# branch-park:" + UUID().uuidString,
|
||||
gatewayID,
|
||||
scope.sessionKey,
|
||||
Self.normalizedAgentID(scope.agentID),
|
||||
nextEpoch,
|
||||
])
|
||||
}
|
||||
|
||||
private nonisolated static func parkPendingCommands(
|
||||
_ db: Database,
|
||||
gatewayID: String,
|
||||
scope: OpenClawChatOutboxScope,
|
||||
lastError: String) throws
|
||||
{
|
||||
try db.execute(
|
||||
sql: """
|
||||
UPDATE outbox_commands
|
||||
SET parked_was_accepted = CASE
|
||||
WHEN status IN ('sending', 'awaiting_confirmation') OR had_unacknowledged_send = 1
|
||||
THEN 1 ELSE parked_was_accepted END,
|
||||
status = 'failed', last_error = ?
|
||||
WHERE gateway_id = ? AND session_key = ? AND agent_id = ?
|
||||
AND status IN ('queued', 'sending', 'awaiting_confirmation', 'failed')
|
||||
""",
|
||||
arguments: [
|
||||
lastError + "\n# branch-park:" + UUID().uuidString,
|
||||
gatewayID,
|
||||
scope.sessionKey,
|
||||
self.normalizedAgentID(scope.agentID),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
extension OpenClawChatSQLiteTranscriptCache {
|
||||
@@ -935,10 +1478,6 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
.prefix(self.maxCachedSessions))
|
||||
}
|
||||
|
||||
private static func normalizedAgentID(_ agentID: String?) -> String {
|
||||
agentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
||||
}
|
||||
|
||||
private static func attachmentByteCount(_ attachments: [OpenClawChatOutboxAttachment]) -> Int? {
|
||||
var total = 0
|
||||
for attachment in attachments {
|
||||
|
||||
+228
-10
@@ -43,6 +43,56 @@ protocol OpenClawChatCanonicalTranscriptMerging: OpenClawChatTranscriptCache {
|
||||
canonicalMessageIdempotencyKey: String) async
|
||||
}
|
||||
|
||||
/// Durable branch ownership is scoped exactly like outbox delivery routing.
|
||||
public struct OpenClawChatOutboxScope: Hashable, Sendable {
|
||||
public let sessionKey: String
|
||||
public let agentID: String?
|
||||
|
||||
public init(sessionKey: String, agentID: String?) {
|
||||
self.sessionKey = sessionKey
|
||||
let normalizedAgentID = agentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
self.agentID = normalizedAgentID?.isEmpty == false ? normalizedAgentID : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted branch ownership captured before bootstrap can advance the transcript tip.
|
||||
public struct OpenClawChatOutboxBranchState: Equatable, Sendable {
|
||||
public let epoch: Int
|
||||
public let lastActiveLeafEntryID: String?
|
||||
public let hadPendingCommands: Bool
|
||||
public let switchPendingSince: TimeInterval?
|
||||
public let needsReconciliation: Bool
|
||||
public let revision: Int
|
||||
|
||||
public init(
|
||||
epoch: Int,
|
||||
lastActiveLeafEntryID: String?,
|
||||
hadPendingCommands: Bool = false,
|
||||
switchPendingSince: TimeInterval? = nil,
|
||||
needsReconciliation: Bool = false,
|
||||
revision: Int = 0)
|
||||
{
|
||||
self.epoch = epoch
|
||||
self.lastActiveLeafEntryID = lastActiveLeafEntryID
|
||||
self.hadPendingCommands = hadPendingCommands
|
||||
self.switchPendingSince = switchPendingSince
|
||||
self.needsReconciliation = needsReconciliation
|
||||
self.revision = revision
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawChatOutboxRetryExpectation: Equatable, Sendable {
|
||||
public let attemptVersion: Int
|
||||
public let retryCount: Int
|
||||
public let lastError: String?
|
||||
|
||||
public init(attemptVersion: Int, retryCount: Int, lastError: String?) {
|
||||
self.attemptVersion = attemptVersion
|
||||
self.retryCount = retryCount
|
||||
self.lastError = lastError
|
||||
}
|
||||
}
|
||||
|
||||
/// One attachment captured with a durable chat command.
|
||||
public struct OpenClawChatOutboxAttachment: Codable, Hashable, Sendable {
|
||||
public let type: String
|
||||
@@ -94,6 +144,10 @@ public struct OpenClawChatOutboxCommand: Hashable, Sendable, Identifiable {
|
||||
/// Durable routing owner, required for the literal `global` session and
|
||||
/// retained for ownership checks on canonical agent-scoped keys.
|
||||
public let agentID: String?
|
||||
/// Local branch generation captured when this delivery attempt was queued.
|
||||
public let branchEpoch: Int
|
||||
/// Scope epoch observed alongside this row snapshot.
|
||||
public let scopeBranchEpoch: Int?
|
||||
public let text: String
|
||||
/// Attachment bytes remain owned by SQLite until canonical history proves
|
||||
/// delivery or the user explicitly deletes the command.
|
||||
@@ -104,6 +158,9 @@ public struct OpenClawChatOutboxCommand: Hashable, Sendable, Identifiable {
|
||||
/// Seconds since 1970; flush order is strictly ascending `createdAt`.
|
||||
public let createdAt: Double
|
||||
public var status: Status
|
||||
/// Immutable ownership token for one delivery lifecycle. Every automatic
|
||||
/// or user-initiated retry increments it before another send can start.
|
||||
public let attemptVersion: Int
|
||||
public var retryCount: Int
|
||||
public var lastError: String?
|
||||
|
||||
@@ -113,11 +170,14 @@ public struct OpenClawChatOutboxCommand: Hashable, Sendable, Identifiable {
|
||||
deliverySessionKey: String? = nil,
|
||||
routingContract: String? = nil,
|
||||
agentID: String? = nil,
|
||||
branchEpoch: Int = 0,
|
||||
scopeBranchEpoch: Int? = nil,
|
||||
text: String,
|
||||
attachments: [OpenClawChatOutboxAttachment] = [],
|
||||
thinking: String,
|
||||
createdAt: Double,
|
||||
status: Status,
|
||||
attemptVersion: Int = 1,
|
||||
retryCount: Int,
|
||||
lastError: String?)
|
||||
{
|
||||
@@ -132,11 +192,14 @@ public struct OpenClawChatOutboxCommand: Hashable, Sendable, Identifiable {
|
||||
self.routingContract = normalizedRoutingContract?.isEmpty == false ? normalizedRoutingContract : nil
|
||||
let normalizedAgentID = agentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
self.agentID = normalizedAgentID?.isEmpty == false ? normalizedAgentID : nil
|
||||
self.branchEpoch = branchEpoch
|
||||
self.scopeBranchEpoch = scopeBranchEpoch ?? branchEpoch
|
||||
self.text = text
|
||||
self.attachments = attachments
|
||||
self.thinking = thinking
|
||||
self.createdAt = createdAt
|
||||
self.status = status
|
||||
self.attemptVersion = attemptVersion
|
||||
self.retryCount = retryCount
|
||||
self.lastError = lastError
|
||||
}
|
||||
@@ -146,12 +209,21 @@ public enum OpenClawChatOutboxUpdateResult: Equatable, Sendable {
|
||||
case updated
|
||||
case confirmed
|
||||
case missing
|
||||
case superseded
|
||||
case unavailable
|
||||
}
|
||||
|
||||
public enum OpenClawChatOutboxChange: Equatable, Sendable {
|
||||
case canceled(id: String)
|
||||
case confirmed(id: String)
|
||||
case canceled(gatewayID: String, id: String)
|
||||
case confirmed(gatewayID: String, id: String)
|
||||
case invalidated(gatewayID: String, scope: OpenClawChatOutboxScope)
|
||||
|
||||
var gatewayID: String {
|
||||
switch self {
|
||||
case let .canceled(gatewayID, _), let .confirmed(gatewayID, _), let .invalidated(gatewayID, _):
|
||||
gatewayID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Durable offline outbox for chat commands. Implementations expose one
|
||||
@@ -178,31 +250,177 @@ public protocol OpenClawChatCommandOutbox: Sendable {
|
||||
/// Atomically claims the oldest queued row when no other row is sending.
|
||||
/// Nil means another flusher owns the queue or no deliverable row remains.
|
||||
func claimNextCommand() async -> OpenClawChatOutboxCommand?
|
||||
func markCommandQueued(id: String, retryCount: Int, lastError: String?) async
|
||||
func markCommandAwaitingConfirmation(id: String) async -> OpenClawChatOutboxUpdateResult
|
||||
/// Safe automatic retry: only the completing attempt may requeue the row,
|
||||
/// and a successful requeue mints the next attempt version atomically.
|
||||
func markCommandQueued(
|
||||
id: String,
|
||||
attemptVersion: Int,
|
||||
retryCount: Int,
|
||||
lastError: String?) async -> OpenClawChatOutboxUpdateResult
|
||||
func markCommandAwaitingConfirmation(
|
||||
id: String,
|
||||
attemptVersion: Int) async -> OpenClawChatOutboxUpdateResult
|
||||
/// Result-bearing terminal transition for callers that must stop their
|
||||
/// FIFO when durable storage is unavailable.
|
||||
func markCommandFailedIfPresent(
|
||||
id: String,
|
||||
attemptVersion: Int,
|
||||
retryCount: Int,
|
||||
lastError: String?) async -> OpenClawChatOutboxUpdateResult
|
||||
/// Result-bearing retry used to adopt an unowned legacy alias into the
|
||||
/// canonical target explicitly selected by the user.
|
||||
/// Captures the persisted scope state before bootstrap history can advance its tip.
|
||||
func branchState(for scope: OpenClawChatOutboxScope) async -> OpenClawChatOutboxBranchState?
|
||||
/// Installs the cross-view-model transcript-mutation barrier only when no
|
||||
/// delivery is already unresolved for the scope.
|
||||
func beginBranchSwitch(_ scope: OpenClawChatOutboxScope) async -> Bool
|
||||
/// Rolls back a barrier when the server rejected the switch.
|
||||
func cancelBranchSwitch(_ scope: OpenClawChatOutboxScope) async -> Bool
|
||||
/// The server changed the branch but local refresh failed; block replay
|
||||
/// until reconciliation establishes the active leaf.
|
||||
func demoteBranchSwitchToReconcile(_ scope: OpenClawChatOutboxScope) async -> Bool
|
||||
/// Reconciles a bootstrap branch snapshot before automatic replay is enabled.
|
||||
/// A nil active leaf represents a successfully listed empty transcript.
|
||||
func reconcileBranchScope(
|
||||
_ scope: OpenClawChatOutboxScope,
|
||||
previousState: OpenClawChatOutboxBranchState,
|
||||
activeLeafEntryID: String?,
|
||||
branchLeafEntryIDs: Set<String>,
|
||||
activeTranscriptEntryIDs: Set<String>,
|
||||
lastError: String) async -> [OpenClawChatOutboxCommand]?
|
||||
/// Atomically records a confirmed server-side branch change and parks rows
|
||||
/// stamped with the superseded generation.
|
||||
func confirmBranchChange(
|
||||
_ scope: OpenClawChatOutboxScope,
|
||||
activeLeafEntryID: String,
|
||||
lastError: String) async -> [OpenClawChatOutboxCommand]?
|
||||
/// Advances the observed transcript tip only while branch ownership still
|
||||
/// matches the epoch captured by the caller.
|
||||
func updateLastActiveLeafEntryID(
|
||||
_ leafEntryID: String,
|
||||
expectedEpoch: Int,
|
||||
for scope: OpenClawChatOutboxScope) async -> Bool
|
||||
/// Retry only if the failed row still matches the version shown to the user.
|
||||
/// The default fails closed so a store cannot bypass branch-change parking.
|
||||
func markCommandRetriedIfPresent(
|
||||
id: String,
|
||||
expectation: OpenClawChatOutboxRetryExpectation,
|
||||
agentID: String?,
|
||||
deliverySessionKey: String,
|
||||
routingContract: String) async -> OpenClawChatOutboxUpdateResult
|
||||
routingContract: String,
|
||||
replacementID: String?) async -> OpenClawChatOutboxUpdateResult
|
||||
/// User cancellation succeeds only before a sender claims the row. The
|
||||
/// status predicate is the cross-view-model cancellation boundary.
|
||||
func cancelCommand(id: String) async -> OpenClawChatOutboxUpdateResult
|
||||
/// Canonical gateway history may complete any row, including a sending
|
||||
/// row whose request ACK was lost.
|
||||
func confirmCommand(id: String) async -> OpenClawChatOutboxUpdateResult
|
||||
/// Canonical gateway history may complete the matching attempt, including
|
||||
/// a sending row whose request ACK was lost.
|
||||
func confirmCommand(id: String, attemptVersion: Int) async -> OpenClawChatOutboxUpdateResult
|
||||
/// Cross-view-model invalidation.
|
||||
func changes() -> AsyncStream<OpenClawChatOutboxChange>
|
||||
}
|
||||
|
||||
extension OpenClawChatCommandOutbox {
|
||||
public func markCommandQueued(
|
||||
id _: String,
|
||||
attemptVersion _: Int,
|
||||
retryCount _: Int,
|
||||
lastError _: String?) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
.unavailable
|
||||
}
|
||||
|
||||
public func markCommandAwaitingConfirmation(
|
||||
id _: String,
|
||||
attemptVersion _: Int) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
.unavailable
|
||||
}
|
||||
|
||||
public func markCommandFailedIfPresent(
|
||||
id _: String,
|
||||
attemptVersion _: Int,
|
||||
retryCount _: Int,
|
||||
lastError _: String?) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
.unavailable
|
||||
}
|
||||
|
||||
public func confirmCommand(
|
||||
id _: String,
|
||||
attemptVersion _: Int) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
.unavailable
|
||||
}
|
||||
|
||||
public func branchState(for _: OpenClawChatOutboxScope) async -> OpenClawChatOutboxBranchState? {
|
||||
nil
|
||||
}
|
||||
|
||||
public func beginBranchSwitch(_: OpenClawChatOutboxScope) async -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
public func cancelBranchSwitch(_: OpenClawChatOutboxScope) async -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
public func demoteBranchSwitchToReconcile(_: OpenClawChatOutboxScope) async -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
public func reconcileBranchScope(
|
||||
_: OpenClawChatOutboxScope,
|
||||
previousState _: OpenClawChatOutboxBranchState,
|
||||
activeLeafEntryID _: String?,
|
||||
branchLeafEntryIDs _: Set<String>,
|
||||
activeTranscriptEntryIDs _: Set<String>,
|
||||
lastError _: String) async -> [OpenClawChatOutboxCommand]?
|
||||
{
|
||||
nil
|
||||
}
|
||||
|
||||
public func confirmBranchChange(
|
||||
_: OpenClawChatOutboxScope,
|
||||
activeLeafEntryID _: String,
|
||||
lastError _: String) async -> [OpenClawChatOutboxCommand]?
|
||||
{
|
||||
nil
|
||||
}
|
||||
|
||||
public func updateLastActiveLeafEntryID(
|
||||
_: String,
|
||||
expectedEpoch _: Int,
|
||||
for _: OpenClawChatOutboxScope) async -> Bool
|
||||
{
|
||||
false
|
||||
}
|
||||
|
||||
public func markCommandRetriedIfPresent(
|
||||
id: String,
|
||||
expectation: OpenClawChatOutboxRetryExpectation,
|
||||
agentID: String?,
|
||||
deliverySessionKey: String,
|
||||
routingContract: String) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
await self.markCommandRetriedIfPresent(
|
||||
id: id,
|
||||
expectation: expectation,
|
||||
agentID: agentID,
|
||||
deliverySessionKey: deliverySessionKey,
|
||||
routingContract: routingContract,
|
||||
replacementID: nil)
|
||||
}
|
||||
|
||||
public func markCommandRetriedIfPresent(
|
||||
id _: String,
|
||||
expectation _: OpenClawChatOutboxRetryExpectation,
|
||||
agentID _: String?,
|
||||
deliverySessionKey _: String,
|
||||
routingContract _: String,
|
||||
replacementID _: String? = nil) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
.unavailable
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawChatSessionRoutingIdentity: Equatable, Sendable {
|
||||
public let scope: String
|
||||
public let mainSessionKey: String
|
||||
|
||||
@@ -439,6 +439,10 @@ public protocol OpenClawChatTransport: Sendable {
|
||||
func forkSessionAtMessage(
|
||||
sessionKey: String,
|
||||
entryId: String) async throws -> OpenClawChatForkAtMessageResponse
|
||||
func listSessionBranches(
|
||||
sessionKey: String,
|
||||
agentID: String?) async throws -> OpenClawChatSessionBranchesResponse
|
||||
func switchSessionBranch(sessionKey: String, agentID: String?, leafEntryId: String) async throws
|
||||
func setSessionModel(sessionKey: String, model: String?) async throws
|
||||
func patchSessionModel(
|
||||
sessionKey: String,
|
||||
@@ -768,6 +772,23 @@ extension OpenClawChatTransport {
|
||||
userInfo: [NSLocalizedDescriptionKey: "sessions.fork not supported by this transport"])
|
||||
}
|
||||
|
||||
public func listSessionBranches(
|
||||
sessionKey _: String,
|
||||
agentID _: String?) async throws -> OpenClawChatSessionBranchesResponse
|
||||
{
|
||||
throw NSError(
|
||||
domain: "OpenClawChatTransport",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "sessions.branches.list not supported by this transport"])
|
||||
}
|
||||
|
||||
public func switchSessionBranch(sessionKey _: String, agentID _: String?, leafEntryId _: String) async throws {
|
||||
throw NSError(
|
||||
domain: "OpenClawChatTransport",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "sessions.branches.switch not supported by this transport"])
|
||||
}
|
||||
|
||||
public func listModels() async throws -> [OpenClawChatModelChoice] {
|
||||
throw NSError(
|
||||
domain: "OpenClawChatTransport",
|
||||
|
||||
@@ -1215,9 +1215,7 @@ extension OpenClawChatView {
|
||||
}
|
||||
|
||||
private var messageSessionActionsDisabled: Bool {
|
||||
self.viewModel.hasBlockingRunActivity ||
|
||||
self.viewModel.isSending ||
|
||||
self.viewModel.isAborting
|
||||
!self.viewModel.canPerformMessageSessionAction
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
+1
-2
@@ -797,8 +797,7 @@ extension OpenClawChatViewModel {
|
||||
// The cache store writes only gateway-derived rows. It filters
|
||||
// locally retained outbox bubbles until history proves their keys.
|
||||
persistTranscriptToCache(
|
||||
sessionKey: request.session.key,
|
||||
agentID: request.session.agentID,
|
||||
session: request.session,
|
||||
messages: nextMessages,
|
||||
canonicalMessageIdempotencyKeys: Set(incoming.compactMap(\.idempotencyKey)))
|
||||
}
|
||||
|
||||
@@ -43,10 +43,221 @@ extension OpenClawChatViewModel {
|
||||
self.outboxStatesByMessageID[messageID]
|
||||
}
|
||||
|
||||
static func activeBranchLeafEntryID(in branches: [OpenClawChatSessionBranch]) -> String? {
|
||||
let active = branches.filter(\.active)
|
||||
guard active.count == 1 else { return nil }
|
||||
let leaf = active[0].leafEntryId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return leaf.isEmpty ? nil : leaf
|
||||
}
|
||||
|
||||
static func branchListingIsUnsupported(_ error: Error) -> Bool {
|
||||
if let gatewayError = error as? GatewayResponseError {
|
||||
let message = gatewayError.message.lowercased()
|
||||
return message.contains("sessions.branches.list") &&
|
||||
(gatewayError.code == "INVALID_REQUEST" ||
|
||||
message.contains("unsupported") ||
|
||||
message.contains("unimplemented"))
|
||||
}
|
||||
let error = error as NSError
|
||||
let message = error.localizedDescription.lowercased()
|
||||
return message.contains("sessions.branches.list") &&
|
||||
(message.contains("not supported") || message.contains("unsupported") ||
|
||||
message.contains("unimplemented") || message.contains("unknown method"))
|
||||
}
|
||||
|
||||
func outboxBranchScope(for session: SessionSnapshot) -> OpenClawChatOutboxScope? {
|
||||
let agentID = self.outboxAgentID(for: session)
|
||||
guard !self.outboxRequiresAgentID(for: session) || agentID != nil else { return nil }
|
||||
return OpenClawChatOutboxScope(sessionKey: session.key, agentID: agentID)
|
||||
}
|
||||
|
||||
private static func outboxBranchScope(for command: OpenClawChatOutboxCommand) -> OpenClawChatOutboxScope {
|
||||
OpenClawChatOutboxScope(sessionKey: command.sessionKey, agentID: command.agentID)
|
||||
}
|
||||
|
||||
var hasPendingOutboxCommandsForCurrentSession: Bool {
|
||||
self
|
||||
.outbox != nil &&
|
||||
(!self.hasRestoredOutboxMessages || self.outboxStatesByMessageID.values.contains { !$0.isFailed })
|
||||
}
|
||||
|
||||
var hasUnresolvedOutboxCommandsForCurrentSession: Bool {
|
||||
self.outbox != nil && (!self.hasRestoredOutboxMessages || !self.outboxStatesByMessageID.isEmpty)
|
||||
}
|
||||
|
||||
func beginOutboxSessionMutation(_ session: SessionSnapshot) async -> Bool {
|
||||
guard let outbox, let scope = self.outboxBranchScope(for: session) else { return self.outbox == nil }
|
||||
await self.pendingCacheWriteTask?.value
|
||||
return await outbox.beginBranchSwitch(scope)
|
||||
}
|
||||
|
||||
func cancelOutboxSessionMutation(_ session: SessionSnapshot) async {
|
||||
guard let outbox, let scope = self.outboxBranchScope(for: session) else { return }
|
||||
_ = await outbox.cancelBranchSwitch(scope)
|
||||
}
|
||||
|
||||
func recoverOutboxAfterSessionMutationRefreshFailure(
|
||||
_ session: SessionSnapshot,
|
||||
branchingUnsupported: Bool) async
|
||||
{
|
||||
self.pauseOutboxBranchScope(session)
|
||||
if let outbox = self.outbox, let scope = self.outboxBranchScope(for: session) {
|
||||
_ = await outbox.demoteBranchSwitchToReconcile(scope)
|
||||
}
|
||||
if branchingUnsupported {
|
||||
self.allowOutboxReplayWithoutBranching(session)
|
||||
} else {
|
||||
self.reconcilePendingOutboxBranchScopes()
|
||||
}
|
||||
}
|
||||
|
||||
func confirmOutboxBranchChange(_ session: SessionSnapshot, activeLeafEntryID: String) async -> Bool {
|
||||
guard let outbox, let scope = self.outboxBranchScope(for: session) else { return self.outbox == nil }
|
||||
self.reconciledOutboxBranchScopes.remove(scope)
|
||||
await self.pendingCacheWriteTask?.value
|
||||
guard let commands = await outbox.confirmBranchChange(
|
||||
scope,
|
||||
activeLeafEntryID: activeLeafEntryID,
|
||||
lastError: "Session branch changed; review and retry this message.")
|
||||
else { return false }
|
||||
self.reconciledOutboxBranchScopes.insert(scope)
|
||||
guard self.isCurrentSession(session) else { return true }
|
||||
self.presentOutboxCommands(commands.filter { self.commandMatchesTarget($0, session: session) })
|
||||
return true
|
||||
}
|
||||
|
||||
func pauseOutboxBranchScope(_ session: SessionSnapshot) {
|
||||
guard let scope = self.outboxBranchScope(for: session) else { return }
|
||||
self.reconciledOutboxBranchScopes.remove(scope)
|
||||
}
|
||||
|
||||
func captureOutboxBranchState(for session: SessionSnapshot) async -> OpenClawChatOutboxBranchState? {
|
||||
guard let outbox, let scope = self.outboxBranchScope(for: session) else { return nil }
|
||||
await self.pendingCacheWriteTask?.value
|
||||
return await outbox.branchState(for: scope)
|
||||
}
|
||||
|
||||
private func waitForBootstrapOutboxBranchCapture(for session: SessionSnapshot) async {
|
||||
guard let capture = self.bootstrapOutboxBranchStateCapture, capture.session == session else { return }
|
||||
_ = await capture.task.value
|
||||
}
|
||||
|
||||
func observeOutboxTranscriptTip(_ message: OpenClawChatMessage, session: SessionSnapshot) {
|
||||
guard let outbox, let scope = self.outboxBranchScope(for: session),
|
||||
let tip = message.transcriptMessageID?.trimmingCharacters(in: .whitespacesAndNewlines), !tip.isEmpty
|
||||
else { return }
|
||||
let state = Task { await outbox.branchState(for: scope) }
|
||||
let previous = self.pendingCacheWriteTask
|
||||
self.pendingCacheWriteTask = Task.detached {
|
||||
await previous?.value
|
||||
guard let epoch = await state.value?.epoch else { return }
|
||||
_ = await outbox.updateLastActiveLeafEntryID(tip, expectedEpoch: epoch, for: scope)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func reconcileOutboxBranchScope(
|
||||
_ session: SessionSnapshot,
|
||||
branches: [OpenClawChatSessionBranch],
|
||||
previousState: OpenClawChatOutboxBranchState?,
|
||||
connectionGeneration: UInt64) async -> Bool
|
||||
{
|
||||
guard let outbox, let scope = self.outboxBranchScope(for: session), let previousState,
|
||||
connectionGeneration == self.outboxBranchConnectionGeneration
|
||||
else { return self.outbox == nil }
|
||||
let activeLeaf: String? = branches.isEmpty ? nil : Self.activeBranchLeafEntryID(in: branches)
|
||||
guard branches.isEmpty || activeLeaf != nil else { return false }
|
||||
let leaves = Set(branches.map(\.leafEntryId).filter { !$0.isEmpty })
|
||||
let activeTranscriptEntryIDs = self.isCurrentSession(session)
|
||||
? Set(self.messages.compactMap(\.transcriptMessageID))
|
||||
: []
|
||||
guard let commands = await outbox.reconcileBranchScope(
|
||||
scope,
|
||||
previousState: previousState,
|
||||
activeLeafEntryID: activeLeaf,
|
||||
branchLeafEntryIDs: leaves,
|
||||
activeTranscriptEntryIDs: activeTranscriptEntryIDs,
|
||||
lastError: "Session branch changed; review and retry this message.")
|
||||
else { return false }
|
||||
self.reconciledOutboxBranchScopes.insert(scope)
|
||||
if self.isCurrentSession(session) {
|
||||
self.presentOutboxCommands(commands.filter { self.commandMatchesTarget($0, session: session) })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func allowOutboxReplayWithoutBranching(_ session: SessionSnapshot) {
|
||||
guard let scope = self.outboxBranchScope(for: session) else { return }
|
||||
self.reconciledOutboxBranchScopes.insert(scope)
|
||||
self.flushOutboxIfNeeded()
|
||||
}
|
||||
|
||||
func reconcilePendingOutboxBranchScopes() {
|
||||
guard let outbox, self.healthOK else { return }
|
||||
Task { [weak self] in
|
||||
guard let self, let commands = await outbox.loadCommandsIfAvailable() else { return }
|
||||
let grouped = Dictionary(grouping: commands.filter {
|
||||
$0.status == .queued || $0.status == .sending || $0.status == .awaitingConfirmation
|
||||
}, by: Self.outboxBranchScope(for:))
|
||||
for (scope, scopedCommands) in grouped where !self.reconciledOutboxBranchScopes.contains(scope) {
|
||||
guard let command = scopedCommands.first,
|
||||
let state = await outbox.branchState(for: scope)
|
||||
else { continue }
|
||||
do {
|
||||
let response = try await self.transport.listSessionBranches(
|
||||
sessionKey: command.deliverySessionKey,
|
||||
agentID: command.agentID)
|
||||
let activeLeaf: String? = response.branches.isEmpty ? nil : Self
|
||||
.activeBranchLeafEntryID(in: response.branches)
|
||||
guard response.branches.isEmpty || activeLeaf != nil else { continue }
|
||||
let leaves = Set(response.branches.map(\.leafEntryId).filter { !$0.isEmpty })
|
||||
let activeTranscriptEntryIDs = self.outboxBranchScope(for: self.currentSessionSnapshot()) == scope
|
||||
? Set(self.messages.compactMap(\.transcriptMessageID))
|
||||
: []
|
||||
guard let reconciled = await outbox.reconcileBranchScope(
|
||||
scope,
|
||||
previousState: state,
|
||||
activeLeafEntryID: activeLeaf,
|
||||
branchLeafEntryIDs: leaves,
|
||||
activeTranscriptEntryIDs: activeTranscriptEntryIDs,
|
||||
lastError: "Session branch changed; review and retry this message.")
|
||||
else { continue }
|
||||
self.reconciledOutboxBranchScopes.insert(scope)
|
||||
let visible = self.currentSessionSnapshot()
|
||||
if self.outboxBranchScope(for: visible) == scope {
|
||||
self
|
||||
.presentOutboxCommands(reconciled
|
||||
.filter { self.commandMatchesTarget($0, session: visible) })
|
||||
}
|
||||
} catch {
|
||||
if Self.branchListingIsUnsupported(error) {
|
||||
self.reconciledOutboxBranchScopes.insert(scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.flushOutboxIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func invalidateOutboxBranchReconciliation() {
|
||||
self.outboxBranchConnectionGeneration &+= 1
|
||||
self.reconciledOutboxBranchScopes.removeAll()
|
||||
self.reconcilingOutboxBranchScopes.removeAll()
|
||||
for task in self.outboxBranchReconcileRetryTasks.values {
|
||||
task.cancel()
|
||||
}
|
||||
self.outboxBranchReconcileRetryTasks.removeAll()
|
||||
self.outboxBranchReconcileRetryAttempts.removeAll()
|
||||
}
|
||||
|
||||
/// Tap-to-retry for a failed command: reset attempts, refresh createdAt
|
||||
/// (so even an expired row can send again), and flush if healthy.
|
||||
public func retryOutboxMessage(_ messageID: UUID) {
|
||||
guard let outbox, let commandID = self.outboxCommandIDsByMessageID[messageID] else { return }
|
||||
guard !self.isSwitchingSessionBranch,
|
||||
let outbox,
|
||||
let commandID = self.outboxCommandIDsByMessageID[messageID],
|
||||
let failure = self.outboxFailureVersionsByMessageID[messageID]
|
||||
else { return }
|
||||
let session = self.currentSessionSnapshot()
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -64,9 +275,14 @@ extension OpenClawChatViewModel {
|
||||
}
|
||||
let result = await outbox.markCommandRetriedIfPresent(
|
||||
id: commandID,
|
||||
expectation: OpenClawChatOutboxRetryExpectation(
|
||||
attemptVersion: failure.attemptVersion,
|
||||
retryCount: failure.retryCount,
|
||||
lastError: failure.lastError),
|
||||
agentID: agentID,
|
||||
deliverySessionKey: deliverySessionKey,
|
||||
routingContract: routingContract)
|
||||
routingContract: routingContract,
|
||||
replacementID: UUID().uuidString)
|
||||
if result == .updated {
|
||||
// Durable work is gateway-global. Flush even when the visible
|
||||
// session changed while the SQLite update was suspended.
|
||||
@@ -76,7 +292,8 @@ extension OpenClawChatViewModel {
|
||||
switch result {
|
||||
case .updated:
|
||||
self.outboxStatesByMessageID[messageID] = .queued
|
||||
case .missing, .confirmed:
|
||||
self.outboxFailureVersionsByMessageID.removeValue(forKey: messageID)
|
||||
case .missing, .confirmed, .superseded:
|
||||
self.clearOutboxState(forCommandID: commandID)
|
||||
case .unavailable:
|
||||
self.errorText = "Could not retry the queued message. Try again."
|
||||
@@ -194,6 +411,7 @@ extension OpenClawChatViewModel {
|
||||
status: .queued,
|
||||
retryCount: 0,
|
||||
lastError: nil)
|
||||
await self.waitForBootstrapOutboxBranchCapture(for: session)
|
||||
let accepted = await outbox.enqueueCommand(command)
|
||||
guard accepted else {
|
||||
if self.isCurrentSession(session) {
|
||||
@@ -254,6 +472,7 @@ extension OpenClawChatViewModel {
|
||||
lastError: deliveryIsAmbiguous
|
||||
? OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError
|
||||
: nil)
|
||||
await self.waitForBootstrapOutboxBranchCapture(for: session)
|
||||
guard await outbox.enqueueCommand(command) else { return false }
|
||||
guard self.isCurrentSession(session) else { return true }
|
||||
self.mapOutboxCommand(command, to: messageID)
|
||||
@@ -276,6 +495,7 @@ extension OpenClawChatViewModel {
|
||||
self.outboxCommandIDsByMessageID.removeAll()
|
||||
self.outboxMessageIDsByCommandID.removeAll()
|
||||
self.outboxStatesByMessageID.removeAll()
|
||||
self.outboxFailureVersionsByMessageID.removeAll()
|
||||
}
|
||||
|
||||
/// Re-adopts or re-appends queued bubbles for the visible session after
|
||||
@@ -300,7 +520,7 @@ extension OpenClawChatViewModel {
|
||||
// Relaunching while already healthy never sees an unhealthy ->
|
||||
// healthy transition, so kick the flush here as well.
|
||||
if self.healthOK, commands.contains(where: { $0.status == .queued }) {
|
||||
self.flushOutboxIfNeeded()
|
||||
self.reconcilePendingOutboxBranchScopes()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -334,8 +554,8 @@ extension OpenClawChatViewModel {
|
||||
}) {
|
||||
await self.persistCanonicalOutboxEvidence(canonicalMessage, for: command)
|
||||
}
|
||||
let result = await outbox.confirmCommand(id: command.id)
|
||||
if result != .unavailable {
|
||||
let result = await outbox.confirmCommand(id: command.id, attemptVersion: command.attemptVersion)
|
||||
if result == .updated || result == .confirmed {
|
||||
self.clearOutboxState(forCommandID: command.id)
|
||||
}
|
||||
}
|
||||
@@ -430,6 +650,12 @@ extension OpenClawChatViewModel {
|
||||
self.outboxCommandIDsByMessageID[messageID] = command.id
|
||||
self.outboxMessageIDsByCommandID[command.id] = messageID
|
||||
self.outboxStatesByMessageID[messageID] = Self.outboxDisplayState(for: command)
|
||||
if command.status == .failed {
|
||||
self.outboxFailureVersionsByMessageID[messageID] = (
|
||||
command.attemptVersion, command.retryCount, command.lastError)
|
||||
} else {
|
||||
self.outboxFailureVersionsByMessageID.removeValue(forKey: messageID)
|
||||
}
|
||||
}
|
||||
|
||||
private func pruneOutboxMappings() {
|
||||
@@ -442,6 +668,7 @@ extension OpenClawChatViewModel {
|
||||
self.outboxMessageIDsByCommandID.removeValue(forKey: commandID)
|
||||
}
|
||||
self.outboxStatesByMessageID.removeValue(forKey: messageID)
|
||||
self.outboxFailureVersionsByMessageID.removeValue(forKey: messageID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,7 +704,7 @@ extension OpenClawChatViewModel {
|
||||
if ok, !wasHealthy {
|
||||
guard self.outbox != nil else { return }
|
||||
if self.hasCurrentSessionMetadata {
|
||||
self.flushOutboxIfNeeded()
|
||||
self.reconcilePendingOutboxBranchScopes()
|
||||
} else if refreshSessionsOnReconnect {
|
||||
let session = self.currentSessionSnapshot()
|
||||
Task { await self.fetchSessions(limit: 50, sessionSnapshot: session) }
|
||||
@@ -524,6 +751,13 @@ extension OpenClawChatViewModel {
|
||||
}
|
||||
let visibleSession = self.currentSessionSnapshot()
|
||||
self.presentOutboxCommands(initialCommands.filter { self.commandMatchesTarget($0, session: visibleSession) })
|
||||
if initialCommands.contains(where: {
|
||||
($0.status == .queued || $0.status == .sending || $0.status == .awaitingConfirmation) &&
|
||||
!self.reconciledOutboxBranchScopes.contains(Self.outboxBranchScope(for: $0))
|
||||
}) {
|
||||
self.reconcilePendingOutboxBranchScopes()
|
||||
return
|
||||
}
|
||||
// Do not capability-gate ordinary live chat when no durable work
|
||||
// needs a replay lease (notably against older gateways).
|
||||
let hasRouteWork = initialCommands.contains { command in
|
||||
@@ -561,6 +795,23 @@ extension OpenClawChatViewModel {
|
||||
let visibleSession = self.currentSessionSnapshot()
|
||||
self.presentOutboxCommands(commands.filter { self.commandMatchesTarget($0, session: visibleSession) })
|
||||
guard let next = await outbox.claimNextCommand() else { break }
|
||||
let scope = Self.outboxBranchScope(for: next)
|
||||
guard self.reconciledOutboxBranchScopes.contains(scope) else {
|
||||
_ = await outbox.markCommandQueued(
|
||||
id: next.id,
|
||||
attemptVersion: next.attemptVersion,
|
||||
retryCount: next.retryCount,
|
||||
lastError: nil)
|
||||
break
|
||||
}
|
||||
guard next.branchEpoch == next.scopeBranchEpoch else {
|
||||
_ = await outbox.markCommandFailedIfPresent(
|
||||
id: next.id,
|
||||
attemptVersion: next.attemptVersion,
|
||||
retryCount: next.retryCount,
|
||||
lastError: "Session branch changed; review and retry this message.")
|
||||
continue
|
||||
}
|
||||
if self.transport.outboxRequiresSessionRoutingContract,
|
||||
next.routingContract != routeLease.sessionRoutingContract
|
||||
{
|
||||
@@ -636,6 +887,7 @@ extension OpenClawChatViewModel {
|
||||
// channel, so it is safe to retry automatically.
|
||||
await outbox.markCommandQueued(
|
||||
id: command.id,
|
||||
attemptVersion: command.attemptVersion,
|
||||
retryCount: command.retryCount,
|
||||
lastError: nil)
|
||||
self.setOutboxState(.queued, forCommandID: command.id)
|
||||
@@ -676,7 +928,9 @@ extension OpenClawChatViewModel {
|
||||
// persistence. The durable outbox remains the sole owner of the
|
||||
// optimistic bubble until canonical history carries its key; writing
|
||||
// it into the cache would require cross-database cancellation logic.
|
||||
let update = await outbox.markCommandAwaitingConfirmation(id: command.id)
|
||||
let update = await outbox.markCommandAwaitingConfirmation(
|
||||
id: command.id,
|
||||
attemptVersion: command.attemptVersion)
|
||||
guard update != .unavailable else {
|
||||
self.applyTransportHealth(false)
|
||||
return .stop
|
||||
@@ -732,6 +986,7 @@ extension OpenClawChatViewModel {
|
||||
{
|
||||
let update = await outbox.markCommandFailedIfPresent(
|
||||
id: command.id,
|
||||
attemptVersion: command.attemptVersion,
|
||||
retryCount: command.retryCount,
|
||||
lastError: OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError)
|
||||
switch update {
|
||||
@@ -741,6 +996,8 @@ extension OpenClawChatViewModel {
|
||||
forCommandID: command.id)
|
||||
case .missing, .confirmed:
|
||||
self.clearOutboxState(forCommandID: command.id)
|
||||
case .superseded:
|
||||
self.clearOutboxState(forCommandID: command.id)
|
||||
case .unavailable:
|
||||
self.applyTransportHealth(false)
|
||||
}
|
||||
@@ -753,6 +1010,7 @@ extension OpenClawChatViewModel {
|
||||
{
|
||||
let update = await outbox.markCommandFailedIfPresent(
|
||||
id: command.id,
|
||||
attemptVersion: command.attemptVersion,
|
||||
retryCount: command.retryCount,
|
||||
lastError: OpenClawChatSQLiteTranscriptCache.outboxChangedTargetError)
|
||||
guard update != .unavailable else {
|
||||
@@ -781,6 +1039,7 @@ extension OpenClawChatViewModel {
|
||||
if attempts >= Self.maxOutboxSendAttempts {
|
||||
let update = await outbox.markCommandFailedIfPresent(
|
||||
id: command.id,
|
||||
attemptVersion: command.attemptVersion,
|
||||
retryCount: attempts,
|
||||
lastError: reason)
|
||||
guard update != .unavailable else {
|
||||
@@ -798,7 +1057,11 @@ extension OpenClawChatViewModel {
|
||||
// flush instead of blocking behind it forever.
|
||||
return true
|
||||
}
|
||||
await outbox.markCommandQueued(id: command.id, retryCount: attempts, lastError: reason)
|
||||
_ = await outbox.markCommandQueued(
|
||||
id: command.id,
|
||||
attemptVersion: command.attemptVersion,
|
||||
retryCount: attempts,
|
||||
lastError: reason)
|
||||
self.setOutboxState(.queued, forCommandID: command.id)
|
||||
// Strict createdAt ordering: never skip ahead of a command that
|
||||
// still has retries left.
|
||||
@@ -832,7 +1095,7 @@ extension OpenClawChatViewModel {
|
||||
return await outbox.recoverInterruptedSends()
|
||||
}
|
||||
|
||||
private func outboxAgentID(for session: SessionSnapshot) -> String? {
|
||||
func outboxAgentID(for session: SessionSnapshot) -> String? {
|
||||
guard self.transport.outboxRequiresSessionRoutingContract else { return nil }
|
||||
if session.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "unknown" {
|
||||
return nil
|
||||
@@ -906,6 +1169,7 @@ extension OpenClawChatViewModel {
|
||||
guard let messageID = self.outboxMessageIDsByCommandID.removeValue(forKey: commandID) else { return }
|
||||
self.outboxCommandIDsByMessageID.removeValue(forKey: messageID)
|
||||
self.outboxStatesByMessageID.removeValue(forKey: messageID)
|
||||
self.outboxFailureVersionsByMessageID.removeValue(forKey: messageID)
|
||||
}
|
||||
|
||||
func handleOutboxChange(_ change: OpenClawChatOutboxChange) {
|
||||
@@ -913,7 +1177,7 @@ extension OpenClawChatViewModel {
|
||||
// store mutation, including snapshots owned by another view model.
|
||||
self.outboxPresentationGeneration &+= 1
|
||||
switch change {
|
||||
case let .canceled(commandID):
|
||||
case let .canceled(_, commandID):
|
||||
// The initiating view owns its async result so canonical proof
|
||||
// observed before that continuation can still preserve the row.
|
||||
// Other views have no local cancellation task and apply the event.
|
||||
@@ -921,10 +1185,15 @@ extension OpenClawChatViewModel {
|
||||
guard let messageID = self.outboxMessageIDsByCommandID[commandID] else { return }
|
||||
self.clearOutboxState(forCommandID: commandID)
|
||||
self.replaceMessages(self.messages.filter { $0.id != messageID })
|
||||
case let .confirmed(commandID):
|
||||
case let .confirmed(_, commandID):
|
||||
// Canonical history owns the message row; only its outbox badge
|
||||
// and command mapping disappear.
|
||||
self.clearOutboxState(forCommandID: commandID)
|
||||
case let .invalidated(_, scope):
|
||||
let session = self.currentSessionSnapshot()
|
||||
guard self.outboxBranchScope(for: session) == scope else { return }
|
||||
self.reconciledOutboxBranchScopes.remove(scope)
|
||||
self.restoreOutboxMessages(session: session)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ extension OpenClawChatViewModel {
|
||||
}
|
||||
|
||||
var hasBlockingRunActivity: Bool {
|
||||
pendingRunCount > 0 || hasActiveSessionRunWithoutChatSnapshot
|
||||
pendingRunCount > 0 || hasActiveSessionRunWithoutChatSnapshot || isSwitchingSessionBranch
|
||||
}
|
||||
|
||||
var workingIndicatorIdentity: String {
|
||||
@@ -457,8 +457,7 @@ extension OpenClawChatViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
let mustPreserveOutboxOrder = !hasRestoredOutboxMessages ||
|
||||
outboxStatesByMessageID.values.contains(where: { !$0.isFailed })
|
||||
let mustPreserveOutboxOrder = self.hasPendingOutboxCommandsForCurrentSession
|
||||
let attachmentDecision = await attachmentPersistenceDecision(
|
||||
draft,
|
||||
mustPreserveOutboxOrder: mustPreserveOutboxOrder)
|
||||
|
||||
@@ -6,6 +6,12 @@ private let chatSessionActionsLogger = Logger(
|
||||
category: "OpenClawChat")
|
||||
|
||||
extension OpenClawChatViewModel {
|
||||
private enum SessionBranchesRefreshPurpose {
|
||||
case readOnly
|
||||
case reconcile
|
||||
case finalizeMutation
|
||||
}
|
||||
|
||||
public func refreshSessions(limit: Int? = nil) {
|
||||
let context = self.currentSessionSnapshot()
|
||||
Task { await self.fetchSessions(limit: limit, sessionSnapshot: context) }
|
||||
@@ -373,38 +379,260 @@ extension OpenClawChatViewModel {
|
||||
|
||||
public func rewindToMessage(_ message: OpenClawChatMessage) async {
|
||||
guard let entryID = Self.sessionMutationEntryID(for: message) else { return }
|
||||
guard !self.hasBlockingRunActivity, !self.isSending, !self.isAborting else { return }
|
||||
guard self.canPerformMessageSessionAction else { return }
|
||||
let initiatingSession = self.currentSessionSnapshot()
|
||||
guard await self.beginOutboxSessionMutation(initiatingSession) else { return }
|
||||
guard self.isCurrentSession(initiatingSession) else {
|
||||
await self.cancelOutboxSessionMutation(initiatingSession)
|
||||
return
|
||||
}
|
||||
do {
|
||||
let result = try await self.transport.rewindSession(
|
||||
sessionKey: initiatingSession.key,
|
||||
entryId: entryID)
|
||||
guard self.isCurrentSession(initiatingSession) else { return }
|
||||
guard self.isCurrentSession(initiatingSession) else {
|
||||
await self.recoverOutboxAfterSessionMutationRefreshFailure(
|
||||
initiatingSession,
|
||||
branchingUnsupported: false)
|
||||
return
|
||||
}
|
||||
self.replyTarget = nil
|
||||
self.runMessageScopesByRunID.removeAll()
|
||||
self.provisionalFinalMessagesByID.removeAll()
|
||||
self.input = result.editorText ?? ""
|
||||
let historyRequest = self.beginHistoryRequest(for: initiatingSession)
|
||||
_ = await self.refreshHistoryAfterRun(historyRequest: historyRequest)
|
||||
guard self.isCurrentSession(initiatingSession) else {
|
||||
await self.recoverOutboxAfterSessionMutationRefreshFailure(
|
||||
initiatingSession,
|
||||
branchingUnsupported: false)
|
||||
return
|
||||
}
|
||||
await self.refreshSessionBranches(confirmingBranchChange: true)
|
||||
} catch {
|
||||
await self.cancelOutboxSessionMutation(initiatingSession)
|
||||
self.errorText = error.localizedDescription
|
||||
chatSessionActionsLogger.error(
|
||||
"sessions.rewind failed \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func refreshSessionBranches(confirmingBranchChange: Bool = false) async -> Bool {
|
||||
let session = self.currentSessionSnapshot()
|
||||
let refreshGeneration = self.beginSessionBranchesRefresh()
|
||||
let previousState = await self.captureOutboxBranchState(for: session)
|
||||
return await self.performSessionBranchesRefresh(
|
||||
for: session,
|
||||
refreshGeneration: refreshGeneration,
|
||||
previousState: previousState,
|
||||
purpose: confirmingBranchChange ? .finalizeMutation : .readOnly)
|
||||
}
|
||||
|
||||
func refreshSessionBranches(
|
||||
for session: SessionSnapshot,
|
||||
preBootstrapBranchState: OpenClawChatOutboxBranchState?) async -> Bool
|
||||
{
|
||||
let refreshGeneration = self.beginSessionBranchesRefresh()
|
||||
return await self.performSessionBranchesRefresh(
|
||||
for: session,
|
||||
refreshGeneration: refreshGeneration,
|
||||
previousState: preBootstrapBranchState,
|
||||
purpose: .reconcile)
|
||||
}
|
||||
|
||||
private func beginSessionBranchesRefresh() -> UInt64 {
|
||||
self.sessionBranchesRefreshGeneration &+= 1
|
||||
self.isLoadingSessionBranches = true
|
||||
return self.sessionBranchesRefreshGeneration
|
||||
}
|
||||
|
||||
private func performSessionBranchesRefresh(
|
||||
for session: SessionSnapshot,
|
||||
refreshGeneration: UInt64,
|
||||
previousState: OpenClawChatOutboxBranchState?,
|
||||
purpose: SessionBranchesRefreshPurpose) async -> Bool
|
||||
{
|
||||
let connectionGeneration = self.outboxBranchConnectionGeneration
|
||||
defer {
|
||||
if self.isCurrentSession(session),
|
||||
refreshGeneration == self.sessionBranchesRefreshGeneration
|
||||
{
|
||||
self.isLoadingSessionBranches = false
|
||||
}
|
||||
}
|
||||
do {
|
||||
let response = try await self.transport.listSessionBranches(
|
||||
sessionKey: session.key,
|
||||
agentID: self.outboxAgentID(for: session))
|
||||
guard self.isCurrentSession(session),
|
||||
refreshGeneration == self.sessionBranchesRefreshGeneration,
|
||||
connectionGeneration == self.outboxBranchConnectionGeneration
|
||||
else {
|
||||
if case .finalizeMutation = purpose {
|
||||
await self.recoverOutboxAfterSessionMutationRefreshFailure(
|
||||
session,
|
||||
branchingUnsupported: false)
|
||||
}
|
||||
return false
|
||||
}
|
||||
switch purpose {
|
||||
case .readOnly:
|
||||
if let outbox = self.outbox,
|
||||
let scope = self.outboxBranchScope(for: session),
|
||||
let expectedEpoch = previousState?.epoch,
|
||||
let activeLeafEntryID = Self.activeBranchLeafEntryID(in: response.branches)
|
||||
{
|
||||
_ = await outbox.updateLastActiveLeafEntryID(
|
||||
activeLeafEntryID,
|
||||
expectedEpoch: expectedEpoch,
|
||||
for: scope)
|
||||
}
|
||||
case .reconcile:
|
||||
guard await self.reconcileOutboxBranchScope(
|
||||
session,
|
||||
branches: response.branches,
|
||||
previousState: previousState,
|
||||
connectionGeneration: connectionGeneration)
|
||||
else {
|
||||
self.pauseOutboxBranchScope(session)
|
||||
return false
|
||||
}
|
||||
case .finalizeMutation:
|
||||
guard let activeLeafEntryID = Self.activeBranchLeafEntryID(in: response.branches),
|
||||
await self.confirmOutboxBranchChange(
|
||||
session,
|
||||
activeLeafEntryID: activeLeafEntryID)
|
||||
else {
|
||||
await self.recoverOutboxAfterSessionMutationRefreshFailure(
|
||||
session,
|
||||
branchingUnsupported: false)
|
||||
return false
|
||||
}
|
||||
}
|
||||
guard self.isCurrentSession(session),
|
||||
refreshGeneration == self.sessionBranchesRefreshGeneration
|
||||
else { return false }
|
||||
self.sessionBranches = response.branches
|
||||
self.flushOutboxIfNeeded()
|
||||
return true
|
||||
} catch {
|
||||
guard self.isCurrentSession(session),
|
||||
refreshGeneration == self.sessionBranchesRefreshGeneration,
|
||||
connectionGeneration == self.outboxBranchConnectionGeneration
|
||||
else {
|
||||
if case .finalizeMutation = purpose {
|
||||
await self.recoverOutboxAfterSessionMutationRefreshFailure(
|
||||
session,
|
||||
branchingUnsupported: false)
|
||||
}
|
||||
return false
|
||||
}
|
||||
chatSessionActionsLogger.debug(
|
||||
"sessions.branches.list failed \(error.localizedDescription, privacy: .public)")
|
||||
let branchingUnsupported = Self.branchListingIsUnsupported(error)
|
||||
switch purpose {
|
||||
case .readOnly:
|
||||
break
|
||||
case .reconcile where branchingUnsupported:
|
||||
self.allowOutboxReplayWithoutBranching(session)
|
||||
case .reconcile:
|
||||
self.pauseOutboxBranchScope(session)
|
||||
case .finalizeMutation:
|
||||
await self.recoverOutboxAfterSessionMutationRefreshFailure(
|
||||
session,
|
||||
branchingUnsupported: branchingUnsupported)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func refreshSessionBranchesForMenuPresentation() async {
|
||||
await self.refreshSessionBranches()
|
||||
}
|
||||
|
||||
var canSwitchSessionBranch: Bool {
|
||||
!self.hasBlockingRunActivity &&
|
||||
!self.isSending &&
|
||||
!self.isAborting &&
|
||||
!self.hasUnresolvedOutboxCommandsForCurrentSession
|
||||
}
|
||||
|
||||
var canPerformMessageSessionAction: Bool {
|
||||
!self.hasBlockingRunActivity &&
|
||||
!self.isSending &&
|
||||
!self.isAborting &&
|
||||
!self.hasPendingOutboxCommandsForCurrentSession
|
||||
}
|
||||
|
||||
public func switchToBranch(_ leafEntryId: String) async {
|
||||
let normalizedLeafEntryID = leafEntryId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedLeafEntryID.isEmpty else { return }
|
||||
guard self.canSwitchSessionBranch else { return }
|
||||
guard !self.sessionBranches.contains(where: {
|
||||
$0.leafEntryId == normalizedLeafEntryID && $0.active
|
||||
}) else { return }
|
||||
let initiatingSession = self.currentSessionSnapshot()
|
||||
let switchActivity = self.beginSessionBranchSwitchActivity(for: initiatingSession)
|
||||
defer { self.endSessionBranchSwitchActivity(switchActivity) }
|
||||
guard await self.beginOutboxSessionMutation(initiatingSession) else {
|
||||
return
|
||||
}
|
||||
guard self.isCurrentSessionBranchSwitchActivity(switchActivity) else {
|
||||
await self.cancelOutboxSessionMutation(initiatingSession)
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await self.transport.switchSessionBranch(
|
||||
sessionKey: initiatingSession.key,
|
||||
agentID: self.outboxAgentID(for: initiatingSession),
|
||||
leafEntryId: normalizedLeafEntryID)
|
||||
guard self.isCurrentSessionBranchSwitchActivity(switchActivity) else {
|
||||
if await self.confirmOutboxBranchChange(
|
||||
initiatingSession,
|
||||
activeLeafEntryID: normalizedLeafEntryID) == false
|
||||
{
|
||||
await self.recoverOutboxAfterSessionMutationRefreshFailure(
|
||||
initiatingSession,
|
||||
branchingUnsupported: false)
|
||||
}
|
||||
return
|
||||
}
|
||||
self.replyTarget = nil
|
||||
self.runMessageScopesByRunID.removeAll()
|
||||
self.provisionalFinalMessagesByID.removeAll()
|
||||
await self.reconcileSessionBranchChange(
|
||||
switchActivity,
|
||||
confirmedLeafEntryID: normalizedLeafEntryID)
|
||||
} catch {
|
||||
await self.cancelOutboxSessionMutation(initiatingSession)
|
||||
guard self.isCurrentSessionBranchSwitchActivity(switchActivity) else { return }
|
||||
self.errorText = error.localizedDescription
|
||||
chatSessionActionsLogger.error(
|
||||
"sessions.branches.switch failed \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch parity with forkSession(key:): per-request server-lease guards in the
|
||||
/// transport plus post-RPC staleness re-checks, not the patch-flow route lease,
|
||||
/// which does not expose fork/rewind and would widen the lease API for no sibling.
|
||||
public func forkAtMessage(_ message: OpenClawChatMessage) async {
|
||||
guard let entryID = Self.sessionMutationEntryID(for: message) else { return }
|
||||
guard !self.hasBlockingRunActivity, !self.isSending, !self.isAborting else { return }
|
||||
guard self.canPerformMessageSessionAction else { return }
|
||||
guard self.canCreateSessionForImmediateSwitch() else { return }
|
||||
let initiatingSession = self.currentSessionSnapshot()
|
||||
guard await self.beginOutboxSessionMutation(initiatingSession) else { return }
|
||||
guard self.isCurrentSession(initiatingSession), self.canCreateSessionForImmediateSwitch() else {
|
||||
await self.cancelOutboxSessionMutation(initiatingSession)
|
||||
return
|
||||
}
|
||||
do {
|
||||
let result = try await self.transport.forkSessionAtMessage(
|
||||
sessionKey: initiatingSession.key,
|
||||
entryId: entryID)
|
||||
// Fork leaves the source transcript unchanged, so its lease is only an entry gate.
|
||||
// Rewind repoints the source scope and confirms an epoch change instead.
|
||||
await self.cancelOutboxSessionMutation(initiatingSession)
|
||||
let createdKey = result.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !createdKey.isEmpty else { return }
|
||||
guard self.isCurrentSession(initiatingSession),
|
||||
@@ -420,6 +648,7 @@ extension OpenClawChatViewModel {
|
||||
guard self.sessionKey == createdKey else { return }
|
||||
self.input = result.editorText ?? ""
|
||||
} catch {
|
||||
await self.cancelOutboxSessionMutation(initiatingSession)
|
||||
self.errorText = error.localizedDescription
|
||||
chatSessionActionsLogger.error(
|
||||
"sessions.fork failed \(error.localizedDescription, privacy: .public)")
|
||||
|
||||
@@ -218,6 +218,29 @@ extension OpenClawChatViewModel {
|
||||
return "\(provider)/\(modelID)"
|
||||
}
|
||||
|
||||
func placeholderSession(key: String) -> OpenClawChatSessionEntry {
|
||||
OpenClawChatSessionEntry(
|
||||
key: key,
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: nil,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
abortedLastRun: nil,
|
||||
thinkingLevel: nil,
|
||||
verboseLevel: nil,
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
modelProvider: nil,
|
||||
model: nil,
|
||||
contextTokens: nil)
|
||||
}
|
||||
|
||||
public var sessionChoices: [OpenClawChatSessionEntry] {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let cutoff = now - (24 * 60 * 60 * 1000)
|
||||
@@ -232,7 +255,7 @@ extension OpenClawChatViewModel {
|
||||
result.append(main)
|
||||
included.insert(main.key)
|
||||
} else {
|
||||
result.append(placeholderSession(key: mainSessionKey))
|
||||
result.append(self.placeholderSession(key: mainSessionKey))
|
||||
included.insert(mainSessionKey)
|
||||
}
|
||||
|
||||
@@ -250,7 +273,7 @@ extension OpenClawChatViewModel {
|
||||
if let current = sorted.first(where: { $0.key == self.sessionKey }) {
|
||||
result.append(current)
|
||||
} else {
|
||||
result.append(placeholderSession(key: sessionKey))
|
||||
result.append(self.placeholderSession(key: sessionKey))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,22 +21,46 @@ extension OpenClawChatViewModel {
|
||||
}
|
||||
|
||||
func persistTranscriptToCache(
|
||||
sessionKey: String,
|
||||
agentID: String?,
|
||||
session: SessionSnapshot,
|
||||
messages: [OpenClawChatMessage],
|
||||
canonicalMessageIdempotencyKeys: Set<String>)
|
||||
{
|
||||
guard let transcriptCache else { return }
|
||||
let transcriptCache = self.transcriptCache
|
||||
let outbox = self.outbox
|
||||
let scope = self.outboxBranchScope(for: session)
|
||||
let tip = messages.reversed().compactMap { message -> String? in
|
||||
let entryID = message.transcriptMessageID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return entryID?.isEmpty == false ? entryID : nil
|
||||
}.first
|
||||
guard transcriptCache != nil || (outbox != nil && scope != nil && tip != nil) else { return }
|
||||
let branchStateTask: Task<OpenClawChatOutboxBranchState?, Never>? = if let outbox, let scope {
|
||||
Task { await outbox.branchState(for: scope) }
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
// Chain writes so an older snapshot can never land after a newer one;
|
||||
// detached tasks alone give no ordering guarantee across awaits.
|
||||
let previous = pendingCacheWriteTask
|
||||
pendingCacheWriteTask = Task.detached {
|
||||
await previous?.value
|
||||
await transcriptCache.storeCanonicalTranscript(
|
||||
sessionKey: sessionKey,
|
||||
agentID: Self.transcriptCacheAgentID(sessionKey: sessionKey, agentID: agentID),
|
||||
messages: messages,
|
||||
canonicalMessageIdempotencyKeys: canonicalMessageIdempotencyKeys)
|
||||
if let transcriptCache {
|
||||
await transcriptCache.storeCanonicalTranscript(
|
||||
sessionKey: session.key,
|
||||
agentID: Self.transcriptCacheAgentID(sessionKey: session.key, agentID: session.agentID),
|
||||
messages: messages,
|
||||
canonicalMessageIdempotencyKeys: canonicalMessageIdempotencyKeys)
|
||||
}
|
||||
if let outbox,
|
||||
let scope,
|
||||
let tip,
|
||||
let branchStateTask,
|
||||
let expectedEpoch = await branchStateTask.value?.epoch
|
||||
{
|
||||
_ = await outbox.updateLastActiveLeafEntryID(
|
||||
tip,
|
||||
expectedEpoch: expectedEpoch,
|
||||
for: scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,15 +43,32 @@ extension OpenClawChatViewModel {
|
||||
Task { await self.fetchSessions(limit: 50, sessionSnapshot: context) }
|
||||
return
|
||||
}
|
||||
if change.reason == "rewind" {
|
||||
if change.reason == "rewind" || change.reason == "branch-switch" {
|
||||
guard let sessionKey = change.sessionKey,
|
||||
self.matchesCurrentSessionKey(
|
||||
incoming: sessionKey,
|
||||
agentId: change.agentId,
|
||||
current: self.sessionKey)
|
||||
else { return }
|
||||
self.replyTarget = nil
|
||||
self.runMessageScopesByRunID.removeAll()
|
||||
self.provisionalFinalMessagesByID.removeAll()
|
||||
let context = self.beginHistoryRequest()
|
||||
Task { await self.refreshHistoryAfterRun(historyRequest: context) }
|
||||
if change.reason == "branch-switch" {
|
||||
let switchActivity = self.beginSessionBranchSwitchActivity(for: context.session)
|
||||
Task {
|
||||
defer { self.endSessionBranchSwitchActivity(switchActivity) }
|
||||
await self.reconcileSessionBranchChange(
|
||||
switchActivity,
|
||||
confirmFromBranchRefresh: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await self.refreshHistoryAfterRun(historyRequest: context)
|
||||
guard self.isCurrentSession(context.session) else { return }
|
||||
await self.refreshSessionBranches(confirmingBranchChange: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard change.reason == "patch" || change.reason == "command-metadata" else { return }
|
||||
@@ -99,6 +116,7 @@ extension OpenClawChatViewModel {
|
||||
// still retire its durable row before this handler returns early.
|
||||
confirmOutboxCommands(in: [sanitized])
|
||||
guard isCurrentSession else { return }
|
||||
self.observeOutboxTranscriptTip(sanitized, session: self.currentSessionSnapshot())
|
||||
|
||||
self.invalidateHistorySnapshots()
|
||||
// The active client also receives the gateway's echo of the user turn it
|
||||
|
||||
@@ -80,6 +80,22 @@ public final class OpenClawChatViewModel {
|
||||
/// refetch; catalog-only changes (e.g. creating an empty group) alter no
|
||||
/// session rows and would otherwise stay stale until reconnect.
|
||||
public internal(set) var sessionGroupsRevision = 0
|
||||
public internal(set) var sessionBranches: [OpenClawChatSessionBranch] = []
|
||||
public internal(set) var isLoadingSessionBranches = false
|
||||
@ObservationIgnored
|
||||
var sessionBranchesRefreshGeneration: UInt64 = 0
|
||||
struct SessionBranchSwitchActivity: Equatable {
|
||||
let session: SessionSnapshot
|
||||
let generation: UInt64
|
||||
}
|
||||
|
||||
var sessionBranchSwitchActivity: SessionBranchSwitchActivity?
|
||||
@ObservationIgnored
|
||||
var nextSessionBranchSwitchGeneration: UInt64 = 0
|
||||
|
||||
var isSwitchingSessionBranch: Bool {
|
||||
self.sessionBranchSwitchActivity != nil
|
||||
}
|
||||
|
||||
/// True when this view model owns a gateway-scoped durable text outbox.
|
||||
public var supportsOfflineTextOutbox: Bool {
|
||||
@@ -139,6 +155,10 @@ public final class OpenClawChatViewModel {
|
||||
var outboxCommandIDsByMessageID: [UUID: String] = [:]
|
||||
@ObservationIgnored
|
||||
var outboxMessageIDsByCommandID: [String: UUID] = [:]
|
||||
@ObservationIgnored
|
||||
var outboxFailureVersionsByMessageID: [
|
||||
UUID: (attemptVersion: Int, retryCount: Int, lastError: String?)
|
||||
] = [:]
|
||||
/// Recent canonical keys let the MainActor resolve proof that arrives
|
||||
/// after SQLite cancellation commits but before its UI continuation runs.
|
||||
@ObservationIgnored
|
||||
@@ -148,6 +168,25 @@ public final class OpenClawChatViewModel {
|
||||
@ObservationIgnored
|
||||
var isOutboxFlushRequestedWhileActive = false
|
||||
@ObservationIgnored
|
||||
var reconciledOutboxBranchScopes: Set<OpenClawChatOutboxScope> = []
|
||||
@ObservationIgnored
|
||||
var reconcilingOutboxBranchScopes: Set<OpenClawChatOutboxScope> = []
|
||||
@ObservationIgnored
|
||||
var outboxBranchReconcileRetryAttempts: [OpenClawChatOutboxScope: Int] = [:]
|
||||
@ObservationIgnored
|
||||
var outboxBranchReconcileRetryTasks: [OpenClawChatOutboxScope: Task<Void, Never>] = [:]
|
||||
@ObservationIgnored
|
||||
var outboxBranchReconcileRetryDelaysMs: [UInt64] = [250, 1000, 4000, 16000, 30000]
|
||||
@ObservationIgnored
|
||||
var outboxBranchConnectionGeneration: UInt64 = 0
|
||||
@ObservationIgnored
|
||||
var hasEstablishedTransportHealth = false
|
||||
@ObservationIgnored
|
||||
var bootstrapOutboxBranchStateCapture: (
|
||||
generation: UInt64,
|
||||
session: SessionSnapshot,
|
||||
task: Task<OpenClawChatOutboxBranchState?, Never>)?
|
||||
@ObservationIgnored
|
||||
var cancelingOutboxCommandIDs: Set<String> = []
|
||||
@ObservationIgnored
|
||||
var outboxPresentationGeneration: UInt64 = 0
|
||||
@@ -160,7 +199,6 @@ public final class OpenClawChatViewModel {
|
||||
/// visible session. Until then the in-memory outbox state is blind to
|
||||
/// rows persisted by an earlier process, so the FIFO send gate must
|
||||
/// assume a backlog exists.
|
||||
@ObservationIgnored
|
||||
var hasRestoredOutboxMessages = false
|
||||
@ObservationIgnored
|
||||
nonisolated(unsafe) var outboxRetryTask: Task<Void, Never>?
|
||||
@@ -498,6 +536,9 @@ public final class OpenClawChatViewModel {
|
||||
self.eventTask?.cancel()
|
||||
self.bootstrapTask?.cancel()
|
||||
self.outboxRetryTask?.cancel()
|
||||
for task in self.outboxBranchReconcileRetryTasks.values {
|
||||
task.cancel()
|
||||
}
|
||||
self.outboxChangesTask?.cancel()
|
||||
self.activeSessionRunIndicatorTimeoutTask?.cancel()
|
||||
self.questionRefreshRetryTask?.cancel()
|
||||
@@ -641,7 +682,7 @@ public final class OpenClawChatViewModel {
|
||||
contractRoutingChanged
|
||||
guard bootstrapIdentityChanged else {
|
||||
if contractChanged, self.healthOK {
|
||||
flushOutboxIfNeeded()
|
||||
reconcilePendingOutboxBranchScopes()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -775,11 +816,75 @@ extension OpenClawChatViewModel {
|
||||
(!contractSensitive || self.sessionRoutingContract == snapshot.sessionRoutingContract)
|
||||
}
|
||||
|
||||
func beginSessionBranchSwitchActivity(for session: SessionSnapshot) -> SessionBranchSwitchActivity {
|
||||
self.nextSessionBranchSwitchGeneration &+= 1
|
||||
let activity = SessionBranchSwitchActivity(
|
||||
session: session,
|
||||
generation: self.nextSessionBranchSwitchGeneration)
|
||||
self.sessionBranchSwitchActivity = activity
|
||||
return activity
|
||||
}
|
||||
|
||||
func isCurrentSessionBranchSwitchActivity(_ activity: SessionBranchSwitchActivity) -> Bool {
|
||||
self.sessionBranchSwitchActivity == activity && self.isCurrentSession(activity.session)
|
||||
}
|
||||
|
||||
func endSessionBranchSwitchActivity(_ activity: SessionBranchSwitchActivity) {
|
||||
guard self.isCurrentSessionBranchSwitchActivity(activity) else { return }
|
||||
self.sessionBranchSwitchActivity = nil
|
||||
self.flushOutboxIfNeeded()
|
||||
}
|
||||
|
||||
func reconcileSessionBranchChange(
|
||||
_ activity: SessionBranchSwitchActivity,
|
||||
confirmedLeafEntryID: String? = nil,
|
||||
confirmFromBranchRefresh: Bool = false) async
|
||||
{
|
||||
var stateApplied = true
|
||||
if let confirmedLeafEntryID {
|
||||
stateApplied = await self.confirmOutboxBranchChange(
|
||||
activity.session,
|
||||
activeLeafEntryID: confirmedLeafEntryID)
|
||||
if !stateApplied {
|
||||
await self.recoverOutboxAfterSessionMutationRefreshFailure(
|
||||
activity.session,
|
||||
branchingUnsupported: false)
|
||||
}
|
||||
} else if confirmFromBranchRefresh {
|
||||
stateApplied = await self.refreshSessionBranches(confirmingBranchChange: true)
|
||||
}
|
||||
guard self.isCurrentSessionBranchSwitchActivity(activity) else { return }
|
||||
if stateApplied {
|
||||
for _ in 0..<2 {
|
||||
stateApplied = await self.refreshHistoryAfterRun(
|
||||
historyRequest: self.beginHistoryRequest(for: activity.session)).applied
|
||||
guard self.isCurrentSessionBranchSwitchActivity(activity) else { return }
|
||||
if stateApplied { break }
|
||||
}
|
||||
}
|
||||
if stateApplied, !confirmFromBranchRefresh {
|
||||
stateApplied = await self.refreshSessionBranches()
|
||||
}
|
||||
guard self.isCurrentSessionBranchSwitchActivity(activity) else { return }
|
||||
guard stateApplied else {
|
||||
// After the server-side branch changes, never keep partially consistent local state.
|
||||
// Either install the new state completely or reload the session from scratch.
|
||||
self.advanceSessionGeneration()
|
||||
self.clearSessionOwnedState()
|
||||
self.startBootstrap(paintCachedTranscript: false)
|
||||
return
|
||||
}
|
||||
self.errorText = nil
|
||||
}
|
||||
|
||||
private func isCurrentBootstrap(_ context: BootstrapContext) -> Bool {
|
||||
self.bootstrapGeneration == context.id && self.isCurrentSession(context.session)
|
||||
}
|
||||
|
||||
private func startBootstrap(sessionKey requestedSessionKey: String? = nil) {
|
||||
private func startBootstrap(
|
||||
sessionKey requestedSessionKey: String? = nil,
|
||||
paintCachedTranscript: Bool = true)
|
||||
{
|
||||
let sessionKey = requestedSessionKey ?? self.sessionKey
|
||||
guard sessionKey == self.sessionKey else { return }
|
||||
self.unreadPatchGuard.activate(key: self.sessionMutationIdentity(for: sessionKey))
|
||||
@@ -788,6 +893,7 @@ extension OpenClawChatViewModel {
|
||||
self.isLoading = true
|
||||
self.errorText = nil
|
||||
self.invalidateSessionMetadataReadiness()
|
||||
self.invalidateOutboxBranchReconciliation()
|
||||
self.healthOK = false
|
||||
clearPendingRuns(reason: nil)
|
||||
self.pendingToolCallsById = [:]
|
||||
@@ -799,7 +905,17 @@ extension OpenClawChatViewModel {
|
||||
let context = BootstrapContext(
|
||||
id: bootstrapGeneration,
|
||||
historyRequest: historyRequest)
|
||||
paintFromCacheIfNeeded(session: context.session)
|
||||
let captureTask = Task { [weak self] in
|
||||
await self?.captureOutboxBranchState(for: context.session)
|
||||
}
|
||||
self.bootstrapOutboxBranchStateCapture = (
|
||||
generation: context.id,
|
||||
session: context.session,
|
||||
task: captureTask)
|
||||
self.pauseOutboxBranchScope(context.session)
|
||||
if paintCachedTranscript {
|
||||
paintFromCacheIfNeeded(session: context.session)
|
||||
}
|
||||
restoreOutboxMessages(session: context.session)
|
||||
self.bootstrapTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -815,6 +931,15 @@ extension OpenClawChatViewModel {
|
||||
}
|
||||
}
|
||||
do {
|
||||
guard let capture = self.bootstrapOutboxBranchStateCapture,
|
||||
capture.generation == context.id
|
||||
else { return }
|
||||
let preBootstrapBranchState = await capture.task.value
|
||||
guard self.isCurrentBootstrap(context) else { return }
|
||||
async let branchRefresh = self.refreshSessionBranches(
|
||||
for: context.session,
|
||||
preBootstrapBranchState: preBootstrapBranchState)
|
||||
|
||||
await self.syncActiveSessionSubscription(startingWith: context.session.key)
|
||||
guard self.isCurrentBootstrap(context) else { return }
|
||||
|
||||
@@ -827,6 +952,8 @@ extension OpenClawChatViewModel {
|
||||
for: context.historyRequest,
|
||||
preservingOptimisticLocalMessages: false,
|
||||
syncThinkingOptions: true)
|
||||
_ = await branchRefresh
|
||||
guard self.isCurrentBootstrap(context) else { return }
|
||||
await pollHealthIfNeeded(
|
||||
force: true,
|
||||
sessionSnapshot: context.session,
|
||||
@@ -1005,7 +1132,7 @@ extension OpenClawChatViewModel {
|
||||
persistSessionsToCache(organized)
|
||||
self.readySessionMetadataGeneration = metadataGeneration
|
||||
if self.healthOK {
|
||||
flushOutboxIfNeeded()
|
||||
reconcilePendingOutboxBranchScopes()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1141,6 +1268,9 @@ extension OpenClawChatViewModel {
|
||||
clearPlan()
|
||||
self.updateActiveSessionRunWithoutChatSnapshot(false)
|
||||
resetSlashCommandCatalog()
|
||||
self.sessionBranches = []
|
||||
self.isLoadingSessionBranches = false
|
||||
self.sessionBranchSwitchActivity = nil
|
||||
clearPendingRuns(reason: nil)
|
||||
}
|
||||
|
||||
@@ -1388,29 +1518,6 @@ extension OpenClawChatViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func placeholderSession(key: String) -> OpenClawChatSessionEntry {
|
||||
OpenClawChatSessionEntry(
|
||||
key: key,
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: nil,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
abortedLastRun: nil,
|
||||
thinkingLevel: nil,
|
||||
verboseLevel: nil,
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
modelProvider: nil,
|
||||
model: nil,
|
||||
contextTokens: nil)
|
||||
}
|
||||
|
||||
func syncSelectedModel() {
|
||||
let currentSession = currentSessionEntry()
|
||||
let target = currentModelPatchTarget()
|
||||
|
||||
@@ -39,6 +39,7 @@ public final class OpenClawClientDatabases: @unchecked Sendable {
|
||||
public let directoryURL: URL
|
||||
let cacheQueue: DatabaseQueue
|
||||
let stateQueue: DatabaseQueue
|
||||
let outboxChangeHub = OutboxChangeHub()
|
||||
private let legacyDirectoryURLs: [URL]
|
||||
|
||||
public init(
|
||||
@@ -106,7 +107,7 @@ public final class OpenClawClientDatabases: @unchecked Sendable {
|
||||
/// removed. No gateway payload is deleted until the registry owner commits.
|
||||
public func stageGatewayRemoval(gatewayID: String) throws {
|
||||
let gatewayHash = Self.gatewayIdentityHash(gatewayID)
|
||||
let existingPhase = try self.stateQueue.read { db in
|
||||
let existingPhase = try stateQueue.read { db in
|
||||
try Int.fetchOne(
|
||||
db,
|
||||
sql: "SELECT cleanup_phase FROM forgotten_gateways WHERE gateway_hash = ?",
|
||||
@@ -151,7 +152,7 @@ public final class OpenClawClientDatabases: @unchecked Sendable {
|
||||
/// metadata. A failed commit remains staged for startup reconciliation.
|
||||
public func commitGatewayRemoval(gatewayID: String) throws {
|
||||
let gatewayHash = Self.gatewayIdentityHash(gatewayID)
|
||||
let existingPhase = try self.stateQueue.read { db in
|
||||
let existingPhase = try stateQueue.read { db in
|
||||
try Int.fetchOne(
|
||||
db,
|
||||
sql: "SELECT cleanup_phase FROM forgotten_gateways WHERE gateway_hash = ?",
|
||||
@@ -191,6 +192,7 @@ public final class OpenClawClientDatabases: @unchecked Sendable {
|
||||
])
|
||||
}
|
||||
try db.execute(sql: "DELETE FROM outbox_commands WHERE gateway_id = ?", arguments: [gatewayID])
|
||||
try db.execute(sql: "DELETE FROM outbox_branch_scopes WHERE gateway_id = ?", arguments: [gatewayID])
|
||||
try db.execute(
|
||||
sql: "DELETE FROM gateway_routing_identity WHERE gateway_id = ?",
|
||||
arguments: [gatewayID])
|
||||
@@ -341,6 +343,7 @@ public final class OpenClawClientDatabases: @unchecked Sendable {
|
||||
/// Closes both installation-wide handles before a full reset removes the
|
||||
/// files. Gateway-scoped deletion keeps the shared handles open.
|
||||
public func close() throws {
|
||||
self.outboxChangeHub.finish()
|
||||
try self.cacheQueue.close()
|
||||
try self.stateQueue.close()
|
||||
}
|
||||
@@ -353,7 +356,7 @@ public final class OpenClawClientDatabases: @unchecked Sendable {
|
||||
try self.removeDatabaseFilesChecked(
|
||||
at: directoryURL.appendingPathComponent(filename, isDirectory: false))
|
||||
}
|
||||
for legacyURL in self.legacyDatabaseURLs(in: directoryURL) {
|
||||
for legacyURL in legacyDatabaseURLs(in: directoryURL) {
|
||||
try self.removeDatabaseFilesChecked(at: legacyURL)
|
||||
}
|
||||
}
|
||||
@@ -386,7 +389,7 @@ public final class OpenClawClientDatabases: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private func removeLegacyGatewayDatabaseFiles(gatewayID: String) throws {
|
||||
let directories = Set([self.directoryURL] + self.legacyDirectoryURLs)
|
||||
let directories = Set([directoryURL] + self.legacyDirectoryURLs)
|
||||
for directoryURL in directories {
|
||||
try Self.removeDatabaseFilesChecked(at: Self.legacyPerGatewayDatabaseURL(
|
||||
gatewayID: gatewayID,
|
||||
@@ -395,7 +398,7 @@ public final class OpenClawClientDatabases: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private func rejectPreservedSharedLegacyDatabase() throws {
|
||||
let directories = Set([self.directoryURL] + self.legacyDirectoryURLs)
|
||||
let directories = Set([directoryURL] + self.legacyDirectoryURLs)
|
||||
guard directories.contains(where: { directoryURL in
|
||||
FileManager.default.fileExists(
|
||||
atPath: directoryURL.appendingPathComponent("chat-cache.sqlite").path)
|
||||
@@ -483,6 +486,54 @@ extension OpenClawClientDatabases {
|
||||
);
|
||||
""")
|
||||
}
|
||||
// Additive branch ownership remains local client state. Older app
|
||||
// builds ignore these fields while newer builds fail replay closed.
|
||||
migrator.registerMigration("client-state-branch-ownership-v2") { db in
|
||||
try db.execute(sql: """
|
||||
ALTER TABLE outbox_commands ADD COLUMN branch_epoch INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE outbox_commands ADD COLUMN attempt_version INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE outbox_commands ADD COLUMN parked_was_accepted INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE TABLE outbox_branch_scopes(
|
||||
gateway_id TEXT NOT NULL,
|
||||
session_key TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL DEFAULT '',
|
||||
branch_epoch INTEGER NOT NULL DEFAULT 0,
|
||||
last_active_leaf_id TEXT,
|
||||
switch_pending_since REAL,
|
||||
needs_reconciliation INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(gateway_id, session_key, agent_id)
|
||||
);
|
||||
""")
|
||||
}
|
||||
migrator.registerMigration("client-state-branch-revision-v3") { db in
|
||||
try db.execute(sql: """
|
||||
ALTER TABLE outbox_branch_scopes
|
||||
ADD COLUMN branch_state_revision INTEGER NOT NULL DEFAULT 0;
|
||||
""")
|
||||
}
|
||||
migrator.registerMigration("client-state-agent-id-v4") { db in
|
||||
try db.execute(sql: "UPDATE outbox_commands SET agent_id = '' WHERE agent_id IS NULL")
|
||||
try db.execute(sql: "UPDATE outbox_branch_scopes SET agent_id = '' WHERE agent_id IS NULL")
|
||||
}
|
||||
migrator.registerMigration("client-state-outbox-attempt-scope-v5") { db in
|
||||
try db
|
||||
.execute(
|
||||
sql: "ALTER TABLE outbox_commands ADD COLUMN had_unacknowledged_send INTEGER NOT NULL DEFAULT 0")
|
||||
// Legacy rows with prior attempts may have reached the gateway before a
|
||||
// transport failure; without this evidence a post-park retry would reuse an
|
||||
// idempotency key the old branch may already own.
|
||||
try db.execute(
|
||||
sql: """
|
||||
UPDATE outbox_commands SET had_unacknowledged_send = 1
|
||||
WHERE retry_count > 0 OR status IN ('sending', 'awaiting_confirmation')
|
||||
""")
|
||||
try db.execute(sql: """
|
||||
INSERT OR IGNORE INTO outbox_branch_scopes(
|
||||
gateway_id, session_key, agent_id, branch_epoch, needs_reconciliation
|
||||
)
|
||||
SELECT gateway_id, session_key, agent_id, 0, 1 FROM outbox_commands
|
||||
""")
|
||||
}
|
||||
try migrator.migrate(queue)
|
||||
return queue
|
||||
}
|
||||
@@ -621,7 +672,7 @@ extension OpenClawClientDatabases {
|
||||
try self.writeLegacySnapshot(ownedSnapshot)
|
||||
// Preserve bytes for unregistered gateways rather than
|
||||
// importing or destroying state whose ownership is unknown.
|
||||
let forgottenGatewayHashes = try self.forgottenGatewayHashesForLegacyImport()
|
||||
let forgottenGatewayHashes = try forgottenGatewayHashesForLegacyImport()
|
||||
let allLegacyGatewaysAccountedFor = legacyGatewayIDs.allSatisfy { gatewayID in
|
||||
registeredGatewayIDs?.contains(gatewayID) == true ||
|
||||
forgottenGatewayHashes.contains(Self.gatewayIdentityHash(gatewayID))
|
||||
@@ -652,7 +703,9 @@ extension OpenClawClientDatabases {
|
||||
name != self.gatewayCacheFilename,
|
||||
name != self.clientStateFilename
|
||||
else { return false }
|
||||
if name == "chat-cache.sqlite" { return true }
|
||||
if name == "chat-cache.sqlite" {
|
||||
return true
|
||||
}
|
||||
let stem = String(name.dropLast(".sqlite".count))
|
||||
return stem.count == 64 && stem.allSatisfy(\.isHexDigit)
|
||||
}.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
||||
@@ -802,6 +855,13 @@ extension OpenClawClientDatabases {
|
||||
attachmentBytes,
|
||||
])
|
||||
guard db.changesCount > 0 else { continue }
|
||||
try db.execute(
|
||||
sql: """
|
||||
INSERT OR IGNORE INTO outbox_branch_scopes(
|
||||
gateway_id, session_key, agent_id, branch_epoch, needs_reconciliation
|
||||
) VALUES (?, ?, ?, 0, 1)
|
||||
""",
|
||||
arguments: [command.gatewayID, command.sessionKey, command.agentID])
|
||||
for (position, attachment) in command.attachments.enumerated() {
|
||||
try db.execute(
|
||||
sql: """
|
||||
|
||||
@@ -5,7 +5,11 @@ import OpenClawNativeState
|
||||
import SQLite3
|
||||
|
||||
enum DeviceIdentitySQLiteStore {
|
||||
private static let busyTimeoutMilliseconds: Int32 = 5000
|
||||
// Parallel test bursts serialize first-time identity creation behind the
|
||||
// exclusive coordinator; 5s starved late waiters on loaded CI runners and
|
||||
// failed whichever identity-dependent tests lost the race. Uncontended
|
||||
// production access never waits this long.
|
||||
private static let busyTimeoutMilliseconds: Int32 = 30000
|
||||
private static let maximumLegacyIdentityBytes = 64 * 1024
|
||||
private static let maximumLegacyAuthBytes = 4 * 1024 * 1024
|
||||
private static let doctorClaimSuffix = ".doctor-importing"
|
||||
|
||||
@@ -5729,6 +5729,92 @@ public struct SessionsForkResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionBranch: Codable, Sendable {
|
||||
public let leafentryid: String
|
||||
public let headline: String
|
||||
public let messagecount: Int
|
||||
public let updatedat: String?
|
||||
public let active: Bool
|
||||
|
||||
public init(
|
||||
leafentryid: String,
|
||||
headline: String,
|
||||
messagecount: Int,
|
||||
updatedat: String? = nil,
|
||||
active: Bool)
|
||||
{
|
||||
self.leafentryid = leafentryid
|
||||
self.headline = headline
|
||||
self.messagecount = messagecount
|
||||
self.updatedat = updatedat
|
||||
self.active = active
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case leafentryid = "leafEntryId"
|
||||
case headline
|
||||
case messagecount = "messageCount"
|
||||
case updatedat = "updatedAt"
|
||||
case active
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsBranchesListParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
agentid: String? = nil)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsBranchesListResult: Codable, Sendable {
|
||||
public let branches: [SessionBranch]
|
||||
|
||||
public init(
|
||||
branches: [SessionBranch])
|
||||
{
|
||||
self.branches = branches
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case branches
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsBranchesSwitchParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let leafentryid: String
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
leafentryid: String)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.leafentryid = leafentryid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case leafentryid = "leafEntryId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsBranchesSwitchResult: Codable, Sendable {}
|
||||
|
||||
public struct SessionFileBrowserEntry: Codable, Sendable {
|
||||
public let path: String
|
||||
public let name: String
|
||||
|
||||
@@ -160,6 +160,29 @@ struct ChatGatewayRequestTests {
|
||||
#expect(fork.params["key"] == nil)
|
||||
}
|
||||
|
||||
@Test func `branch list and switch requests preserve routing identity`() {
|
||||
let list = OpenClawChatGatewayRequests.listSessionBranches(
|
||||
sessionKey: "agent:reviewer:telegram:group:1",
|
||||
agentID: " reviewer ")
|
||||
let switchBranch = OpenClawChatGatewayRequests.switchSessionBranch(
|
||||
sessionKey: "global",
|
||||
agentID: nil,
|
||||
leafEntryId: " leaf-42 ")
|
||||
|
||||
#expect(list.method == "sessions.branches.list")
|
||||
#expect(list.timeoutMs == 15000)
|
||||
#expect(list.params["sessionKey"]?.value as? String == "agent:reviewer:telegram:group:1")
|
||||
#expect(list.params["agentId"]?.value as? String == "reviewer")
|
||||
#expect(list.params["key"] == nil)
|
||||
|
||||
#expect(switchBranch.method == "sessions.branches.switch")
|
||||
#expect(switchBranch.timeoutMs == 15000)
|
||||
#expect(switchBranch.params["sessionKey"]?.value as? String == "global")
|
||||
#expect(switchBranch.params["agentId"] == nil)
|
||||
#expect(switchBranch.params["leafEntryId"]?.value as? String == "leaf-42")
|
||||
#expect(switchBranch.params["key"] == nil)
|
||||
}
|
||||
|
||||
@Test func `session group requests encode exact gateway contracts`() {
|
||||
let list = OpenClawChatGatewayRequests.sessionGroupsList()
|
||||
let put = OpenClawChatGatewayRequests.sessionGroupsPut(names: ["Work", "Personal"])
|
||||
|
||||
+221
-6
@@ -65,7 +65,7 @@ extension OpenClawChatSQLiteTranscriptCache {
|
||||
agentID: String? = nil,
|
||||
messages: [OpenClawChatMessage]) async
|
||||
{
|
||||
await self.storeCanonicalTranscript(
|
||||
await storeCanonicalTranscript(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID,
|
||||
messages: messages,
|
||||
@@ -792,7 +792,13 @@ struct ChatTranscriptCacheStoreTests {
|
||||
#expect(identity.contract == "per-sender|work|main")
|
||||
#expect(try await reopened.stateQueue.read { db in
|
||||
try String.fetchAll(db, sql: "SELECT identifier FROM grdb_migrations")
|
||||
} == ["client-state-v1"])
|
||||
} == [
|
||||
"client-state-v1",
|
||||
"client-state-branch-ownership-v2",
|
||||
"client-state-branch-revision-v3",
|
||||
"client-state-agent-id-v4",
|
||||
"client-state-outbox-attempt-scope-v5",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1080,6 +1086,204 @@ struct ClientDatabaseLegacyImportTests {
|
||||
}
|
||||
|
||||
struct ChatCommandOutboxStoreTests {
|
||||
@Test func `nil agent rows use the canonical empty scope owner`() async throws {
|
||||
let directory = try makeDatabaseDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = try OpenClawClientDatabases(directoryURL: directory).store(gatewayID: "gw-a")
|
||||
let scope = OpenClawChatOutboxScope(sessionKey: "main", agentID: nil)
|
||||
#expect(await store.updateLastActiveLeafEntryID("leaf-a", expectedEpoch: 0, for: scope))
|
||||
#expect(await store.enqueueCommand(OpenClawChatOutboxCommand(
|
||||
id: "nil-agent", sessionKey: "main", deliverySessionKey: "main",
|
||||
routingContract: "legacy-unbound", agentID: nil, text: "deliver", thinking: "off",
|
||||
createdAt: Date().timeIntervalSince1970, status: .queued, retryCount: 0, lastError: nil)))
|
||||
let state = try #require(await store.branchState(for: scope))
|
||||
#expect(state.hadPendingCommands)
|
||||
#expect(await store.claimNextCommand()?.id == "nil-agent")
|
||||
#expect(await store.loadCommands().first?.agentID == nil)
|
||||
}
|
||||
|
||||
@Test func `parked accepted rows mint retry identity while queued rows keep it`() async throws {
|
||||
let directory = try makeDatabaseDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = try OpenClawClientDatabases(directoryURL: directory).store(gatewayID: "gw-a")
|
||||
let sendingScope = OpenClawChatOutboxScope(sessionKey: "sending", agentID: "main")
|
||||
#expect(await store.updateLastActiveLeafEntryID("leaf-a", expectedEpoch: 0, for: sendingScope))
|
||||
let sendingState = try #require(await store.branchState(for: sendingScope))
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "sending", sessionKey: "sending", text: "maybe accepted")))
|
||||
let sending = try #require(await store.claimNextCommand())
|
||||
_ = try #require(await store.reconcileBranchScope(
|
||||
sendingScope, previousState: sendingState, activeLeafEntryID: "leaf-b",
|
||||
branchLeafEntryIDs: ["leaf-b"], lastError: "branch changed"))
|
||||
let parkedSending = try #require(await store.loadCommands().first(where: { $0.id == sending.id }))
|
||||
#expect(await store.markCommandRetriedIfPresent(
|
||||
id: parkedSending.id,
|
||||
expectation: OpenClawChatOutboxRetryExpectation(
|
||||
attemptVersion: parkedSending.attemptVersion,
|
||||
retryCount: parkedSending.retryCount,
|
||||
lastError: parkedSending.lastError),
|
||||
agentID: "main", deliverySessionKey: "sending", routingContract: "per-sender|sending|main",
|
||||
replacementID: "sending-retry") == .updated)
|
||||
let retriedSending = try #require(await store.loadCommands().first(where: { $0.id == "sending-retry" }))
|
||||
#expect(retriedSending.attemptVersion == 1)
|
||||
|
||||
let queuedScope = OpenClawChatOutboxScope(sessionKey: "queued", agentID: "main")
|
||||
#expect(await store.updateLastActiveLeafEntryID("leaf-a", expectedEpoch: 0, for: queuedScope))
|
||||
let queuedState = try #require(await store.branchState(for: queuedScope))
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "queued", sessionKey: "queued", text: "not sent")))
|
||||
_ = try #require(await store.reconcileBranchScope(
|
||||
queuedScope, previousState: queuedState, activeLeafEntryID: "leaf-b",
|
||||
branchLeafEntryIDs: ["leaf-b"], lastError: "branch changed"))
|
||||
let parkedQueued = try #require(await store.loadCommands().first(where: { $0.id == "queued" }))
|
||||
#expect(await store.markCommandRetriedIfPresent(
|
||||
id: parkedQueued.id,
|
||||
expectation: OpenClawChatOutboxRetryExpectation(
|
||||
attemptVersion: parkedQueued.attemptVersion,
|
||||
retryCount: parkedQueued.retryCount,
|
||||
lastError: parkedQueued.lastError),
|
||||
agentID: "main", deliverySessionKey: "queued", routingContract: "per-sender|queued|main",
|
||||
replacementID: "unused") == .updated)
|
||||
#expect(await store.loadCommands().contains(where: { $0.id == "queued" && $0.attemptVersion == 2 }))
|
||||
|
||||
let stickyDirectory = try makeDatabaseDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: stickyDirectory) }
|
||||
let stickyStore = try OpenClawClientDatabases(directoryURL: stickyDirectory).store(gatewayID: "gw-a")
|
||||
let requeuedScope = OpenClawChatOutboxScope(sessionKey: "requeued", agentID: "main")
|
||||
#expect(await stickyStore.updateLastActiveLeafEntryID("leaf-a", expectedEpoch: 0, for: requeuedScope))
|
||||
let requeuedState = try #require(await stickyStore.branchState(for: requeuedScope))
|
||||
#expect(await stickyStore.enqueueCommand(outboxCommand(
|
||||
id: "requeued",
|
||||
sessionKey: "requeued",
|
||||
text: "uncertain")))
|
||||
let claimed = try #require(await stickyStore.claimNextCommand())
|
||||
#expect(await stickyStore.markCommandQueued(
|
||||
id: claimed.id,
|
||||
attemptVersion: claimed.attemptVersion,
|
||||
retryCount: 1,
|
||||
lastError: "transport") == .updated)
|
||||
_ = try #require(await stickyStore.reconcileBranchScope(
|
||||
requeuedScope, previousState: requeuedState, activeLeafEntryID: "leaf-b",
|
||||
branchLeafEntryIDs: ["leaf-b"], activeTranscriptEntryIDs: [], lastError: "branch changed"))
|
||||
let parkedRequeued = try #require(await stickyStore.loadCommands().first(where: { $0.id == "requeued" }))
|
||||
#expect(await stickyStore.markCommandRetriedIfPresent(
|
||||
id: parkedRequeued.id,
|
||||
expectation: OpenClawChatOutboxRetryExpectation(
|
||||
attemptVersion: parkedRequeued.attemptVersion,
|
||||
retryCount: parkedRequeued.retryCount,
|
||||
lastError: parkedRequeued.lastError),
|
||||
agentID: "main", deliverySessionKey: "requeued", routingContract: "per-sender|requeued|main",
|
||||
replacementID: "requeued-retry") == .updated)
|
||||
#expect(await stickyStore.loadCommands()
|
||||
.contains(where: { $0.id == "requeued-retry" && $0.attemptVersion == 1 }))
|
||||
}
|
||||
|
||||
@Test func `empty root reconcile permits first row and parks a wiped scope`() async throws {
|
||||
let directory = try makeDatabaseDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = try OpenClawClientDatabases(directoryURL: directory).store(gatewayID: "gw-a")
|
||||
let root = OpenClawChatOutboxScope(sessionKey: "root", agentID: "main")
|
||||
let rootState = try #require(await store.branchState(for: root))
|
||||
_ = try #require(await store.reconcileBranchScope(
|
||||
root, previousState: rootState, activeLeafEntryID: nil,
|
||||
branchLeafEntryIDs: [], lastError: "branch changed"))
|
||||
#expect(await store.branchState(for: root)?.lastActiveLeafEntryID == nil)
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "first", sessionKey: "root", text: "first")))
|
||||
#expect(await store.claimNextCommand()?.id == "first")
|
||||
|
||||
let wiped = OpenClawChatOutboxScope(sessionKey: "wiped", agentID: "main")
|
||||
#expect(await store.updateLastActiveLeafEntryID("leaf-a", expectedEpoch: 0, for: wiped))
|
||||
let wipedState = try #require(await store.branchState(for: wiped))
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "wiped", sessionKey: "wiped", text: "park")))
|
||||
_ = try #require(await store.reconcileBranchScope(
|
||||
wiped, previousState: wipedState, activeLeafEntryID: nil,
|
||||
branchLeafEntryIDs: [], lastError: "branch changed"))
|
||||
#expect(await store.loadCommands().first(where: { $0.id == "wiped" })?.status == .failed)
|
||||
}
|
||||
|
||||
@Test func `nonancestral parking gives failed uncertain rows a fresh retry identity`() async throws {
|
||||
let directory = try makeDatabaseDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let databases = try OpenClawClientDatabases(directoryURL: directory)
|
||||
let store = databases.store(gatewayID: "gw-a")
|
||||
let scope = OpenClawChatOutboxScope(sessionKey: "main", agentID: "main")
|
||||
#expect(await store.updateLastActiveLeafEntryID("leaf-a", expectedEpoch: 0, for: scope))
|
||||
let state = try #require(await store.branchState(for: scope))
|
||||
var failed = outboxCommand(id: "failed", text: "retry")
|
||||
failed.status = .failed
|
||||
failed.lastError = "transport"
|
||||
#expect(await store.enqueueCommand(failed))
|
||||
try await databases.stateQueue.write { db in
|
||||
try db.execute(
|
||||
sql: "UPDATE outbox_commands SET had_unacknowledged_send = 1 WHERE client_uuid = ?",
|
||||
arguments: ["failed"])
|
||||
}
|
||||
_ = try #require(await store.reconcileBranchScope(
|
||||
scope, previousState: state, activeLeafEntryID: "leaf-b",
|
||||
branchLeafEntryIDs: ["leaf-b"], activeTranscriptEntryIDs: [], lastError: "branch changed"))
|
||||
let parked = try #require(await store.loadCommands().first)
|
||||
#expect(await store.markCommandRetriedIfPresent(
|
||||
id: parked.id,
|
||||
expectation: OpenClawChatOutboxRetryExpectation(
|
||||
attemptVersion: parked.attemptVersion,
|
||||
retryCount: parked.retryCount,
|
||||
lastError: parked.lastError),
|
||||
agentID: "main", deliverySessionKey: "main", routingContract: "per-sender|main|main",
|
||||
replacementID: "failed-retry") == .updated)
|
||||
#expect(await store.loadCommands().first?.id == "failed-retry")
|
||||
}
|
||||
|
||||
@Test func `bulk branch parking invalidates sibling subscribers`() async throws {
|
||||
let directory = try makeDatabaseDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let databases = try OpenClawClientDatabases(directoryURL: directory)
|
||||
let store = databases.store(gatewayID: "gw-a")
|
||||
let sibling = databases.store(gatewayID: "gw-a")
|
||||
let otherGateway = databases.store(gatewayID: "gw-b")
|
||||
let scope = OpenClawChatOutboxScope(sessionKey: "main", agentID: "main")
|
||||
#expect(await store.updateLastActiveLeafEntryID("leaf-a", expectedEpoch: 0, for: scope))
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "shared", text: "park me")))
|
||||
let changes = sibling.changes()
|
||||
let otherChanges = otherGateway.changes()
|
||||
var iterator = changes.makeAsyncIterator()
|
||||
_ = try #require(await store.confirmBranchChange(
|
||||
scope, activeLeafEntryID: "leaf-b", lastError: "branch changed"))
|
||||
#expect(await iterator.next() == .invalidated(gatewayID: "gw-a", scope: scope))
|
||||
let crossGatewayDelivered = await withTaskGroup(of: Bool.self) { group in
|
||||
group.addTask {
|
||||
var iterator = otherChanges.makeAsyncIterator()
|
||||
return await iterator.next() != nil
|
||||
}
|
||||
group.addTask {
|
||||
try? await Task.sleep(for: .milliseconds(25))
|
||||
return false
|
||||
}
|
||||
let result = await group.next() ?? true
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
#expect(crossGatewayDelivered == false)
|
||||
#expect(await sibling.loadCommands().first?.status == .failed)
|
||||
}
|
||||
|
||||
@Test func `retiring a store ends only its forwarded change streams`() async throws {
|
||||
let directory = try makeDatabaseDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let databases = try OpenClawClientDatabases(directoryURL: directory)
|
||||
let retired = databases.store(gatewayID: "gw-a")
|
||||
let sibling = databases.store(gatewayID: "gw-a")
|
||||
var retiredIterator = retired.changes().makeAsyncIterator()
|
||||
var siblingIterator = sibling.changes().makeAsyncIterator()
|
||||
|
||||
await retired.retire()
|
||||
#expect(await retiredIterator.next() == nil)
|
||||
|
||||
let scope = OpenClawChatOutboxScope(sessionKey: "main", agentID: "main")
|
||||
#expect(await sibling.updateLastActiveLeafEntryID("leaf-a", expectedEpoch: 0, for: scope))
|
||||
#expect(await sibling.enqueueCommand(outboxCommand(id: "sibling", text: "park")))
|
||||
_ = try #require(await sibling.confirmBranchChange(
|
||||
scope, activeLeafEntryID: "leaf-b", lastError: "branch changed"))
|
||||
#expect(await siblingIterator.next() == .invalidated(gatewayID: "gw-a", scope: scope))
|
||||
}
|
||||
|
||||
@Test func `commands and attachment blobs round trip in order`() async throws {
|
||||
let directory = try makeDatabaseDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
@@ -1110,9 +1314,12 @@ struct ChatCommandOutboxStoreTests {
|
||||
let now = Date().timeIntervalSince1970
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "z-first", text: "one", createdAt: now)))
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "a-second", text: "two", createdAt: now)))
|
||||
#expect(await store.claimNextCommand()?.id == "z-first")
|
||||
let first = try #require(await store.claimNextCommand())
|
||||
#expect(first.id == "z-first")
|
||||
#expect(await store.claimNextCommand() == nil)
|
||||
#expect(await store.markCommandAwaitingConfirmation(id: "z-first") == .updated)
|
||||
#expect(await store.markCommandAwaitingConfirmation(
|
||||
id: "z-first",
|
||||
attemptVersion: first.attemptVersion) == .updated)
|
||||
#expect(await store.claimNextCommand()?.id == "a-second")
|
||||
}
|
||||
|
||||
@@ -1174,8 +1381,13 @@ struct ChatCommandOutboxStoreTests {
|
||||
let store = try OpenClawClientDatabases(directoryURL: directory).store(gatewayID: "gw-a")
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "retry", text: "again", status: .failed)))
|
||||
|
||||
let failed = try #require(await store.loadCommands().first)
|
||||
#expect(await store.markCommandRetriedIfPresent(
|
||||
id: "retry",
|
||||
expectation: OpenClawChatOutboxRetryExpectation(
|
||||
attemptVersion: failed.attemptVersion,
|
||||
retryCount: failed.retryCount,
|
||||
lastError: failed.lastError),
|
||||
agentID: "Agent-B",
|
||||
deliverySessionKey: "agent:agent-b:main",
|
||||
routingContract: "per-sender|main|agent-b") == .updated)
|
||||
@@ -1194,8 +1406,11 @@ struct ChatCommandOutboxStoreTests {
|
||||
let old = Date().timeIntervalSince1970 - OpenClawChatSQLiteTranscriptCache.outboxCommandMaxAge - 1
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "old-queued", text: "old", createdAt: old)))
|
||||
#expect(await store.enqueueCommand(outboxCommand(id: "old-ack", text: "old ack")))
|
||||
#expect(await store.claimNextCommand()?.id == "old-ack")
|
||||
#expect(await store.markCommandAwaitingConfirmation(id: "old-ack") == .updated)
|
||||
let acknowledged = try #require(await store.claimNextCommand())
|
||||
#expect(acknowledged.id == "old-ack")
|
||||
#expect(await store.markCommandAwaitingConfirmation(
|
||||
id: "old-ack",
|
||||
attemptVersion: acknowledged.attemptVersion) == .updated)
|
||||
try await databases.stateQueue.write { db in
|
||||
try db.execute(
|
||||
sql: "UPDATE outbox_commands SET created_at = ? WHERE gateway_id = ? AND client_uuid = ?",
|
||||
|
||||
@@ -4,6 +4,50 @@ import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
extension OpenClawChatSQLiteTranscriptCache {
|
||||
fileprivate func markCommandAwaitingConfirmation(id: String) async -> OpenClawChatOutboxUpdateResult {
|
||||
guard let command = await self.loadCommands().first(where: { $0.id == id }) else { return .missing }
|
||||
return await self.markCommandAwaitingConfirmation(id: id, attemptVersion: command.attemptVersion)
|
||||
}
|
||||
|
||||
fileprivate func markCommandFailedIfPresent(
|
||||
id: String,
|
||||
retryCount: Int,
|
||||
lastError: String?) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
guard let command = await self.loadCommands().first(where: { $0.id == id }) else { return .missing }
|
||||
return await self.markCommandFailedIfPresent(
|
||||
id: id,
|
||||
attemptVersion: command.attemptVersion,
|
||||
retryCount: retryCount,
|
||||
lastError: lastError)
|
||||
}
|
||||
|
||||
fileprivate func markCommandRetriedIfPresent(
|
||||
id: String,
|
||||
agentID: String?,
|
||||
deliverySessionKey: String,
|
||||
routingContract: String) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
guard let command = await self.loadCommands().first(where: { $0.id == id }) else { return .missing }
|
||||
return await self.markCommandRetriedIfPresent(
|
||||
id: id,
|
||||
expectation: OpenClawChatOutboxRetryExpectation(
|
||||
attemptVersion: command.attemptVersion,
|
||||
retryCount: command.retryCount,
|
||||
lastError: command.lastError),
|
||||
agentID: agentID,
|
||||
deliverySessionKey: deliverySessionKey,
|
||||
routingContract: routingContract,
|
||||
replacementID: nil)
|
||||
}
|
||||
|
||||
fileprivate func confirmCommand(id: String) async -> OpenClawChatOutboxUpdateResult {
|
||||
guard let command = await self.loadCommands().first(where: { $0.id == id }) else { return .missing }
|
||||
return await self.confirmCommand(id: id, attemptVersion: command.attemptVersion)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeOutboxDatabaseDirectory() throws -> URL {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("chat-outbox-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
@@ -587,31 +631,46 @@ private actor DelayingOutbox: OpenClawChatCommandOutbox {
|
||||
await self.base.claimNextCommand()
|
||||
}
|
||||
|
||||
func markCommandQueued(id: String, retryCount: Int, lastError: String?) async {
|
||||
await self.base.markCommandQueued(id: id, retryCount: retryCount, lastError: lastError)
|
||||
func markCommandQueued(
|
||||
id: String,
|
||||
attemptVersion: Int,
|
||||
retryCount: Int,
|
||||
lastError: String?) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
await self.base.markCommandQueued(
|
||||
id: id,
|
||||
attemptVersion: attemptVersion,
|
||||
retryCount: retryCount,
|
||||
lastError: lastError)
|
||||
}
|
||||
|
||||
func markCommandAwaitingConfirmation(id: String) async -> OpenClawChatOutboxUpdateResult {
|
||||
await self.base.markCommandAwaitingConfirmation(id: id)
|
||||
func markCommandAwaitingConfirmation(id: String, attemptVersion: Int) async -> OpenClawChatOutboxUpdateResult {
|
||||
await self.base.markCommandAwaitingConfirmation(id: id, attemptVersion: attemptVersion)
|
||||
}
|
||||
|
||||
func markCommandFailedIfPresent(
|
||||
id: String,
|
||||
id: String, attemptVersion: Int,
|
||||
retryCount: Int,
|
||||
lastError: String?) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
guard self.terminalWritesAvailable else { return .unavailable }
|
||||
return await self.base.markCommandFailedIfPresent(id: id, retryCount: retryCount, lastError: lastError)
|
||||
return await self.base.markCommandFailedIfPresent(
|
||||
id: id,
|
||||
attemptVersion: attemptVersion,
|
||||
retryCount: retryCount,
|
||||
lastError: lastError)
|
||||
}
|
||||
|
||||
func markCommandRetriedIfPresent(
|
||||
id: String,
|
||||
expectation: OpenClawChatOutboxRetryExpectation,
|
||||
agentID: String?,
|
||||
deliverySessionKey: String,
|
||||
routingContract: String) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
await self.base.markCommandRetriedIfPresent(
|
||||
id: id,
|
||||
expectation: expectation,
|
||||
agentID: agentID,
|
||||
deliverySessionKey: deliverySessionKey,
|
||||
routingContract: routingContract)
|
||||
@@ -621,8 +680,51 @@ private actor DelayingOutbox: OpenClawChatCommandOutbox {
|
||||
await self.base.cancelCommand(id: id)
|
||||
}
|
||||
|
||||
func confirmCommand(id: String) async -> OpenClawChatOutboxUpdateResult {
|
||||
await self.base.confirmCommand(id: id)
|
||||
func confirmCommand(id: String, attemptVersion: Int) async -> OpenClawChatOutboxUpdateResult {
|
||||
await self.base.confirmCommand(id: id, attemptVersion: attemptVersion)
|
||||
}
|
||||
|
||||
func branchState(for scope: OpenClawChatOutboxScope) async -> OpenClawChatOutboxBranchState? {
|
||||
await self.base.branchState(for: scope)
|
||||
}
|
||||
|
||||
func beginBranchSwitch(_ scope: OpenClawChatOutboxScope) async -> Bool {
|
||||
await self.base.beginBranchSwitch(scope)
|
||||
}
|
||||
|
||||
func cancelBranchSwitch(_ scope: OpenClawChatOutboxScope) async -> Bool {
|
||||
await self.base.cancelBranchSwitch(scope)
|
||||
}
|
||||
|
||||
func reconcileBranchScope(
|
||||
_ scope: OpenClawChatOutboxScope,
|
||||
previousState: OpenClawChatOutboxBranchState,
|
||||
activeLeafEntryID: String?,
|
||||
branchLeafEntryIDs: Set<String>,
|
||||
activeTranscriptEntryIDs: Set<String>,
|
||||
lastError: String) async -> [OpenClawChatOutboxCommand]?
|
||||
{
|
||||
await self.base.reconcileBranchScope(
|
||||
scope, previousState: previousState, activeLeafEntryID: activeLeafEntryID,
|
||||
branchLeafEntryIDs: branchLeafEntryIDs,
|
||||
activeTranscriptEntryIDs: activeTranscriptEntryIDs,
|
||||
lastError: lastError)
|
||||
}
|
||||
|
||||
func confirmBranchChange(
|
||||
_ scope: OpenClawChatOutboxScope,
|
||||
activeLeafEntryID: String,
|
||||
lastError: String) async -> [OpenClawChatOutboxCommand]?
|
||||
{
|
||||
await self.base.confirmBranchChange(scope, activeLeafEntryID: activeLeafEntryID, lastError: lastError)
|
||||
}
|
||||
|
||||
func updateLastActiveLeafEntryID(
|
||||
_ leafEntryID: String,
|
||||
expectedEpoch: Int,
|
||||
for scope: OpenClawChatOutboxScope) async -> Bool
|
||||
{
|
||||
await self.base.updateLastActiveLeafEntryID(leafEntryID, expectedEpoch: expectedEpoch, for: scope)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,30 +791,45 @@ private actor SnapshotHoldingOutbox: OpenClawChatCommandOutbox {
|
||||
await self.base.claimNextCommand()
|
||||
}
|
||||
|
||||
func markCommandQueued(id: String, retryCount: Int, lastError: String?) async {
|
||||
await self.base.markCommandQueued(id: id, retryCount: retryCount, lastError: lastError)
|
||||
}
|
||||
|
||||
func markCommandAwaitingConfirmation(id: String) async -> OpenClawChatOutboxUpdateResult {
|
||||
await self.base.markCommandAwaitingConfirmation(id: id)
|
||||
}
|
||||
|
||||
func markCommandFailedIfPresent(
|
||||
func markCommandQueued(
|
||||
id: String,
|
||||
attemptVersion: Int,
|
||||
retryCount: Int,
|
||||
lastError: String?) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
await self.base.markCommandFailedIfPresent(id: id, retryCount: retryCount, lastError: lastError)
|
||||
await self.base.markCommandQueued(
|
||||
id: id,
|
||||
attemptVersion: attemptVersion,
|
||||
retryCount: retryCount,
|
||||
lastError: lastError)
|
||||
}
|
||||
|
||||
func markCommandAwaitingConfirmation(id: String, attemptVersion: Int) async -> OpenClawChatOutboxUpdateResult {
|
||||
await self.base.markCommandAwaitingConfirmation(id: id, attemptVersion: attemptVersion)
|
||||
}
|
||||
|
||||
func markCommandFailedIfPresent(
|
||||
id: String, attemptVersion: Int,
|
||||
retryCount: Int,
|
||||
lastError: String?) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
await self.base.markCommandFailedIfPresent(
|
||||
id: id,
|
||||
attemptVersion: attemptVersion,
|
||||
retryCount: retryCount,
|
||||
lastError: lastError)
|
||||
}
|
||||
|
||||
func markCommandRetriedIfPresent(
|
||||
id: String,
|
||||
expectation: OpenClawChatOutboxRetryExpectation,
|
||||
agentID: String?,
|
||||
deliverySessionKey: String,
|
||||
routingContract: String) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
await self.base.markCommandRetriedIfPresent(
|
||||
id: id,
|
||||
expectation: expectation,
|
||||
agentID: agentID,
|
||||
deliverySessionKey: deliverySessionKey,
|
||||
routingContract: routingContract)
|
||||
@@ -722,8 +839,8 @@ private actor SnapshotHoldingOutbox: OpenClawChatCommandOutbox {
|
||||
await self.base.cancelCommand(id: id)
|
||||
}
|
||||
|
||||
func confirmCommand(id: String) async -> OpenClawChatOutboxUpdateResult {
|
||||
await self.base.confirmCommand(id: id)
|
||||
func confirmCommand(id: String, attemptVersion: Int) async -> OpenClawChatOutboxUpdateResult {
|
||||
await self.base.confirmCommand(id: id, attemptVersion: attemptVersion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -771,30 +888,45 @@ private actor CancellationHoldingOutbox: OpenClawChatCommandOutbox {
|
||||
await self.base.claimNextCommand()
|
||||
}
|
||||
|
||||
func markCommandQueued(id: String, retryCount: Int, lastError: String?) async {
|
||||
await self.base.markCommandQueued(id: id, retryCount: retryCount, lastError: lastError)
|
||||
}
|
||||
|
||||
func markCommandAwaitingConfirmation(id: String) async -> OpenClawChatOutboxUpdateResult {
|
||||
await self.base.markCommandAwaitingConfirmation(id: id)
|
||||
}
|
||||
|
||||
func markCommandFailedIfPresent(
|
||||
func markCommandQueued(
|
||||
id: String,
|
||||
attemptVersion: Int,
|
||||
retryCount: Int,
|
||||
lastError: String?) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
await self.base.markCommandFailedIfPresent(id: id, retryCount: retryCount, lastError: lastError)
|
||||
await self.base.markCommandQueued(
|
||||
id: id,
|
||||
attemptVersion: attemptVersion,
|
||||
retryCount: retryCount,
|
||||
lastError: lastError)
|
||||
}
|
||||
|
||||
func markCommandAwaitingConfirmation(id: String, attemptVersion: Int) async -> OpenClawChatOutboxUpdateResult {
|
||||
await self.base.markCommandAwaitingConfirmation(id: id, attemptVersion: attemptVersion)
|
||||
}
|
||||
|
||||
func markCommandFailedIfPresent(
|
||||
id: String, attemptVersion: Int,
|
||||
retryCount: Int,
|
||||
lastError: String?) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
await self.base.markCommandFailedIfPresent(
|
||||
id: id,
|
||||
attemptVersion: attemptVersion,
|
||||
retryCount: retryCount,
|
||||
lastError: lastError)
|
||||
}
|
||||
|
||||
func markCommandRetriedIfPresent(
|
||||
id: String,
|
||||
expectation: OpenClawChatOutboxRetryExpectation,
|
||||
agentID: String?,
|
||||
deliverySessionKey: String,
|
||||
routingContract: String) async -> OpenClawChatOutboxUpdateResult
|
||||
{
|
||||
await self.base.markCommandRetriedIfPresent(
|
||||
id: id,
|
||||
expectation: expectation,
|
||||
agentID: agentID,
|
||||
deliverySessionKey: deliverySessionKey,
|
||||
routingContract: routingContract)
|
||||
@@ -807,8 +939,8 @@ private actor CancellationHoldingOutbox: OpenClawChatCommandOutbox {
|
||||
return result
|
||||
}
|
||||
|
||||
func confirmCommand(id: String) async -> OpenClawChatOutboxUpdateResult {
|
||||
await self.base.confirmCommand(id: id)
|
||||
func confirmCommand(id: String, attemptVersion: Int) async -> OpenClawChatOutboxUpdateResult {
|
||||
await self.base.confirmCommand(id: id, attemptVersion: attemptVersion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1078,6 +1210,14 @@ struct ChatViewModelOutboxTests {
|
||||
let store = try makeOutboxStore(
|
||||
databaseDirectoryURL: databaseDirectory,
|
||||
gatewayID: "gw-test")
|
||||
#expect(await store.updateLastActiveLeafEntryID(
|
||||
"leaf-new",
|
||||
expectedEpoch: 0,
|
||||
for: OpenClawChatOutboxScope(sessionKey: "main", agentID: "main")))
|
||||
#expect(await store.updateLastActiveLeafEntryID(
|
||||
"leaf-new",
|
||||
expectedEpoch: 0,
|
||||
for: OpenClawChatOutboxScope(sessionKey: "main", agentID: nil)))
|
||||
#expect(await store.enqueueCommand(OpenClawChatOutboxCommand(
|
||||
id: "alpha-ultra",
|
||||
sessionKey: "main",
|
||||
@@ -1666,6 +1806,14 @@ struct ChatViewModelOutboxTests {
|
||||
let store = try makeOutboxStore(
|
||||
databaseDirectoryURL: databaseDirectory,
|
||||
gatewayID: "gw-test")
|
||||
#expect(await store.updateLastActiveLeafEntryID(
|
||||
"leaf-new",
|
||||
expectedEpoch: 0,
|
||||
for: OpenClawChatOutboxScope(sessionKey: "main", agentID: "main")))
|
||||
#expect(await store.updateLastActiveLeafEntryID(
|
||||
"leaf-new",
|
||||
expectedEpoch: 0,
|
||||
for: OpenClawChatOutboxScope(sessionKey: "main", agentID: nil)))
|
||||
#expect(await store.enqueueCommand(OpenClawChatOutboxCommand(
|
||||
id: "c-terminal-write",
|
||||
sessionKey: "main",
|
||||
@@ -2412,6 +2560,14 @@ extension ChatViewModelOutboxTests {
|
||||
let store = try makeOutboxStore(
|
||||
databaseDirectoryURL: databaseDirectory,
|
||||
gatewayID: "gw-test")
|
||||
#expect(await store.updateLastActiveLeafEntryID(
|
||||
"leaf-new",
|
||||
expectedEpoch: 0,
|
||||
for: OpenClawChatOutboxScope(sessionKey: "second", agentID: nil)))
|
||||
#expect(await store.updateLastActiveLeafEntryID(
|
||||
"leaf-new",
|
||||
expectedEpoch: 0,
|
||||
for: OpenClawChatOutboxScope(sessionKey: "second", agentID: "main")))
|
||||
// Backlog persisted for a session that is not initially visible.
|
||||
#expect(await store.enqueueCommand(OpenClawChatOutboxCommand(
|
||||
id: UUID().uuidString,
|
||||
|
||||
+674
-14
@@ -3,11 +3,38 @@ import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
private func makeSessionActionOutboxDirectory() throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("chat-session-action-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
private func sessionActionOutboxCommand(
|
||||
id: String,
|
||||
text: String) -> OpenClawChatOutboxCommand
|
||||
{
|
||||
OpenClawChatOutboxCommand(
|
||||
id: id,
|
||||
sessionKey: "main",
|
||||
text: text,
|
||||
thinking: "off",
|
||||
createdAt: Date().timeIntervalSince1970,
|
||||
status: .queued,
|
||||
retryCount: 0,
|
||||
lastError: nil)
|
||||
}
|
||||
|
||||
private actor SessionActionTransportState {
|
||||
var forkedParentKeys: [String] = []
|
||||
var rewoundMessages: [(sessionKey: String, entryID: String)] = []
|
||||
var forkedMessages: [(sessionKey: String, entryID: String)] = []
|
||||
var branchListSessionKeys: [String] = []
|
||||
var branchListCallCount = 0
|
||||
var switchedBranches: [(sessionKey: String, leafEntryID: String)] = []
|
||||
var sentSessionKeys: [String] = []
|
||||
var historySessionKeys: [String] = []
|
||||
var historyCallCount = 0
|
||||
var patchedKeys: [String] = []
|
||||
var deletedKeys: [String] = []
|
||||
var groupPuts: [[String]] = []
|
||||
@@ -26,8 +53,24 @@ private actor SessionActionTransportState {
|
||||
self.forkedMessages.append((sessionKey, entryID))
|
||||
}
|
||||
|
||||
func recordHistory(_ sessionKey: String) {
|
||||
func recordBranchList(_ sessionKey: String) -> Int {
|
||||
self.branchListSessionKeys.append(sessionKey)
|
||||
defer { self.branchListCallCount += 1 }
|
||||
return self.branchListCallCount
|
||||
}
|
||||
|
||||
func recordBranchSwitch(sessionKey: String, leafEntryID: String) {
|
||||
self.switchedBranches.append((sessionKey, leafEntryID))
|
||||
}
|
||||
|
||||
func recordSend(sessionKey: String) {
|
||||
self.sentSessionKeys.append(sessionKey)
|
||||
}
|
||||
|
||||
func recordHistory(_ sessionKey: String) -> Int {
|
||||
self.historySessionKeys.append(sessionKey)
|
||||
defer { self.historyCallCount += 1 }
|
||||
return self.historyCallCount
|
||||
}
|
||||
|
||||
func recordPatch(_ key: String) {
|
||||
@@ -48,9 +91,9 @@ private actor SessionActionTransportState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Signals the exact suspension point before fork completion, then holds it so
|
||||
/// Signals the exact suspension point before an action completes, then holds it so
|
||||
/// navigation can advance deterministically before the stale result resumes.
|
||||
private struct SessionActionForkGate: Sendable {
|
||||
private struct SessionActionCompletionGate: Sendable {
|
||||
private let startedStream: AsyncStream<Void>
|
||||
private let startedContinuation: AsyncStream<Void>.Continuation
|
||||
private let releaseStream: AsyncStream<Void>
|
||||
@@ -83,28 +126,62 @@ private struct SessionActionForkGate: Sendable {
|
||||
|
||||
private final class SessionActionTransport: @unchecked Sendable, OpenClawChatTransport {
|
||||
private let state = SessionActionTransportState()
|
||||
private let forkGate: SessionActionForkGate?
|
||||
private let forkAtMessageGate: SessionActionForkGate?
|
||||
private let forkGate: SessionActionCompletionGate?
|
||||
private let rewindGate: SessionActionCompletionGate?
|
||||
private let forkAtMessageGate: SessionActionCompletionGate?
|
||||
private let branchSwitchGate: SessionActionCompletionGate?
|
||||
private let branchListGates: [SessionActionCompletionGate]
|
||||
private let rewindEditorText: String?
|
||||
private let forkAtMessageSessionKey: String
|
||||
private let forkAtMessageEditorText: String?
|
||||
private let branches: [OpenClawChatSessionBranch]
|
||||
private let branchListResponses: [[OpenClawChatSessionBranch]]
|
||||
private let branchListFailureIndices: Set<Int>
|
||||
private let historyGates: [Int: SessionActionCompletionGate]
|
||||
private let historyFailureIndices: Set<Int>
|
||||
private let sendSucceeds: Bool
|
||||
|
||||
init(
|
||||
forkGate: SessionActionForkGate? = nil,
|
||||
forkAtMessageGate: SessionActionForkGate? = nil,
|
||||
forkGate: SessionActionCompletionGate? = nil,
|
||||
rewindGate: SessionActionCompletionGate? = nil,
|
||||
forkAtMessageGate: SessionActionCompletionGate? = nil,
|
||||
branchSwitchGate: SessionActionCompletionGate? = nil,
|
||||
branchListGates: [SessionActionCompletionGate] = [],
|
||||
rewindEditorText: String? = "rewound draft",
|
||||
forkAtMessageSessionKey: String = "forked-at-message",
|
||||
forkAtMessageEditorText: String? = "forked draft")
|
||||
forkAtMessageEditorText: String? = "forked draft",
|
||||
branches: [OpenClawChatSessionBranch] = [],
|
||||
branchListResponses: [[OpenClawChatSessionBranch]] = [],
|
||||
branchListFailureIndices: Set<Int> = [],
|
||||
historyGates: [Int: SessionActionCompletionGate] = [:],
|
||||
historyFailureIndices: Set<Int> = [],
|
||||
sendSucceeds: Bool = false)
|
||||
{
|
||||
self.forkGate = forkGate
|
||||
self.rewindGate = rewindGate
|
||||
self.forkAtMessageGate = forkAtMessageGate
|
||||
self.branchSwitchGate = branchSwitchGate
|
||||
self.branchListGates = branchListGates
|
||||
self.rewindEditorText = rewindEditorText
|
||||
self.forkAtMessageSessionKey = forkAtMessageSessionKey
|
||||
self.forkAtMessageEditorText = forkAtMessageEditorText
|
||||
self.branches = branches
|
||||
self.branchListResponses = branchListResponses
|
||||
self.branchListFailureIndices = branchListFailureIndices
|
||||
self.historyGates = historyGates
|
||||
self.historyFailureIndices = historyFailureIndices
|
||||
self.sendSucceeds = sendSucceeds
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
await self.state.recordHistory(sessionKey)
|
||||
let callIndex = await self.state.recordHistory(sessionKey)
|
||||
await self.historyGates[callIndex]?.suspendCompletion()
|
||||
if self.historyFailureIndices.contains(callIndex) {
|
||||
throw NSError(
|
||||
domain: "SessionActionTransport",
|
||||
code: 2,
|
||||
userInfo: [NSLocalizedDescriptionKey: "history unavailable"])
|
||||
}
|
||||
return OpenClawChatHistoryPayload(
|
||||
sessionKey: sessionKey,
|
||||
sessionId: "session-\(sessionKey)",
|
||||
@@ -113,12 +190,16 @@ private final class SessionActionTransport: @unchecked Sendable, OpenClawChatTra
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
sessionKey _: String,
|
||||
sessionKey: String,
|
||||
message _: String,
|
||||
thinking _: String,
|
||||
idempotencyKey _: String,
|
||||
idempotencyKey: String,
|
||||
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
await self.state.recordSend(sessionKey: sessionKey)
|
||||
if self.sendSucceeds {
|
||||
return OpenClawChatSendResponse(runId: idempotencyKey, status: "accepted")
|
||||
}
|
||||
throw NSError(domain: "SessionActionTransport", code: 1)
|
||||
}
|
||||
|
||||
@@ -133,6 +214,7 @@ private final class SessionActionTransport: @unchecked Sendable, OpenClawChatTra
|
||||
entryId: String) async throws -> OpenClawChatRewindResponse
|
||||
{
|
||||
await self.state.recordRewind(sessionKey: sessionKey, entryID: entryId)
|
||||
await self.rewindGate?.suspendCompletion()
|
||||
return OpenClawChatRewindResponse(editorText: self.rewindEditorText)
|
||||
}
|
||||
|
||||
@@ -147,6 +229,28 @@ private final class SessionActionTransport: @unchecked Sendable, OpenClawChatTra
|
||||
editorText: self.forkAtMessageEditorText)
|
||||
}
|
||||
|
||||
func listSessionBranches(
|
||||
sessionKey: String,
|
||||
agentID _: String?) async throws -> OpenClawChatSessionBranchesResponse
|
||||
{
|
||||
let callIndex = await self.state.recordBranchList(sessionKey)
|
||||
if self.branchListGates.indices.contains(callIndex) {
|
||||
await self.branchListGates[callIndex].suspendCompletion()
|
||||
}
|
||||
if self.branchListFailureIndices.contains(callIndex) {
|
||||
throw NSError(domain: "SessionActionTransport", code: 3)
|
||||
}
|
||||
let branches = self.branchListResponses.indices.contains(callIndex)
|
||||
? self.branchListResponses[callIndex]
|
||||
: self.branches
|
||||
return OpenClawChatSessionBranchesResponse(branches: branches)
|
||||
}
|
||||
|
||||
func switchSessionBranch(sessionKey: String, agentID _: String?, leafEntryId: String) async throws {
|
||||
await self.state.recordBranchSwitch(sessionKey: sessionKey, leafEntryID: leafEntryId)
|
||||
await self.branchSwitchGate?.suspendCompletion()
|
||||
}
|
||||
|
||||
func patchSession(
|
||||
key: String,
|
||||
label _: String??,
|
||||
@@ -221,6 +325,18 @@ private final class SessionActionTransport: @unchecked Sendable, OpenClawChatTra
|
||||
await self.state.forkedMessages
|
||||
}
|
||||
|
||||
func branchListSessionKeys() async -> [String] {
|
||||
await self.state.branchListSessionKeys
|
||||
}
|
||||
|
||||
func switchedBranches() async -> [(sessionKey: String, leafEntryID: String)] {
|
||||
await self.state.switchedBranches
|
||||
}
|
||||
|
||||
func sentSessionKeys() async -> [String] {
|
||||
await self.state.sentSessionKeys
|
||||
}
|
||||
|
||||
func historySessionKeys() async -> [String] {
|
||||
await self.state.historySessionKeys
|
||||
}
|
||||
@@ -433,6 +549,425 @@ struct ChatViewModelSessionActionTests {
|
||||
#expect(await transport.historySessionKeys().isEmpty)
|
||||
}
|
||||
|
||||
@Test func `rewind waits for current session outbox confirmation`() async throws {
|
||||
let directory = try makeSessionActionOutboxDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let databases = try OpenClawClientDatabases(directoryURL: directory)
|
||||
let store = databases.store(gatewayID: "gw-test")
|
||||
let scope = OpenClawChatOutboxScope(sessionKey: "main", agentID: nil)
|
||||
#expect(await store.updateLastActiveLeafEntryID("leaf-active", expectedEpoch: 0, for: scope))
|
||||
#expect(await store.enqueueCommand(sessionActionOutboxCommand(
|
||||
id: "rewind-pending",
|
||||
text: "wait before rewind")))
|
||||
let transport = SessionActionTransport(branches: self.branches())
|
||||
let viewModel = OpenClawChatViewModel(
|
||||
sessionKey: "main",
|
||||
transport: transport,
|
||||
outbox: store)
|
||||
viewModel.restoreOutboxMessages(session: viewModel.currentSessionSnapshot())
|
||||
#expect(await self.waitForOutboxRestore(viewModel))
|
||||
|
||||
await viewModel.rewindToMessage(self.userMessage(entryID: "message-42"))
|
||||
|
||||
#expect(viewModel.canPerformMessageSessionAction == false)
|
||||
#expect(await transport.rewoundMessages().isEmpty)
|
||||
await viewModel.confirmOutboxCommandsNow(in: [self.confirmingMessage(commandID: "rewind-pending")])
|
||||
#expect(viewModel.canPerformMessageSessionAction)
|
||||
|
||||
await viewModel.rewindToMessage(self.userMessage(entryID: "message-42"))
|
||||
|
||||
#expect(await transport.rewoundMessages().map { [$0.sessionKey, $0.entryID] } == [
|
||||
["main", "message-42"],
|
||||
])
|
||||
}
|
||||
|
||||
@Test func `fork at message waits for current session outbox confirmation`() async throws {
|
||||
let directory = try makeSessionActionOutboxDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = try OpenClawClientDatabases(directoryURL: directory).store(gatewayID: "gw-test")
|
||||
#expect(await store.enqueueCommand(sessionActionOutboxCommand(
|
||||
id: "fork-pending",
|
||||
text: "wait before fork")))
|
||||
let transport = SessionActionTransport()
|
||||
let viewModel = OpenClawChatViewModel(
|
||||
sessionKey: "main",
|
||||
transport: transport,
|
||||
outbox: store)
|
||||
viewModel.restoreOutboxMessages(session: viewModel.currentSessionSnapshot())
|
||||
#expect(await self.waitForOutboxRestore(viewModel))
|
||||
|
||||
await viewModel.forkAtMessage(self.userMessage(entryID: "message-42"))
|
||||
|
||||
#expect(viewModel.canPerformMessageSessionAction == false)
|
||||
#expect(await transport.forkedMessages().isEmpty)
|
||||
await viewModel.confirmOutboxCommandsNow(in: [self.confirmingMessage(commandID: "fork-pending")])
|
||||
#expect(viewModel.canPerformMessageSessionAction)
|
||||
|
||||
await viewModel.forkAtMessage(self.userMessage(entryID: "message-42"))
|
||||
|
||||
#expect(await transport.forkedMessages().map { [$0.sessionKey, $0.entryID] } == [
|
||||
["main", "message-42"],
|
||||
])
|
||||
}
|
||||
|
||||
@Test func `rewind bumps branch epoch and parks a racing enqueue`() async throws {
|
||||
let directory = try makeSessionActionOutboxDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let databases = try OpenClawClientDatabases(directoryURL: directory)
|
||||
let store = databases.store(gatewayID: "gw-test")
|
||||
let siblingStore = databases.store(gatewayID: "gw-test")
|
||||
let scope = OpenClawChatOutboxScope(sessionKey: "main", agentID: nil)
|
||||
#expect(await store.updateLastActiveLeafEntryID("leaf-active", expectedEpoch: 0, for: scope))
|
||||
let rewindGate = SessionActionCompletionGate()
|
||||
let transport = SessionActionTransport(
|
||||
rewindGate: rewindGate,
|
||||
branches: self.branches(activeLeafEntryID: "leaf-new"))
|
||||
let viewModel = OpenClawChatViewModel(
|
||||
sessionKey: "main",
|
||||
transport: transport,
|
||||
outbox: store)
|
||||
viewModel.hasRestoredOutboxMessages = true
|
||||
|
||||
let rewind = Task {
|
||||
await viewModel.rewindToMessage(self.userMessage(entryID: "message-42"))
|
||||
}
|
||||
guard await self.waitForForkStart(rewindGate) else {
|
||||
rewindGate.release()
|
||||
rewind.cancel()
|
||||
Issue.record("timed out waiting for rewind start signal")
|
||||
return
|
||||
}
|
||||
#expect(await siblingStore.enqueueCommand(sessionActionOutboxCommand(
|
||||
id: "racing-rewind",
|
||||
text: "belongs to the old transcript")))
|
||||
#expect(await siblingStore.claimNextCommand() == nil)
|
||||
|
||||
rewindGate.release()
|
||||
await rewind.value
|
||||
|
||||
let state = try #require(await store.branchState(for: scope))
|
||||
#expect(state.epoch == 1)
|
||||
#expect(state.lastActiveLeafEntryID == "leaf-new")
|
||||
#expect(state.switchPendingSince == nil)
|
||||
let racedCommand = try #require(await store.loadCommands().first)
|
||||
#expect(racedCommand.id == "racing-rewind")
|
||||
#expect(racedCommand.status == .failed)
|
||||
#expect(OpenClawChatSQLiteTranscriptCache.outboxDisplayError(racedCommand.lastError) ==
|
||||
"Session branch changed; review and retry this message.")
|
||||
}
|
||||
|
||||
@Test func `rewind list failure clears lease and later reconcile delivers`() async throws {
|
||||
let directory = try makeSessionActionOutboxDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let databases = try OpenClawClientDatabases(directoryURL: directory)
|
||||
let store = databases.store(gatewayID: "gw-test")
|
||||
let siblingStore = databases.store(gatewayID: "gw-test")
|
||||
let scope = OpenClawChatOutboxScope(sessionKey: "main", agentID: nil)
|
||||
#expect(await store.updateLastActiveLeafEntryID("leaf-active", expectedEpoch: 0, for: scope))
|
||||
let transport = SessionActionTransport(
|
||||
branches: self.branches(),
|
||||
branchListFailureIndices: [0],
|
||||
sendSucceeds: true)
|
||||
let viewModel = OpenClawChatViewModel(
|
||||
sessionKey: "main",
|
||||
transport: transport,
|
||||
outbox: store)
|
||||
viewModel.hasRestoredOutboxMessages = true
|
||||
|
||||
await viewModel.rewindToMessage(self.userMessage(entryID: "message-42"))
|
||||
|
||||
#expect(await store.branchState(for: scope)?.switchPendingSince == nil)
|
||||
#expect(await store.branchState(for: scope)?.needsReconciliation == true)
|
||||
#expect(viewModel.reconciledOutboxBranchScopes.contains(scope) == false)
|
||||
#expect(await store.enqueueCommand(sessionActionOutboxCommand(
|
||||
id: "after-rewind-list-failure",
|
||||
text: "send after reconcile")))
|
||||
#expect(await siblingStore.claimNextCommand() == nil)
|
||||
viewModel.healthOK = true
|
||||
viewModel.readySessionMetadataGeneration = viewModel.sessionMetadataGeneration
|
||||
viewModel.flushOutboxIfNeeded()
|
||||
|
||||
#expect(await self.waitForSend(transport))
|
||||
#expect(await transport.branchListSessionKeys().suffix(2) == ["main", "main"])
|
||||
#expect(await transport.sentSessionKeys() == ["main"])
|
||||
#expect(await store.loadCommands().map(\.status) == [.awaitingConfirmation])
|
||||
}
|
||||
|
||||
@Test func `branch refresh populates state`() async {
|
||||
let branches = self.branches()
|
||||
let transport = SessionActionTransport(branches: branches)
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
|
||||
await viewModel.refreshSessionBranches()
|
||||
|
||||
#expect(viewModel.sessionBranches == branches)
|
||||
#expect(viewModel.isLoadingSessionBranches == false)
|
||||
#expect(await transport.branchListSessionKeys() == ["main"])
|
||||
}
|
||||
|
||||
@Test func `opening branch menu refreshes conversation stale metadata once`() async {
|
||||
let staleBranches = self.branches()
|
||||
let freshBranches = self.branches(activeLeafEntryID: "leaf-new")
|
||||
let transport = SessionActionTransport(branches: freshBranches)
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
viewModel.sessionBranches = staleBranches
|
||||
|
||||
await viewModel.refreshSessionBranchesForMenuPresentation()
|
||||
|
||||
#expect(viewModel.sessionBranches == freshBranches)
|
||||
#expect(await transport.branchListSessionKeys() == ["main"])
|
||||
}
|
||||
|
||||
@Test func `branch message count uses localized singular and plural forms`() {
|
||||
#expect(OpenClawChatComposer.branchMessageCount(1) == "1 message")
|
||||
#expect(OpenClawChatComposer.branchMessageCount(2) == "2 messages")
|
||||
}
|
||||
|
||||
@Test func `branch refresh failure preserves cached branches`() async {
|
||||
let branches = self.branches()
|
||||
let transport = SessionActionTransport(branchListFailureIndices: [0])
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
viewModel.sessionBranches = branches
|
||||
|
||||
await viewModel.refreshSessionBranches()
|
||||
|
||||
#expect(viewModel.sessionBranches == branches)
|
||||
#expect(viewModel.isLoadingSessionBranches == false)
|
||||
#expect(await transport.branchListSessionKeys() == ["main"])
|
||||
}
|
||||
|
||||
@Test func `read only branch refresh failure preserves replay eligibility`() async throws {
|
||||
let directory = try makeSessionActionOutboxDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = try OpenClawClientDatabases(directoryURL: directory).store(gatewayID: "gw-test")
|
||||
let scope = OpenClawChatOutboxScope(sessionKey: "main", agentID: nil)
|
||||
let transport = SessionActionTransport(branchListFailureIndices: [0])
|
||||
let viewModel = OpenClawChatViewModel(
|
||||
sessionKey: "main",
|
||||
transport: transport,
|
||||
outbox: store)
|
||||
viewModel.reconciledOutboxBranchScopes.insert(scope)
|
||||
|
||||
await viewModel.refreshSessionBranchesForMenuPresentation()
|
||||
|
||||
#expect(viewModel.reconciledOutboxBranchScopes.contains(scope))
|
||||
#expect(await store.branchState(for: scope)?.switchPendingSince == nil)
|
||||
}
|
||||
|
||||
@Test func `newer branch refresh supersedes an older response`() async {
|
||||
let firstGate = SessionActionCompletionGate()
|
||||
let oldBranches = self.branches()
|
||||
let newBranches = self.branches(activeLeafEntryID: "leaf-new")
|
||||
let transport = SessionActionTransport(
|
||||
branchListGates: [firstGate],
|
||||
branchListResponses: [oldBranches, newBranches])
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
|
||||
let firstRefresh = Task { await viewModel.refreshSessionBranches() }
|
||||
guard await self.waitForForkStart(firstGate) else {
|
||||
firstGate.release()
|
||||
firstRefresh.cancel()
|
||||
Issue.record("timed out waiting for branch list start signal")
|
||||
return
|
||||
}
|
||||
await viewModel.refreshSessionBranches()
|
||||
|
||||
#expect(viewModel.sessionBranches == newBranches)
|
||||
firstGate.release()
|
||||
await firstRefresh.value
|
||||
|
||||
#expect(viewModel.sessionBranches == newBranches)
|
||||
#expect(viewModel.isLoadingSessionBranches == false)
|
||||
#expect(await transport.branchListSessionKeys() == ["main", "main"])
|
||||
}
|
||||
|
||||
@Test func `branch switch refreshes history and branch state`() async {
|
||||
let branches = self.branches()
|
||||
let transport = SessionActionTransport(branches: branches)
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
viewModel.sessionBranches = branches
|
||||
|
||||
await viewModel.switchToBranch("leaf-new")
|
||||
|
||||
#expect(await transport.switchedBranches().map { [$0.sessionKey, $0.leafEntryID] } == [
|
||||
["main", "leaf-new"],
|
||||
])
|
||||
#expect(await transport.historySessionKeys() == ["main"])
|
||||
#expect(await transport.branchListSessionKeys() == ["main"])
|
||||
#expect(viewModel.sessionBranches == branches)
|
||||
}
|
||||
|
||||
@Test(arguments: [false, true])
|
||||
func `branch change failure funnels through full session reload`(remoteEvent: Bool) async {
|
||||
let historyReloadGate = SessionActionCompletionGate()
|
||||
let branchesReloadGate = SessionActionCompletionGate()
|
||||
let remoteConfirmationGate = SessionActionCompletionGate()
|
||||
let staleBranches = self.branches()
|
||||
let freshBranches = self.branches(activeLeafEntryID: "leaf-new")
|
||||
let transport = SessionActionTransport(
|
||||
branchListGates: remoteEvent
|
||||
? [remoteConfirmationGate, branchesReloadGate]
|
||||
: [branchesReloadGate],
|
||||
branches: freshBranches,
|
||||
historyGates: [2: historyReloadGate],
|
||||
historyFailureIndices: [0, 1])
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
viewModel.sessionBranches = staleBranches
|
||||
viewModel.messages = [self.userMessage(entryID: "pre-switch")]
|
||||
|
||||
if remoteEvent {
|
||||
viewModel.handleTransportEvent(.sessionsChanged(.init(
|
||||
sessionKey: "main",
|
||||
reason: "branch-switch")))
|
||||
_ = await self.waitForForkStart(remoteConfirmationGate)
|
||||
remoteConfirmationGate.release()
|
||||
} else {
|
||||
await viewModel.switchToBranch("leaf-new")
|
||||
}
|
||||
|
||||
let historyReloadStarted = await self.waitForForkStart(historyReloadGate)
|
||||
let branchesReloadStarted = await self.waitForForkStart(branchesReloadGate)
|
||||
#expect(historyReloadStarted)
|
||||
#expect(branchesReloadStarted)
|
||||
#expect(await transport.historySessionKeys() == ["main", "main", "main"])
|
||||
#expect(await transport.branchListSessionKeys() == (remoteEvent ? ["main", "main"] : ["main"]))
|
||||
#expect(viewModel.messages.isEmpty)
|
||||
#expect(viewModel.sessionBranches.isEmpty)
|
||||
#expect(viewModel.hasAppliedLiveHistory == false)
|
||||
#expect(viewModel.isLoading)
|
||||
|
||||
historyReloadGate.release()
|
||||
branchesReloadGate.release()
|
||||
let reloaded = await self.waitForBranchReload(viewModel, branches: freshBranches)
|
||||
#expect(reloaded)
|
||||
#expect(viewModel.sessionBranches.first(where: \.active)?.leafEntryId == "leaf-new")
|
||||
}
|
||||
|
||||
@Test func `branch switch does not dispatch while busy`() async {
|
||||
let branches = self.branches()
|
||||
let transport = SessionActionTransport(branches: branches)
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
viewModel.sessionBranches = branches
|
||||
viewModel.isSending = true
|
||||
|
||||
await viewModel.switchToBranch("leaf-new")
|
||||
|
||||
#expect(await transport.switchedBranches().isEmpty)
|
||||
#expect(await transport.historySessionKeys().isEmpty)
|
||||
#expect(await transport.branchListSessionKeys().isEmpty)
|
||||
}
|
||||
|
||||
@Test func `branch switch does not overlap an in flight switch`() async {
|
||||
let gate = SessionActionCompletionGate()
|
||||
let branches = self.branches()
|
||||
let transport = SessionActionTransport(branchSwitchGate: gate, branches: branches)
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
viewModel.sessionBranches = branches
|
||||
|
||||
let firstSwitch = Task { await viewModel.switchToBranch("leaf-new") }
|
||||
guard await self.waitForForkStart(gate) else {
|
||||
gate.release()
|
||||
firstSwitch.cancel()
|
||||
Issue.record("timed out waiting for branch switch start signal")
|
||||
return
|
||||
}
|
||||
await viewModel.switchToBranch("leaf-new")
|
||||
|
||||
#expect(await transport.switchedBranches().count == 1)
|
||||
gate.release()
|
||||
await firstSwitch.value
|
||||
}
|
||||
|
||||
@Test func `branch switch blocks sends and rewinds until refresh completes`() async {
|
||||
let gate = SessionActionCompletionGate()
|
||||
let branches = self.branches()
|
||||
let transport = SessionActionTransport(branchSwitchGate: gate, branches: branches)
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
viewModel.sessionBranches = branches
|
||||
viewModel.input = "new message"
|
||||
|
||||
let branchSwitch = Task { await viewModel.switchToBranch("leaf-new") }
|
||||
guard await self.waitForForkStart(gate) else {
|
||||
gate.release()
|
||||
branchSwitch.cancel()
|
||||
Issue.record("timed out waiting for branch switch start signal")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(viewModel.hasBlockingRunActivity)
|
||||
#expect(viewModel.canSend == false)
|
||||
viewModel.send()
|
||||
await viewModel.rewindToMessage(self.userMessage(entryID: "message-42"))
|
||||
await viewModel.forkAtMessage(self.userMessage(entryID: "message-42"))
|
||||
for _ in 0..<10 {
|
||||
await Task.yield()
|
||||
}
|
||||
|
||||
#expect(await transport.sentSessionKeys().isEmpty)
|
||||
#expect(await transport.rewoundMessages().isEmpty)
|
||||
#expect(await transport.forkedMessages().isEmpty)
|
||||
|
||||
gate.release()
|
||||
await branchSwitch.value
|
||||
|
||||
#expect(viewModel.hasBlockingRunActivity == false)
|
||||
#expect(viewModel.canSend)
|
||||
}
|
||||
|
||||
@Test func `stale branch switch completion is ignored`() async {
|
||||
let gate = SessionActionCompletionGate()
|
||||
let branches = self.branches()
|
||||
let transport = SessionActionTransport(branchSwitchGate: gate, branches: branches)
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
viewModel.sessionBranches = branches
|
||||
|
||||
let branchSwitch = Task { await viewModel.switchToBranch("leaf-new") }
|
||||
guard await self.waitForForkStart(gate) else {
|
||||
gate.release()
|
||||
branchSwitch.cancel()
|
||||
Issue.record("timed out waiting for branch switch start signal")
|
||||
return
|
||||
}
|
||||
viewModel.switchSession(to: "other")
|
||||
gate.release()
|
||||
await branchSwitch.value
|
||||
|
||||
#expect(viewModel.sessionKey == "other")
|
||||
#expect(await transport.switchedBranches().map { [$0.sessionKey, $0.leafEntryID] } == [
|
||||
["main", "leaf-new"],
|
||||
])
|
||||
#expect(await transport.historySessionKeys().contains("main") == false)
|
||||
#expect(await transport.branchListSessionKeys().contains("main") == false)
|
||||
}
|
||||
|
||||
@Test func `navigation releases branch switch gate before stale completion`() async {
|
||||
let gate = SessionActionCompletionGate()
|
||||
let branches = self.branches()
|
||||
let transport = SessionActionTransport(branchSwitchGate: gate, branches: branches)
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
viewModel.sessionBranches = branches
|
||||
|
||||
let branchSwitch = Task { await viewModel.switchToBranch("leaf-new") }
|
||||
guard await self.waitForForkStart(gate) else {
|
||||
gate.release()
|
||||
branchSwitch.cancel()
|
||||
Issue.record("timed out waiting for branch switch start signal")
|
||||
return
|
||||
}
|
||||
|
||||
viewModel.switchSession(to: "other")
|
||||
viewModel.input = "new session message"
|
||||
#expect(viewModel.hasBlockingRunActivity == false)
|
||||
#expect(viewModel.canSend)
|
||||
|
||||
gate.release()
|
||||
await branchSwitch.value
|
||||
|
||||
#expect(viewModel.sessionKey == "other")
|
||||
#expect(viewModel.hasBlockingRunActivity == false)
|
||||
#expect(viewModel.canSend)
|
||||
}
|
||||
|
||||
@Test func `fork at message switches and seeds editor`() async {
|
||||
let transport = SessionActionTransport(
|
||||
forkAtMessageSessionKey: "agent:main:forked",
|
||||
@@ -447,7 +982,7 @@ struct ChatViewModelSessionActionTests {
|
||||
}
|
||||
|
||||
@Test func `fork at message completion does not override newer navigation`() async {
|
||||
let forkGate = SessionActionForkGate()
|
||||
let forkGate = SessionActionCompletionGate()
|
||||
let transport = SessionActionTransport(
|
||||
forkAtMessageGate: forkGate,
|
||||
forkAtMessageSessionKey: "agent:main:forked")
|
||||
@@ -481,6 +1016,28 @@ struct ChatViewModelSessionActionTests {
|
||||
#expect(await transport.historySessionKeys() == ["main"])
|
||||
}
|
||||
|
||||
@Test func `remote branch switch refreshes current transcript and branches only`() async {
|
||||
let transport = SessionActionTransport(branches: self.branches())
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
viewModel.setReplyTarget(messageID: UUID(), text: "old branch", senderLabel: "User")
|
||||
viewModel.input = "new message"
|
||||
|
||||
viewModel.handleTransportEvent(.sessionsChanged(.init(sessionKey: "other", reason: "branch-switch")))
|
||||
#expect(viewModel.replyTarget != nil)
|
||||
#expect(viewModel.canSend)
|
||||
viewModel.handleTransportEvent(.sessionsChanged(.init(sessionKey: "main", reason: "branch-switch")))
|
||||
#expect(viewModel.hasBlockingRunActivity)
|
||||
#expect(viewModel.canSend == false)
|
||||
|
||||
let refreshed = await self.waitForBranchListRequest(transport)
|
||||
#expect(refreshed)
|
||||
let unlocked = await self.waitForBranchSwitchActivityToClear(viewModel)
|
||||
#expect(unlocked)
|
||||
#expect(viewModel.replyTarget == nil)
|
||||
#expect(await transport.historySessionKeys() == ["main"])
|
||||
#expect(await transport.branchListSessionKeys() == ["main"])
|
||||
}
|
||||
|
||||
@Test func `fork does not mutate gateway while session switching is blocked`() async {
|
||||
let transport = SessionActionTransport()
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
@@ -501,7 +1058,7 @@ struct ChatViewModelSessionActionTests {
|
||||
}
|
||||
|
||||
@Test func `fork completion does not override newer navigation`() async {
|
||||
let forkGate = SessionActionForkGate()
|
||||
let forkGate = SessionActionCompletionGate()
|
||||
let transport = SessionActionTransport(forkGate: forkGate)
|
||||
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
|
||||
|
||||
@@ -521,7 +1078,7 @@ struct ChatViewModelSessionActionTests {
|
||||
}
|
||||
|
||||
private func waitForForkStart(
|
||||
_ gate: SessionActionForkGate,
|
||||
_ gate: SessionActionCompletionGate,
|
||||
timeout: Duration = .seconds(15)) async -> Bool
|
||||
{
|
||||
// The stream controls ordering; this deadline only bounds a broken fake or call path.
|
||||
@@ -552,6 +1109,84 @@ struct ChatViewModelSessionActionTests {
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForBranchListRequest(
|
||||
_ transport: SessionActionTransport,
|
||||
timeout: Duration = .seconds(15)) async -> Bool
|
||||
{
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now + timeout
|
||||
while clock.now < deadline {
|
||||
if await transport.branchListSessionKeys().isEmpty == false {
|
||||
return true
|
||||
}
|
||||
await Task.yield()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForBranchSwitchActivityToClear(
|
||||
_ viewModel: OpenClawChatViewModel,
|
||||
timeout: Duration = .seconds(15)) async -> Bool
|
||||
{
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now + timeout
|
||||
while clock.now < deadline {
|
||||
if viewModel.hasBlockingRunActivity == false {
|
||||
return true
|
||||
}
|
||||
await Task.yield()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForOutboxRestore(
|
||||
_ viewModel: OpenClawChatViewModel,
|
||||
timeout: Duration = .seconds(15)) async -> Bool
|
||||
{
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now + timeout
|
||||
while clock.now < deadline {
|
||||
if viewModel.hasRestoredOutboxMessages,
|
||||
viewModel.hasPendingOutboxCommandsForCurrentSession
|
||||
{
|
||||
return true
|
||||
}
|
||||
await Task.yield()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForSend(
|
||||
_ transport: SessionActionTransport,
|
||||
timeout: Duration = .seconds(15)) async -> Bool
|
||||
{
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now + timeout
|
||||
while clock.now < deadline {
|
||||
if await transport.sentSessionKeys().isEmpty == false {
|
||||
return true
|
||||
}
|
||||
await Task.yield()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForBranchReload(
|
||||
_ viewModel: OpenClawChatViewModel,
|
||||
branches: [OpenClawChatSessionBranch],
|
||||
timeout: Duration = .seconds(15)) async -> Bool
|
||||
{
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now + timeout
|
||||
while clock.now < deadline {
|
||||
if viewModel.sessionBranches == branches, !viewModel.isLoading {
|
||||
return true
|
||||
}
|
||||
await Task.yield()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func userMessage(entryID: String) -> OpenClawChatMessage {
|
||||
OpenClawChatMessage(
|
||||
role: "user",
|
||||
@@ -560,6 +1195,31 @@ struct ChatViewModelSessionActionTests {
|
||||
transcriptMessageID: entryID)
|
||||
}
|
||||
|
||||
private func confirmingMessage(commandID: String) -> OpenClawChatMessage {
|
||||
OpenClawChatMessage(
|
||||
role: "user",
|
||||
content: [],
|
||||
timestamp: nil,
|
||||
idempotencyKey: "\(commandID):user")
|
||||
}
|
||||
|
||||
private func branches(activeLeafEntryID: String = "leaf-active") -> [OpenClawChatSessionBranch] {
|
||||
[
|
||||
OpenClawChatSessionBranch(
|
||||
leafEntryId: "leaf-active",
|
||||
headline: "Current path",
|
||||
messageCount: 4,
|
||||
updatedAt: "2026-07-19T12:00:00Z",
|
||||
active: activeLeafEntryID == "leaf-active"),
|
||||
OpenClawChatSessionBranch(
|
||||
leafEntryId: "leaf-new",
|
||||
headline: "Alternate path",
|
||||
messageCount: 2,
|
||||
updatedAt: nil,
|
||||
active: activeLeafEntryID == "leaf-new"),
|
||||
]
|
||||
}
|
||||
|
||||
private func entry(key: String) -> OpenClawChatSessionEntry {
|
||||
OpenClawChatSessionEntry(
|
||||
key: key,
|
||||
|
||||
@@ -477,7 +477,12 @@ import {
|
||||
SessionsCatalogReadResultSchema,
|
||||
} from "./sessions-catalog.js";
|
||||
import {
|
||||
SessionBranchSchema,
|
||||
SessionsAbortParamsSchema,
|
||||
SessionsBranchesListParamsSchema,
|
||||
SessionsBranchesListResultSchema,
|
||||
SessionsBranchesSwitchParamsSchema,
|
||||
SessionsBranchesSwitchResultSchema,
|
||||
SessionsCompactParamsSchema,
|
||||
SessionsCompactionBranchParamsSchema,
|
||||
SessionsCompactionBranchResultSchema,
|
||||
@@ -810,6 +815,11 @@ export const ProtocolSchemas = {
|
||||
SessionsRewindResult: SessionsRewindResultSchema,
|
||||
SessionsForkParams: SessionsForkParamsSchema,
|
||||
SessionsForkResult: SessionsForkResultSchema,
|
||||
SessionBranch: SessionBranchSchema,
|
||||
SessionsBranchesListParams: SessionsBranchesListParamsSchema,
|
||||
SessionsBranchesListResult: SessionsBranchesListResultSchema,
|
||||
SessionsBranchesSwitchParams: SessionsBranchesSwitchParamsSchema,
|
||||
SessionsBranchesSwitchResult: SessionsBranchesSwitchResultSchema,
|
||||
SessionFileBrowserEntry: SessionFileBrowserEntrySchema,
|
||||
SessionFileBrowserResult: SessionFileBrowserResultSchema,
|
||||
SessionFileKind: SessionFileKindSchema,
|
||||
|
||||
Reference in New Issue
Block a user