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:
Peter Steinberger
2026-07-21 10:59:44 -07:00
committed by GitHub
parent f0793b7e60
commit 8f31892b55
28 changed files with 3135 additions and 265 deletions
+93 -53
View File
@@ -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: &params, 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 {
@@ -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
@@ -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"])
@@ -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,
@@ -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,