diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index b2f6af8e775d..c064ff63abaa 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -396,6 +396,7 @@ enum class GatewayMethod( SessionsRewind("sessions.rewind"), SessionsFork("sessions.fork"), SessionsCreate("sessions.create"), + SessionsRecover("sessions.recover"), SessionsSend("sessions.send"), SessionsAbort("sessions.abort"), SessionsPatch("sessions.patch"), diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 1b0b2e342f4c..c0070f549a3c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -5953,6 +5953,7 @@ public struct SessionRow: Codable, Sendable { public let lastinteractionat: Double? public let status: AnyCodable? public let lastrunerror: String? + public let restartrecoverystatus: String? public let activeleafentryid: AnyCodable? public let spawnedby: String? public let parentsessionkey: String? @@ -6015,6 +6016,7 @@ public struct SessionRow: Codable, Sendable { lastinteractionat: Double? = nil, status: AnyCodable? = nil, lastrunerror: String? = nil, + restartrecoverystatus: String? = nil, activeleafentryid: AnyCodable? = nil, spawnedby: String? = nil, parentsessionkey: String? = nil, @@ -6076,6 +6078,7 @@ public struct SessionRow: Codable, Sendable { self.lastinteractionat = lastinteractionat self.status = status self.lastrunerror = lastrunerror + self.restartrecoverystatus = restartrecoverystatus self.activeleafentryid = activeleafentryid self.spawnedby = spawnedby self.parentsessionkey = parentsessionkey @@ -6139,6 +6142,7 @@ public struct SessionRow: Codable, Sendable { case lastinteractionat = "lastInteractionAt" case status case lastrunerror = "lastRunError" + case restartrecoverystatus = "restartRecoveryStatus" case activeleafentryid = "activeLeafEntryId" case spawnedby = "spawnedBy" case parentsessionkey = "parentSessionKey" @@ -8447,6 +8451,50 @@ public struct SessionsCreateResult: Codable, Sendable { } } +public struct SessionsRecoverParams: Codable, Sendable { + public let key: String + public let agentid: String? + + public init( + key: String, + agentid: String? = nil) + { + self.key = key + self.agentid = agentid + } + + private enum CodingKeys: String, CodingKey { + case key + case agentid = "agentId" + } +} + +public struct SessionsRecoverResult: Codable, Sendable { + public let ok: Bool + public let key: String + public let sessionid: String + public let continuation: AnyCodable + + public init( + ok: Bool, + key: String, + sessionid: String, + continuation: AnyCodable) + { + self.ok = ok + self.key = key + self.sessionid = sessionid + self.continuation = continuation + } + + private enum CodingKeys: String, CodingKey { + case ok + case key + case sessionid = "sessionId" + case continuation + } +} + public struct SessionsSendParams: Codable, Sendable { public let key: String public let agentid: String? diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 67c274762077..4f1b2767f6d4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"30cf4f65584fea104edf342dd3a9121d30de8c26d9cfe67825e7c8b0c777e21f","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"bcb2d5e0214c73a0352443baf8cb707bb90c701853cefe61eefc1fdfa3e7fe92","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index e394e6cfaaba..7a370bd526f9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"04a43ad5caff854c063d5151a119e7f6b566f326f56db5dfd8856e804f5fe2de","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"12d73f365af2349d0b2318ad303af1c24db36a6ddf68bc141b3713696a6daa43","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json index 632e8ca5bf90..110651a5405e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json @@ -1 +1 @@ -{"contentHash":"acdc878b3110b6eb7a56b4be45faa5ee938e83bba08da469338e74ff2272d7f4","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} +{"contentHash":"15b87abc01b6482b3092b12aac8e918de6c9df732702d63914df34a6b030f26e","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 5e81bc5936cc..60a8fec7d9c0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"2984444f1ee0ebd2a6597d6a9fb4ce5286b39613b685bf307a775a0c85a5751f","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"2dcd8c79a124e110e625371372bbd7410c66f256934f9003cd0c49826a785459","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index d0166da306e4..6d61f5e72341 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"aed307240ced7847020be82f398b688fccbcafb8b0d5f1ca8ae6d6bae756f21a","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"d382ede401d9c5f6a3121630fa13ac34b1c2aa1425d7ae28659d47f29e5f6290","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json index db6c283ec355..805534853c09 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json @@ -1 +1 @@ -{"contentHash":"13c0832e71dab56c9cd61c8b63e38be59ba73d6e668a292d350ca717f31de129","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} +{"contentHash":"083809c090eab05652b84a3acd0618f6e60bb28287dc13e3e57270d2e37bec1c","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 0a61cb67e2d4..b3f0fd04af56 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"d3315da878582d8333a99d3ca1ba5451d447d191894df8ee35540db7c0870b8e","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"9cb2f743250d5b0d1469ea6344d7d30de4d2aa0b8c40881e9d0415e3d9912961","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index f1e8a90b8e63..e4b6a921560f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"5f61f7addeb83870fbf35d569624313f3e31cc84275ec6b5f4a65fde4c2e07ce","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"e1f879c60c79ffee904e89c29c1a4f12f0c3282e69afc1bcb46b8b9d309f92a7","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json b/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json index 9d30862644d9..dc790432aabf 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json @@ -1 +1 @@ -{"contentHash":"19379d0ce6b726c7a754de86d8baf620706460fbb8dac335555fd5b06358e60e","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"} +{"contentHash":"b5208a7965ea72776fa9015b0864b9924404806927ee9162e24eaf904a514ed7","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 0617f16acf67..9b465b102f2b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"3a7cc068c6fb57a76a30f2708a5ec67d3a5023f5c55445f525550fa47a725b73","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"7c674d21cfd9f48f10234d7ced1c3ae4589c9750e0f402d049865bb0459c03ba","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json b/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json index b46fab2dc463..318b23cba0b5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json @@ -1 +1 @@ -{"contentHash":"64b9d73bee51fd041dc4555fe9dbb06179140b035b4956571c2374f8ee10628d","entrypoint":"channel-reply-pipeline","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline"} +{"contentHash":"8825ff02425d974e8bc2963b0ee733c22382eb03ec97b381970e0ed7326806ed","entrypoint":"channel-reply-pipeline","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json b/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json index 33feac6d5e13..5f8cfab7142f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json @@ -1 +1 @@ -{"contentHash":"b226a1e13c943d0b973ab845f86840ddc03d773c8447ac7c8e5007e65b599581","entrypoint":"command-auth-native","importSpecifier":"openclaw/plugin-sdk/command-auth-native"} +{"contentHash":"3855304ef46d440d4e5783de59b87a60c0e5e4a30f26668b22e8cae37a5c7e49","entrypoint":"command-auth-native","importSpecifier":"openclaw/plugin-sdk/command-auth-native"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-auth.json b/docs/.generated/plugin-sdk-api-baseline/command-auth.json index f9db2203df25..62a6394ea1f0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-auth.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-auth.json @@ -1 +1 @@ -{"contentHash":"db3b9ff52de1b3b650cdfde4d64019209326f5d6e2203259822a6df946201002","entrypoint":"command-auth","importSpecifier":"openclaw/plugin-sdk/command-auth"} +{"contentHash":"80351d7c0b54fe254538cb887162cc8467b393666ffb212b29de2a490f78ab95","entrypoint":"command-auth","importSpecifier":"openclaw/plugin-sdk/command-auth"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json index e2777566e36c..8ddfe3b60470 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json @@ -1 +1 @@ -{"contentHash":"ff0d61988c767ef519ed3ca8d65ec8c52fb9c95b9273bf76a5209ee0c6e92c44","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} +{"contentHash":"7ddd541c90a2d0755ebe6c289eeb07a4918a531ffce5c24dcba6ccf1c5376ec3","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 6b3403900ed3..db1c4b59849d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"bb6c1b3ba80725df4f77d9fdcb7cfa23708a89dc283f179c417b2413f393edce","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"ecd20ca3c6c947bbf1d6867f9ac7ddc8da0ee6c0d51ed55f5e946d186484077f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index fcb61796b93d..c5b209c6feac 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"e937d0c9dcd7e614f2606bb1fd11524b6cbd8b18aef4bf1abf053958ce5af0fb","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"cdf57e7f08eefd8ede2162297d4db0f1e30094dd9d9e5f0176bc8585d5372084","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index 14a051f5f45f..d0784bc4cfec 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"cccc089cc9b839725c8f8ecc828ade72fb797684b21548977041122a42ac4d2d","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"9be585be7caffa3686f64e9d84ae3c51c2fe4cccbfaf598f33f3082e3ceba40e","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index e2b1867c9320..c7cd524f3d99 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"61b0578351fa18e36fd88426e541101f368749fde15163cea9b3ae0861efe9fe","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"644de05b59eae4368a5aa846d88137e7e77e61c7c45ee1439770e544f33ce534","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 603c90df5964..8b80d90a95f9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"52462bacc819d8a62678b5eec09a5aa8f2f544bd5d67869c8a5afe465f1b2fe2","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"b13afbf796748191ddab99cbae60d40ec5cddc50b08b09ab8f9e683ebf7f3c34","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json index 6ff01f6bbdeb..b047215d6234 100644 --- a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json @@ -1 +1 @@ -{"contentHash":"49858013fb88e4d2fa389b1530519ff710eeb9829619e84c57c2762743901dd3","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} +{"contentHash":"b1ccfb49f91b31ff82717f5052f946235a4faa761b12372fca3a2d403f0b4438","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json b/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json index d6bf2491c2de..fb0a7a5f68cc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json @@ -1 +1 @@ -{"contentHash":"fa28516c53cb3c131c365f6ceef286a4cd3025a610545f32bd091b9d40c6d932","entrypoint":"models-provider-runtime","importSpecifier":"openclaw/plugin-sdk/models-provider-runtime"} +{"contentHash":"c28e091f733460acc85e936bc82cfa77dfd611a9e8e2b6ac506e5d795a13dc4c","entrypoint":"models-provider-runtime","importSpecifier":"openclaw/plugin-sdk/models-provider-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json index 260de341da0e..233579c24366 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json @@ -1 +1 @@ -{"contentHash":"60cb9df1dfabddde17570f7782f7a8e593f466677fc33dc9b50b6709de8559fc","entrypoint":"plugin-command-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-command-runtime"} +{"contentHash":"2adca88c3ddff7fea95c93a793364c808a67f67f7adb01f0b5c210c90d30c6e7","entrypoint":"plugin-command-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-command-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index ed2a2f70b807..fcdc91c49bc7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"6ec95a6a1a3b8efc15d32d7c929b983225a9953efd1c28e415f7d4b8e888a6af","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"bb957b8916022522a8f9b8309e8dcea2f6cc03941ece8b1f6fcff569973f677d","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 3bdc7f20feeb..d374870d01f7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"752b3e01c032e68d70e2d24972366c7d0655d1007af00ea1b71d3e13083b91c0","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"d7cf3b452540f716d0de28d8cfd39721577a69bac00f6df1ddc0e8223d4ffca3","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 3cd6a8b9764a..aa57f14dc4cb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"c5c0d35df118a2f51de7f9b2abc021a4d9ebfbc2fe7802d5ac23263a3990c195","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"4ebeddf97cd8fee4eb9edeab6b6f05d3540bfa160a842c4c83993836deae6ba4","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json b/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json index cff195a386f6..d569959d10bf 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json @@ -1 +1 @@ -{"contentHash":"3f9c3de29ec5aca2f1e2d09e6032fce20ed9f350f30750dc85f4b3a8c41c21c4","entrypoint":"reply-dispatch-runtime","importSpecifier":"openclaw/plugin-sdk/reply-dispatch-runtime"} +{"contentHash":"20d56779377eb793b4801cc35371016a0fc537bde4dba23bf3d79ebe42635d08","entrypoint":"reply-dispatch-runtime","importSpecifier":"openclaw/plugin-sdk/reply-dispatch-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json b/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json index 857eb9d398eb..ee9a1736e9d4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json @@ -1 +1 @@ -{"contentHash":"94d2227081920079037cf5e3b4b6cf69c42d6241438220f9aa3eed724b063236","entrypoint":"reply-runtime","importSpecifier":"openclaw/plugin-sdk/reply-runtime"} +{"contentHash":"f362c1069c54952c9234e1b527bbdff8ee84046f824308fa6b3c63e3c8a84dc1","entrypoint":"reply-runtime","importSpecifier":"openclaw/plugin-sdk/reply-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/runtime-store.json b/docs/.generated/plugin-sdk-api-baseline/runtime-store.json index 2eda66f06cba..9055e82d7a37 100644 --- a/docs/.generated/plugin-sdk-api-baseline/runtime-store.json +++ b/docs/.generated/plugin-sdk-api-baseline/runtime-store.json @@ -1 +1 @@ -{"contentHash":"75361fbead55fda2647aa2a3d003a15084f354f11a477e28cc29a5ec6fc6cfcd","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"} +{"contentHash":"80a8182bd0e1c6fc281ccc0a69e27a827d6e9d64237a82897677cf2c6bfddc78","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"} diff --git a/docs/.generated/plugin-sdk-api-baseline/session-catalog.json b/docs/.generated/plugin-sdk-api-baseline/session-catalog.json index 9dc13897dbff..35c081ccaa9a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/session-catalog.json +++ b/docs/.generated/plugin-sdk-api-baseline/session-catalog.json @@ -1 +1 @@ -{"contentHash":"ba2d77ba16690bca9a09e83ae1a5fc2569017338ef9a43fb810015d6d2d7205c","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"} +{"contentHash":"dbd6e38de969a070a467b892e4f687f6ec258d0d8ff44e35befdab5012a5d9d1","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"} diff --git a/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json b/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json index 50fce3c9a278..ceeaa5fd83a6 100644 --- a/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json @@ -1 +1 @@ -{"contentHash":"312b644d9fb54857501442486f9fa6dadf538c34a6277cd24ef457c72c0d2944","entrypoint":"session-store-runtime","importSpecifier":"openclaw/plugin-sdk/session-store-runtime"} +{"contentHash":"5219c975d099e0c8920cbefdeb7d711fb6755c185a1414e3f60039e1cfd9bcf3","entrypoint":"session-store-runtime","importSpecifier":"openclaw/plugin-sdk/session-store-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json b/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json index be4e27f0ee3b..8571f1d61cd2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json @@ -1 +1 @@ -{"contentHash":"1f3096342401055bb64eab79e81229145bcb24b2b4ad8fe95028cd4b80c15317","entrypoint":"skill-commands-runtime","importSpecifier":"openclaw/plugin-sdk/skill-commands-runtime"} +{"contentHash":"bb44f7f72d1de70ed45aeb857e932795d53a6cb87e7812afc3f6d69571bc0f30","entrypoint":"skill-commands-runtime","importSpecifier":"openclaw/plugin-sdk/skill-commands-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index d3b40774b4bd..5128a3cfd088 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"b873b17e9949d28e6dbf0ca28ef17d11c250990def15db725ded5fcad737bef5","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"cd431f3c3ab8d51ae78b12c5c008b819a8059f36003ead70e0dd660abe1461d1","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 3cb2b8c9a811..1ad68f4bdde7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"b21b50ff43ddc09e336a8127f71d58c99a3e5e691970bdf15d0ebeb2bede0f68","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"b94f65403518cb98f9d3ac7cd885cd5366c8621dff261196fe499d428bade3b4","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 47fb10279488..52541d3e6f5b 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -298,6 +298,8 @@ export { SessionWorktreeInfoSchema, SessionsCreateParamsSchema, SessionsCreateResultSchema, + SessionsRecoverParamsSchema, + SessionsRecoverResultSchema, SessionsDispatchParamsSchema, SessionsDispatchResultSchema, SessionsReclaimParamsSchema, diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-lifecycle.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-lifecycle.ts index 2255ee5dbe3b..4f7bd4d07fa0 100644 --- a/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-lifecycle.ts +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-lifecycle.ts @@ -40,6 +40,8 @@ export const SessionLifecycleProtocolSchemas = { SessionWorktreeInfo: sessions.SessionWorktreeInfoSchema, SessionsCreateParams: sessions.SessionsCreateParamsSchema, SessionsCreateResult: sessions.SessionsCreateResultSchema, + SessionsRecoverParams: sessions.SessionsRecoverParamsSchema, + SessionsRecoverResult: sessions.SessionsRecoverResultSchema, SessionsSendParams: sessions.SessionsSendParamsSchema, SessionsMessagesSubscribeParams: sessions.SessionsMessagesSubscribeParamsSchema, SessionsMessagesUnsubscribeParams: sessions.SessionsMessagesUnsubscribeParamsSchema, diff --git a/packages/gateway-protocol/src/schema/sessions-recover.test.ts b/packages/gateway-protocol/src/schema/sessions-recover.test.ts new file mode 100644 index 000000000000..9e42d39d00ad --- /dev/null +++ b/packages/gateway-protocol/src/schema/sessions-recover.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { validateSessionsCreateParams, validateSessionsRecoverParams } from "../index.js"; + +describe("sessions.recover schema", () => { + it("accepts only a source key and optional agent", () => { + expect(validateSessionsRecoverParams({ key: "agent:main:dashboard:dead" })).toBe(true); + expect(validateSessionsRecoverParams({ key: "global", agentId: "main" })).toBe(true); + expect(validateSessionsRecoverParams({ key: "", agentId: "main" })).toBe(false); + expect(validateSessionsRecoverParams({ key: "global", message: "replace this" })).toBe(false); + }); + + it("keeps recovery out of generic session creation", () => { + expect( + validateSessionsCreateParams({ + parentSessionKey: "agent:main:dashboard:dead", + recover: true, + }), + ).toBe(false); + }); +}); diff --git a/packages/gateway-protocol/src/schema/sessions-recover.ts b/packages/gateway-protocol/src/schema/sessions-recover.ts new file mode 100644 index 000000000000..565360cf13c3 --- /dev/null +++ b/packages/gateway-protocol/src/schema/sessions-recover.ts @@ -0,0 +1,28 @@ +import { Type } from "typebox"; +import { closedObject } from "./closed-object.js"; +import { ErrorShapeSchema } from "./frames.js"; +import { NonEmptyString } from "./primitives.js"; + +/** Recovers one restart-tombstoned session into a fresh same-agent session. */ +export const SessionsRecoverParamsSchema = closedObject({ + key: NonEmptyString, + agentId: Type.Optional(NonEmptyString), +}); + +const SessionRecoveryContinuationOutcomeSchema = Type.Union([ + closedObject({ + status: Type.Literal("started"), + runId: NonEmptyString, + }), + closedObject({ + status: Type.Literal("rejected"), + error: ErrorShapeSchema, + }), +]); + +export const SessionsRecoverResultSchema = closedObject({ + ok: Type.Literal(true), + key: NonEmptyString, + sessionId: NonEmptyString, + continuation: SessionRecoveryContinuationOutcomeSchema, +}); diff --git a/packages/gateway-protocol/src/schema/sessions-row.test.ts b/packages/gateway-protocol/src/schema/sessions-row.test.ts index 31449e63d35e..78120c3644b5 100644 --- a/packages/gateway-protocol/src/schema/sessions-row.test.ts +++ b/packages/gateway-protocol/src/schema/sessions-row.test.ts @@ -17,6 +17,7 @@ describe("SessionRowSchema", () => { archivedBy: { type: "human", id: "profile-bob", label: "Bob" }, visibility: "suggest", sharingRole: "owner", + restartRecoveryStatus: "tombstoned", }; const roundTripped = structuredClone(row); @@ -29,6 +30,7 @@ describe("SessionRowSchema", () => { archivedBy: { type: "human", id: "profile-bob", label: "Bob" }, visibility: "suggest", sharingRole: "owner", + restartRecoveryStatus: "tombstoned", }); }); }); diff --git a/packages/gateway-protocol/src/schema/sessions-row.ts b/packages/gateway-protocol/src/schema/sessions-row.ts index b58e2554e8c4..9aa3da37de6b 100644 --- a/packages/gateway-protocol/src/schema/sessions-row.ts +++ b/packages/gateway-protocol/src/schema/sessions-row.ts @@ -71,6 +71,7 @@ export const SessionRowSchema = Type.Object( ]), ), lastRunError: Type.Optional(Type.String()), + restartRecoveryStatus: Type.Optional(Type.Literal("tombstoned")), activeLeafEntryId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), spawnedBy: Type.Optional(Type.String()), parentSessionKey: Type.Optional(Type.String()), diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index e802d7a6c9ad..3c0c7b69e1b3 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -7,8 +7,10 @@ import { ChatAttachmentsSchema } from "./logs-chat.js"; import { PluginJsonValueSchema } from "./plugins.js"; import { NonEmptyString, SessionLabelString } from "./primitives.js"; import { SessionsCreateParamsSchema } from "./sessions-create.js"; +import { SessionsRecoverParamsSchema, SessionsRecoverResultSchema } from "./sessions-recover.js"; export { SessionsCreateParamsSchema }; +export { SessionsRecoverParamsSchema, SessionsRecoverResultSchema }; export { SessionsResolveParamsSchema, type SessionsResolveParams } from "./sessions-resolve.js"; export { SESSIONS_PATCH_MANY_MAX_TARGETS, @@ -816,6 +818,8 @@ export type SessionsBranchesSwitchResult = Static; export type SessionsCreateParams = Static; export type SessionsCreateResult = Static; +export type SessionsRecoverParams = Static; +export type SessionsRecoverResult = Static; export type SessionsSendParams = Static; export type SessionsMessagesSubscribeParams = Static; export type SessionsMessagesUnsubscribeParams = Static< diff --git a/packages/gateway-protocol/src/validator-registry.ts b/packages/gateway-protocol/src/validator-registry.ts index 254e0a205f47..2e7965a6c948 100644 --- a/packages/gateway-protocol/src/validator-registry.ts +++ b/packages/gateway-protocol/src/validator-registry.ts @@ -226,6 +226,7 @@ export const validateSessionSuggestionsResolveParams = compile( ); export const validateSessionTypingParams = compile(S.SessionTypingParamsSchema); export const validateSessionsCreateParams = compile(S.SessionsCreateParamsSchema); +export const validateSessionsRecoverParams = compile(S.SessionsRecoverParamsSchema); export const validateSessionsSendParams = compile(S.SessionsSendParamsSchema); export const validateSessionsDispatchParams = compile(S.SessionsDispatchParamsSchema); export const validateSessionsReclaimParams = compile(S.SessionsReclaimParamsSchema); diff --git a/src/agents/main-session-recovery/main-session-recovery-state.test.ts b/src/agents/main-session-recovery/main-session-recovery-state.test.ts index ef5721ccd9c4..08544bc77bab 100644 --- a/src/agents/main-session-recovery/main-session-recovery-state.test.ts +++ b/src/agents/main-session-recovery/main-session-recovery-state.test.ts @@ -5,7 +5,10 @@ import type { } from "../../config/sessions.js"; import { buildMainSessionRecoveryClearPatch } from "./main-session-recovery-clear.js"; import { projectMainSessionRecoveryLifecycle } from "./main-session-recovery-lifecycle.js"; -import { transitionMainSessionRecovery } from "./main-session-recovery-state.js"; +import { + inspectMainRestartRecoveryRolloverEligibility, + transitionMainSessionRecovery, +} from "./main-session-recovery-state.js"; const sessionKey = "agent:main:main"; function recoveryState( @@ -84,6 +87,47 @@ function projectLifecycle( } describe("main session recovery state", () => { + it("allows rollover until the tombstone records its successor", () => { + expect( + inspectMainRestartRecoveryRolloverEligibility( + interruptedEntry({ + mainRestartRecovery: recoveryState({ tombstone: { reason: "exhausted" } }), + }), + ), + ).toEqual({ eligible: true }); + expect( + inspectMainRestartRecoveryRolloverEligibility( + interruptedEntry({ + archivedAt: 101, + mainRestartRecovery: recoveryState({ tombstone: { reason: "exhausted" } }), + }), + ), + ).toEqual({ eligible: true }); + expect( + inspectMainRestartRecoveryRolloverEligibility( + interruptedEntry({ + archivedAt: 101, + mainRestartRecovery: recoveryState({ + tombstone: { + reason: "exhausted", + recoveredSessionId: "recovered-id", + recoveredSessionKey: "agent:main:dashboard:recovered", + }, + }), + }), + ), + ).toEqual({ + eligible: false, + reason: "already_recovered", + recoveredSessionId: "recovered-id", + recoveredSessionKey: "agent:main:dashboard:recovered", + }); + expect(inspectMainRestartRecoveryRolloverEligibility(interruptedEntry())).toEqual({ + eligible: false, + reason: "not_tombstoned", + }); + }); + it("gives a legacy interrupted row a stable cycle before exposing it to a scan", () => { const entry = interruptedEntry({ mainRestartRecovery: undefined }); diff --git a/src/agents/main-session-recovery/main-session-recovery-state.ts b/src/agents/main-session-recovery/main-session-recovery-state.ts index e16d1774adc8..f2cc482c93c5 100644 --- a/src/agents/main-session-recovery/main-session-recovery-state.ts +++ b/src/agents/main-session-recovery/main-session-recovery-state.ts @@ -151,6 +151,35 @@ export function isMainSessionRecoveryPending(entry: SessionEntry, sessionKey: st ); } +type MainRestartRecoveryRolloverEligibility = + | { eligible: true } + | { + eligible: false; + reason: "already_recovered"; + recoveredSessionId?: string; + recoveredSessionKey?: string; + } + | { eligible: false; reason: "not_tombstoned" }; + +export function inspectMainRestartRecoveryRolloverEligibility( + entry: SessionEntry, +): MainRestartRecoveryRolloverEligibility { + if (!entry.mainRestartRecovery?.tombstone) { + return { eligible: false, reason: "not_tombstoned" }; + } + const recoveredSessionId = entry.mainRestartRecovery.tombstone.recoveredSessionId; + const recoveredSessionKey = entry.mainRestartRecovery.tombstone.recoveredSessionKey; + if (recoveredSessionId || recoveredSessionKey) { + return { + eligible: false, + reason: "already_recovered", + ...(recoveredSessionId ? { recoveredSessionId } : {}), + ...(recoveredSessionKey ? { recoveredSessionKey } : {}), + }; + } + return { eligible: true }; +} + // A healthy session can retain lifecycle fences after its final recovery owner // clears. With no active delivery or aggregate, those fences no longer own work. function hasOrphanedMainRestartRecoveryFences(entry: SessionEntry, sessionKey: string): boolean { diff --git a/src/agents/main-session-recovery/main-session-restart-recovery-failure.ts b/src/agents/main-session-recovery/main-session-restart-recovery-failure.ts index ab578f933c51..193d8a32e1eb 100644 --- a/src/agents/main-session-recovery/main-session-restart-recovery-failure.ts +++ b/src/agents/main-session-recovery/main-session-restart-recovery-failure.ts @@ -19,7 +19,8 @@ import { const TOMBSTONED_SESSION_NOTICE = "I couldn't continue this session after a gateway restart. " + - "Use /new or /reset to start a replacement session."; + "Your transcript is safe. In WebChat, use Resume in new session to continue it; " + + "in other channels, use /new or /reset to start a replacement session."; function buildRestartRecoveryTombstoneNoticeKey(entry: SessionEntry): string { const interruptedRunId = diff --git a/src/agents/main-session-recovery/main-session-restart-recovery.test.ts b/src/agents/main-session-recovery/main-session-restart-recovery.test.ts index 1cae9d73d1de..ff612813fa67 100644 --- a/src/agents/main-session-recovery/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-recovery/main-session-restart-recovery.test.ts @@ -3359,6 +3359,11 @@ describe("main-session-restart-recovery", () => { }); await expectRecovery({ recovered: 0, failed: 0, skipped: 1 }); + expect(sendRecoveryNotice).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining("Resume in new session"), + }), + ); expect(sendRecoveryNotice).toHaveBeenCalledWith( expect.objectContaining({ text: expect.stringContaining("/new or /reset") }), ); @@ -3556,7 +3561,7 @@ describe("main-session-restart-recovery", () => { to: "discord:dm:main", threadId: undefined, idempotencyKey: "main-session-restart-recovery:recovery-main:failed-notice", - text: expect.stringContaining("/new or /reset"), + text: expect.stringContaining("Resume in new session"), }); const failedEntry = loadSessionEntry({ sessionKey: "agent:main:main", storePath }); expect(failedEntry).toMatchObject({ @@ -3595,7 +3600,7 @@ describe("main-session-restart-recovery", () => { content: [ { type: "text", - text: expect.stringContaining("/new or /reset"), + text: expect.stringContaining("Resume in new session"), }, ], }, diff --git a/src/config/sessions/main-session-recovery.types.ts b/src/config/sessions/main-session-recovery.types.ts index abee619569e5..e19442b07b8e 100644 --- a/src/config/sessions/main-session-recovery.types.ts +++ b/src/config/sessions/main-session-recovery.types.ts @@ -24,5 +24,10 @@ export type MainRestartRecoveryState = { /** Run identity for claims that have crossed the actual agent-run boundary. */ runIdsByClaimId?: Record; }; - tombstone?: { reason: string }; + tombstone?: { + reason: string; + /** Durable successor returned when an explicit rollover request is retried. */ + recoveredSessionId?: string; + recoveredSessionKey?: string; + }; }; diff --git a/src/config/sessions/session-accessor.recovery.test.ts b/src/config/sessions/session-accessor.recovery.test.ts new file mode 100644 index 000000000000..0e60cae5451b --- /dev/null +++ b/src/config/sessions/session-accessor.recovery.test.ts @@ -0,0 +1,169 @@ +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; +import { + loadSessionEntry, + loadTranscriptEvents, + recoverSessionEntryFromRestartTombstone, + replaceSessionEntry, + replaceTranscriptEvents, +} from "./session-accessor.js"; +import type { InternalSessionEntry } from "./types.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +async function createFixture() { + const root = tempDirs.make("openclaw-session-recovery-"); + const storePath = path.join(root, "sessions.json"); + const sourceKey = "agent:main:dashboard:tombstoned"; + const successorKey = "agent:main:dashboard:recovered"; + const sourceSessionId = "source-session"; + await replaceSessionEntry({ agentId: "main", sessionKey: sourceKey, storePath }, { + sessionId: sourceSessionId, + updatedAt: 10, + pinnedAt: 5, + pluginOwnerId: "codex", + mainRestartRecovery: { + cycleId: "cycle-1", + revision: 4, + chargedAttempts: 3, + tombstone: { reason: "automatic recovery exhausted" }, + }, + } as InternalSessionEntry); + await replaceTranscriptEvents( + { agentId: "main", sessionId: sourceSessionId, sessionKey: sourceKey, storePath }, + [ + { + type: "session", + version: 3, + id: sourceSessionId, + timestamp: "2026-08-12T00:00:00.000Z", + cwd: root, + }, + { + type: "message", + id: "user-1", + parentId: null, + timestamp: "2026-08-12T00:00:01.000Z", + message: { role: "user", content: "finish this" }, + }, + { + type: "message", + id: "side-branch", + parentId: "user-1", + timestamp: "2026-08-12T00:00:02.000Z", + message: { role: "assistant", content: "preserve the whole transcript" }, + }, + { + type: "leaf", + id: "leaf-1", + parentId: "side-branch", + timestamp: "2026-08-12T00:00:03.000Z", + targetId: "user-1", + }, + ], + ); + return { root, sourceKey, sourceSessionId, storePath, successorKey }; +} + +describe("recoverSessionEntryFromRestartTombstone", () => { + it("copies the full transcript and atomically records the archived successor transition", async () => { + const fixture = await createFixture(); + const successorEntry = { sessionId: "successor-session", updatedAt: 20, spawnDepth: 0 }; + const params = { + agentId: "main", + expected: { + cycleId: "cycle-1", + revision: 4, + sessionId: fixture.sourceSessionId, + pluginOwnerId: "codex", + }, + sourceTarget: { canonicalKey: fixture.sourceKey, storeKeys: [fixture.sourceKey] }, + storePath: fixture.storePath, + successorEntry, + successorTarget: { canonicalKey: fixture.successorKey, storeKeys: [fixture.successorKey] }, + }; + + const created = await recoverSessionEntryFromRestartTombstone(params); + expect(created).toMatchObject({ status: "created", successorKey: fixture.successorKey }); + expect( + loadSessionEntry({ + agentId: "main", + sessionKey: fixture.sourceKey, + storePath: fixture.storePath, + }), + ).toMatchObject({ + archivedAt: expect.any(Number), + mainRestartRecovery: { + cycleId: "cycle-1", + revision: 5, + tombstone: { + recoveredSessionId: "successor-session", + recoveredSessionKey: fixture.successorKey, + }, + }, + }); + expect( + loadSessionEntry({ + agentId: "main", + sessionKey: fixture.successorKey, + storePath: fixture.storePath, + }), + ).toMatchObject(successorEntry); + const recoveredEvents = await loadTranscriptEvents({ + agentId: "main", + sessionId: successorEntry.sessionId, + sessionKey: fixture.successorKey, + storePath: fixture.storePath, + }); + expect(recoveredEvents).toHaveLength(4); + expect(recoveredEvents[0]).toMatchObject({ type: "session", id: successorEntry.sessionId }); + expect(JSON.stringify(recoveredEvents)).toContain("preserve the whole transcript"); + + const repeated = await recoverSessionEntryFromRestartTombstone({ + ...params, + successorEntry: { sessionId: "unused-session", updatedAt: 30 }, + successorTarget: { + canonicalKey: "agent:main:dashboard:unused", + storeKeys: ["agent:main:dashboard:unused"], + }, + }); + expect(repeated).toMatchObject({ + status: "existing", + successorKey: fixture.successorKey, + successorEntry: { sessionId: successorEntry.sessionId }, + }); + }); + + it("does not archive or copy when the recovery revision changed", async () => { + const fixture = await createFixture(); + const result = await recoverSessionEntryFromRestartTombstone({ + agentId: "main", + expected: { + cycleId: "cycle-1", + revision: 3, + sessionId: fixture.sourceSessionId, + pluginOwnerId: "codex", + }, + sourceTarget: { canonicalKey: fixture.sourceKey, storeKeys: [fixture.sourceKey] }, + storePath: fixture.storePath, + successorEntry: { sessionId: "successor-session", updatedAt: 20 }, + successorTarget: { canonicalKey: fixture.successorKey, storeKeys: [fixture.successorKey] }, + }); + expect(result).toEqual({ status: "conflict", reason: "source-changed" }); + expect( + loadSessionEntry({ + agentId: "main", + sessionKey: fixture.sourceKey, + storePath: fixture.storePath, + })?.archivedAt, + ).toBeUndefined(); + expect( + loadSessionEntry({ + agentId: "main", + sessionKey: fixture.successorKey, + storePath: fixture.storePath, + }), + ).toBeUndefined(); + }); +}); diff --git a/src/config/sessions/session-accessor.sqlite-recovery.ts b/src/config/sessions/session-accessor.sqlite-recovery.ts new file mode 100644 index 000000000000..c1aded65e825 --- /dev/null +++ b/src/config/sessions/session-accessor.sqlite-recovery.ts @@ -0,0 +1,235 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { runOpenClawAgentWriteTransaction } from "../../state/openclaw-agent-db.js"; +import { + deleteLegacySessionEntryRows, + normalizeLifecycleTarget, + readSessionIdentitySnapshot, + rehomeSessionWindows, + resolveLifecyclePrimaryEntry, + writeSessionEntry, +} from "./session-accessor.sqlite-entry-store.js"; +import { emitCommittedSessionIdentityDiff } from "./session-accessor.sqlite-identity.js"; +import type { SessionEntryMaintenancePlan } from "./session-accessor.sqlite-lifecycle-types.js"; +import { + applySessionEntryMaintenance, + finalizeSessionEntryMaintenancePlansBestEffort, +} from "./session-accessor.sqlite-maintenance.js"; +import { loadTranscriptEventsFromDatabase } from "./session-accessor.sqlite-read.js"; +import { + cloneSessionEntry, + formatLegacySqliteSessionMarkerForScope, + normalizeSqliteSessionKey, + resolveSqliteStoreScope, + resolveSqliteTranscriptArchiveDirectory, + runExclusiveSqliteSessionWrite, + toDatabaseOptions, +} from "./session-accessor.sqlite-scope.js"; +import { appendTranscriptEventsInTransaction } from "./session-accessor.sqlite-transcript-store.js"; +import type { SessionCreatedActor } from "./session-entry-provenance.js"; +import { createSessionTranscriptHeader } from "./transcript-header.js"; +import type { InternalSessionEntry, SessionEntry } from "./types.js"; + +export type RestartTombstoneRecoveryResult = + | { + status: "created" | "existing"; + sourceEntry: SessionEntry; + successorEntry: SessionEntry; + successorKey: string; + } + | { + status: "conflict"; + reason: + | "not-tombstoned" + | "source-changed" + | "successor-missing" + | "target-exists" + | "transcript-missing"; + }; + +/** + * Atomically clones a tombstoned transcript, creates its successor, and records + * the revisioned source archive/link transition in the same agent database. + */ +export async function recoverSessionEntryFromRestartTombstone(params: { + agentId: string; + archivedBy?: SessionCreatedActor; + expected: { + cycleId: string; + pluginOwnerId?: string; + revision: number; + sessionId: string; + }; + commitGuard?: () => void; + sourceTarget: { canonicalKey: string; storeKeys: readonly string[] }; + storePath: string; + successorEntry: InternalSessionEntry & { sessionId: string }; + successorTarget: { canonicalKey: string; storeKeys: readonly string[] }; +}): Promise { + const resolved = resolveSqliteStoreScope(params.storePath, { agentId: params.agentId }); + const sourceTarget = normalizeLifecycleTarget({ + ...params.sourceTarget, + storeKeys: [...params.sourceTarget.storeKeys], + }); + const successorTarget = normalizeLifecycleTarget({ + ...params.successorTarget, + storeKeys: [...params.successorTarget.storeKeys], + }); + const maintenancePlans: SessionEntryMaintenancePlan[] = []; + let previousIdentity = new Map(); + let currentIdentity = new Map(); + let result: RestartTombstoneRecoveryResult = { + status: "conflict", + reason: "source-changed", + }; + + await runExclusiveSqliteSessionWrite(resolved, async () => { + runOpenClawAgentWriteTransaction((database) => { + const source = resolveLifecyclePrimaryEntry(database, sourceTarget)?.entry as + | InternalSessionEntry + | undefined; + const recovery = source?.mainRestartRecovery; + const tombstone = recovery?.tombstone; + if (!source?.sessionId || !recovery || !tombstone) { + result = { status: "conflict", reason: "not-tombstoned" }; + return; + } + + const recoveredSessionKey = tombstone.recoveredSessionKey; + const recoveredSessionId = tombstone.recoveredSessionId; + if (recoveredSessionKey || recoveredSessionId) { + if (!recoveredSessionKey || !recoveredSessionId) { + result = { status: "conflict", reason: "successor-missing" }; + return; + } + const linked = resolveLifecyclePrimaryEntry( + database, + normalizeLifecycleTarget({ + canonicalKey: recoveredSessionKey, + storeKeys: [recoveredSessionKey], + }), + )?.entry; + if (!linked || linked.sessionId !== recoveredSessionId) { + result = { status: "conflict", reason: "successor-missing" }; + return; + } + result = { + status: "existing", + sourceEntry: cloneSessionEntry(source), + successorEntry: cloneSessionEntry(linked), + successorKey: recoveredSessionKey, + }; + return; + } + + if ( + source.sessionId !== params.expected.sessionId || + recovery.cycleId !== params.expected.cycleId || + recovery.revision !== params.expected.revision || + source.pluginOwnerId !== params.expected.pluginOwnerId + ) { + result = { status: "conflict", reason: "source-changed" }; + return; + } + if (resolveLifecyclePrimaryEntry(database, successorTarget)?.entry) { + result = { status: "conflict", reason: "target-exists" }; + return; + } + + const sourceEvents = loadTranscriptEventsFromDatabase(database, source.sessionId); + const header = sourceEvents.find( + (event): event is Record => isRecord(event) && event.type === "session", + ); + if (!header) { + result = { status: "conflict", reason: "transcript-missing" }; + return; + } + + const successorSessionId = params.successorEntry.sessionId; + const parentSession = formatLegacySqliteSessionMarkerForScope({ + ...resolved, + sessionId: source.sessionId, + sessionKey: normalizeSqliteSessionKey(sourceTarget.canonicalKey), + }); + appendTranscriptEventsInTransaction( + database, + { + ...resolved, + sessionId: successorSessionId, + sessionKey: normalizeSqliteSessionKey(successorTarget.canonicalKey), + }, + [ + { + ...createSessionTranscriptHeader({ + cwd: typeof header.cwd === "string" ? header.cwd : undefined, + sessionId: successorSessionId, + }), + parentSession, + }, + ...sourceEvents.filter((event) => !(isRecord(event) && event.type === "session")), + ], + ); + + const now = Date.now(); + const nextSource: InternalSessionEntry = { + ...source, + mainRestartRecovery: { + ...recovery, + revision: recovery.revision + 1, + tombstone: { + ...tombstone, + recoveredSessionId: successorSessionId, + recoveredSessionKey: successorTarget.canonicalKey, + }, + }, + archivedAt: source.archivedAt ?? now, + ...(source.archivedBy === undefined && params.archivedBy + ? { archivedBy: params.archivedBy } + : {}), + updatedAt: Math.max(now, (source.updatedAt ?? 0) + 1), + }; + delete nextSource.pinnedAt; + + params.commitGuard?.(); + + const identityKeys = [ + ...sourceTarget.storeKeys, + ...successorTarget.storeKeys, + sourceTarget.canonicalKey, + successorTarget.canonicalKey, + ]; + previousIdentity = readSessionIdentitySnapshot(database, identityKeys); + writeSessionEntry(database, successorTarget.canonicalKey, params.successorEntry); + writeSessionEntry(database, sourceTarget.canonicalKey, nextSource, { previousEntry: source }); + rehomeSessionWindows(database, sourceTarget.canonicalKey, sourceTarget.storeKeys); + rehomeSessionWindows(database, successorTarget.canonicalKey, successorTarget.storeKeys); + deleteLegacySessionEntryRows(database, sourceTarget.storeKeys, sourceTarget.canonicalKey, { + rehomeMembers: true, + }); + deleteLegacySessionEntryRows( + database, + successorTarget.storeKeys, + successorTarget.canonicalKey, + { rehomeMembers: false }, + ); + maintenancePlans.push( + applySessionEntryMaintenance(database, { + activeSessionKey: successorTarget.canonicalKey, + archiveDirectory: resolveSqliteTranscriptArchiveDirectory(resolved), + skipMaintenance: true, + storePath: params.storePath, + }), + ); + currentIdentity = readSessionIdentitySnapshot(database, identityKeys); + result = { + status: "created", + sourceEntry: cloneSessionEntry(nextSource), + successorEntry: cloneSessionEntry(params.successorEntry), + successorKey: successorTarget.canonicalKey, + }; + }, toDatabaseOptions(resolved)); + }); + + emitCommittedSessionIdentityDiff(previousIdentity, currentIdentity); + await finalizeSessionEntryMaintenancePlansBestEffort(resolved, maintenancePlans); + return result; +} diff --git a/src/config/sessions/session-accessor.ts b/src/config/sessions/session-accessor.ts index c4596a181c7b..61c35bc46147 100644 --- a/src/config/sessions/session-accessor.ts +++ b/src/config/sessions/session-accessor.ts @@ -167,6 +167,10 @@ export { updateSessionEntry, updateSessionLastRoute, } from "./session-accessor.entry-mutation.js"; +export { + recoverSessionEntryFromRestartTombstone, + type RestartTombstoneRecoveryResult, +} from "./session-accessor.sqlite-recovery.js"; export { applySessionEntryLifecycleMutation, applySessionEntryReplacements, diff --git a/src/gateway/methods/core-descriptors.since.test.ts b/src/gateway/methods/core-descriptors.since.test.ts index 3a34ed3c2982..b4cbe4b231a0 100644 --- a/src/gateway/methods/core-descriptors.since.test.ts +++ b/src/gateway/methods/core-descriptors.since.test.ts @@ -82,6 +82,7 @@ const TRAIN_2026_7_METHODS = [ const CURRENT_TRAIN_METHODS = [ "sessions.patchMany", + "sessions.recover", "update.hold", "sessions.catalog.startTerminal", "worker.desktop.observe", diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 8940966c6fa3..a3efa2c472a5 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -236,6 +236,7 @@ const CORE_GATEWAY_METHOD_SPECS = [ // Params-aware plus state-aware: the handler permits write-scoped cwd only // inside configured agent workspaces; execNode and other privileged modes stay admin. ["sessions.create", "sessions-create", "dynamic", "<=2026.7", { startup: true }], + ["sessions.recover", "sessions-recover", "operator.write", "2026.8", { startup: true }], ["sessions.send", "sessions-messaging", "operator.write", "<=2026.7", { startup: true }], ["sessions.abort", "sessions-abort", "operator.write", "<=2026.7", { startup: true }], // Dynamic mutation scope policy, including write-scoped model overrides, lives diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 1d09492e3d5d..44486ab0a5c2 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -150,6 +150,8 @@ const CORE_GATEWAY_HANDLER_MODULES = { ), "sessions-create": () => import("./server-methods/sessions-create.js").then((module) => module.sessionCreateHandlers), + "sessions-recover": () => + import("./server-methods/sessions-recover.js").then((module) => module.sessionRecoverHandlers), "sessions-delete": () => import("./server-methods/sessions-delete.js").then((module) => module.sessionDeleteHandlers), "sessions-dispatch": () => diff --git a/src/gateway/server-methods/chat-send-handler.ts b/src/gateway/server-methods/chat-send-handler.ts index 06de56863696..05d86ffa3f43 100644 --- a/src/gateway/server-methods/chat-send-handler.ts +++ b/src/gateway/server-methods/chat-send-handler.ts @@ -24,14 +24,16 @@ import { import { createGatewayChatUserTurnController } from "./chat-user-turn-recorder.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; -export async function handleChatSend( +async function handleChatSendWithOptions( { params, respond, context, client }: GatewayRequestHandlerOptions, onAdmissionOwned?: () => Promise, externalAuthorityAdmission?: ChatSendExternalAuthorityAdmission, + options?: { trustedSystemInput?: boolean }, ): Promise { const setup = await prepareAndAdmitChatSend( { params, respond, context, client }, onAdmissionOwned, + options, ); if (!setup) { return; @@ -282,3 +284,21 @@ export async function handleChatSend( }); } } + +export async function handleChatSend( + options: GatewayRequestHandlerOptions, + onAdmissionOwned?: () => Promise, + externalAuthorityAdmission?: ChatSendExternalAuthorityAdmission, +): Promise { + await handleChatSendWithOptions(options, onAdmissionOwned, externalAuthorityAdmission); +} + +/** Dispatches Gateway-authored system input without widening the public chat-send contract. */ +export async function handleTrustedInternalChatSend( + options: GatewayRequestHandlerOptions, + onAdmissionOwned?: () => Promise, +): Promise { + await handleChatSendWithOptions(options, onAdmissionOwned, undefined, { + trustedSystemInput: true, + }); +} diff --git a/src/gateway/server-methods/chat-send-request.ts b/src/gateway/server-methods/chat-send-request.ts index 6d86b7800afe..d32691f16015 100644 --- a/src/gateway/server-methods/chat-send-request.ts +++ b/src/gateway/server-methods/chat-send-request.ts @@ -85,6 +85,7 @@ type NormalizeChatSendRequestResult = export function normalizeChatSendRequest(params: { params: Record; client: GatewayRequestHandlerOptions["client"]; + trustedSystemInput?: boolean; }): NormalizeChatSendRequestResult { const chatSendReceivedAtMs = performance.now(); const client = params.client; @@ -117,6 +118,7 @@ export function normalizeChatSendRequest(params: { p.systemProvenanceReceipt || suppressCommandInterpretation || explicitOriginResult.value) && + !params.trustedSystemInput && !hasGatewayAdminScope(params.client) ) { return { diff --git a/src/gateway/server-methods/chat-send-setup.ts b/src/gateway/server-methods/chat-send-setup.ts index 8978abf22c16..39f931c173ab 100644 --- a/src/gateway/server-methods/chat-send-setup.ts +++ b/src/gateway/server-methods/chat-send-setup.ts @@ -14,8 +14,13 @@ export async function prepareAndAdmitChatSend( client, }: Pick, onAdmissionOwned?: () => Promise, + options?: { trustedSystemInput?: boolean }, ) { - const normalizedRequest = normalizeChatSendRequest({ params, client }); + const normalizedRequest = normalizeChatSendRequest({ + params, + client, + ...(options?.trustedSystemInput ? { trustedSystemInput: true } : {}), + }); if (!normalizedRequest.ok) { respond( false, diff --git a/src/gateway/server-methods/session-change-event.ts b/src/gateway/server-methods/session-change-event.ts index 6020f048d32c..b642c08e5506 100644 --- a/src/gateway/server-methods/session-change-event.ts +++ b/src/gateway/server-methods/session-change-event.ts @@ -183,3 +183,18 @@ export function emitSessionsChanged(context: SessionChangeContext, payload: Sess pendingSessionChanges.add(next); broadcastSessionsChanged(context, payload); } + +export function emitSessionArchived( + context: SessionChangeContext, + sessionKey: string | undefined, + agentId?: string, +): void { + if (!sessionKey) { + return; + } + emitSessionsChanged(context, { + sessionKey, + ...(agentId ? { agentId } : {}), + reason: "archive", + }); +} diff --git a/src/gateway/server-methods/session-recovery-continuation.ts b/src/gateway/server-methods/session-recovery-continuation.ts new file mode 100644 index 000000000000..4ea6c299d1f9 --- /dev/null +++ b/src/gateway/server-methods/session-recovery-continuation.ts @@ -0,0 +1,79 @@ +import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; +import { formatSystemTurnPrompt } from "../../sessions/system-turn-prompt.js"; +import type { SessionRecoveryContinuationOutcome } from "../session-recovery-service.js"; +import { handleTrustedInternalChatSend } from "./chat-send-handler.js"; +import type { GatewayRequestHandlerOptions } from "./types.js"; + +const RECOVERY_CONTINUATION_TEXT = + "Continue from the recovered transcript and finish the interrupted work."; + +/** Starts the fixed recovery continuation as trusted system input. */ +export async function launchSessionRecoveryContinuation(params: { + agentId: string; + client: GatewayRequestHandlerOptions["client"]; + commitGuard?: () => void; + context: GatewayRequestHandlerOptions["context"]; + idempotencyKey: string; + req: GatewayRequestHandlerOptions["req"]; + sessionId: string; + sessionKey: string; +}): Promise { + let outcome: SessionRecoveryContinuationOutcome | undefined; + try { + await handleTrustedInternalChatSend( + { + req: params.req, + params: { + sessionKey: params.sessionKey, + agentId: params.agentId, + sessionId: params.sessionId, + message: formatSystemTurnPrompt(RECOVERY_CONTINUATION_TEXT), + idempotencyKey: params.idempotencyKey, + deliver: false, + suppressCommandInterpretation: true, + systemInputProvenance: { + kind: "internal_system", + sourceSessionKey: params.sessionKey, + sourceTool: "sessions.recover", + }, + }, + respond: (ok, payload, error) => { + const response = payload as { runId?: unknown } | undefined; + const runId = + ok && response && typeof response.runId === "string" ? response.runId.trim() : ""; + outcome = + ok && runId + ? { status: "started", runId } + : { + status: "rejected", + error: + error ?? errorShape(ErrorCodes.UNAVAILABLE, "Continuation was not started."), + }; + }, + context: params.context, + client: params.client, + isWebchatConnect: () => false, + }, + params.commitGuard + ? async () => { + params.commitGuard?.(); + return true; + } + : undefined, + ); + } catch (error) { + outcome = { + status: "rejected", + error: errorShape( + ErrorCodes.INVALID_REQUEST, + error instanceof Error ? error.message : "Continuation authority check failed.", + ), + }; + } + return ( + outcome ?? { + status: "rejected", + error: errorShape(ErrorCodes.UNAVAILABLE, "Continuation returned no outcome."), + } + ); +} diff --git a/src/gateway/server-methods/sessions-recover.ts b/src/gateway/server-methods/sessions-recover.ts new file mode 100644 index 000000000000..e549dff67b17 --- /dev/null +++ b/src/gateway/server-methods/sessions-recover.ts @@ -0,0 +1,62 @@ +import { + validateSessionsRecoverParams, + type SessionsRecoverResult, +} from "../../../packages/gateway-protocol/src/index.js"; +import { recoverGatewaySession } from "../session-recovery-service.js"; +import { createAgentRuntimeAuthorityGuard } from "./agent-runtime-authority.js"; +import { emitSessionArchived, emitSessionsChanged } from "./session-change-event.js"; +import { resolveOperatorSessionCreation } from "./session-creation-provenance.js"; +import { launchSessionRecoveryContinuation } from "./session-recovery-continuation.js"; +import type { GatewayRequestHandlers } from "./types.js"; +import { assertValidParams } from "./validation.js"; + +export const sessionRecoverHandlers: GatewayRequestHandlers = { + "sessions.recover": async ({ req, params, respond, client, context }) => { + if (!assertValidParams(params, validateSessionsRecoverParams, "sessions.recover", respond)) { + return; + } + const authority = createAgentRuntimeAuthorityGuard(client, context, respond); + const creation = resolveOperatorSessionCreation(client); + const recovered = await recoverGatewaySession({ + cfg: context.getRuntimeConfig(), + key: params.key, + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(creation.actor ? { actor: creation.actor } : {}), + authorizedPluginId: client?.internal?.pluginRuntimeOwnerId, + ...(authority.commitGuard ? { commitGuard: authority.commitGuard } : {}), + launchContinuation: async (continuation) => + await launchSessionRecoveryContinuation({ + ...continuation, + client, + ...(authority.commitGuard ? { commitGuard: authority.commitGuard } : {}), + context, + req, + }), + }).catch((error: unknown) => authority.handleClosedError(error)); + if (!recovered) { + return; + } + if (!recovered.ok) { + respond(false, undefined, recovered.error); + return; + } + + emitSessionArchived( + context, + recovered.sourceKey, + recovered.sourceKey === "global" ? recovered.agentId : undefined, + ); + emitSessionsChanged(context, { + sessionKey: recovered.successorKey, + reason: recovered.created ? "create" : "recovery", + ...(recovered.successorKey === "global" ? { agentId: recovered.agentId } : {}), + }); + const result: SessionsRecoverResult = { + ok: true, + key: recovered.successorKey, + sessionId: recovered.successorEntry.sessionId, + continuation: recovered.continuation, + }; + respond(true, result, undefined); + }, +}; diff --git a/src/gateway/server.sessions.recover.test.ts b/src/gateway/server.sessions.recover.test.ts new file mode 100644 index 000000000000..1a4c8ee56b38 --- /dev/null +++ b/src/gateway/server.sessions.recover.test.ts @@ -0,0 +1,214 @@ +import { expect, test } from "vitest"; +import { loadSessionEntry, loadTranscriptEvents } from "../config/sessions/session-accessor.js"; +import { testState, writeSessionStore } from "./test-helpers.js"; +import { + directSessionReq, + seedSessionTranscript, + sessionStoreEntry, + setupGatewaySessionsTestHarness, +} from "./test/server-sessions.test-helpers.js"; + +const { createSessionStoreDir } = setupGatewaySessionsTestHarness(); + +test("sessions.recover rolls over one tombstone and returns its continuation outcome", async () => { + const { storePath } = await createSessionStoreDir(); + testState.sessionConfig = { dmScope: "main", scope: "per-sender" }; + const sourceKey = "agent:main:dashboard:tombstoned"; + const sourceSessionId = "tombstoned-session"; + await writeSessionStore({ + entries: { + [sourceKey]: sessionStoreEntry(sourceSessionId, { + status: "failed", + abortedLastRun: true, + agentHarnessId: "codex", + agentRuntimeOverride: "codex", + providerOverride: "openai", + modelOverride: "gpt-5.6-sol", + modelSelectionLocked: true, + pinnedAt: 1, + spawnedCwd: "/tmp/recovered-worktree", + mainRestartRecovery: { + cycleId: "cycle-tombstoned", + revision: 4, + chargedAttempts: 3, + tombstone: { reason: "automatic recovery exhausted" }, + }, + }), + }, + }); + await seedSessionTranscript({ + agentId: "main", + sessionId: sourceSessionId, + sessionKey: sourceKey, + storePath, + messages: [ + { role: "user", content: "finish the interrupted implementation" }, + { role: "assistant", content: [{ type: "text", text: "I reached the final check." }] }, + ], + }); + const sourceTranscriptBefore = await loadTranscriptEvents({ + agentId: "main", + sessionId: sourceSessionId, + sessionKey: sourceKey, + storePath, + }); + + type RecoveryPayload = { + key: string; + sessionId: string; + continuation: { status: string; runId?: string }; + }; + const [recovered, concurrentRetry] = await Promise.all([ + directSessionReq("sessions.recover", { agentId: "main", key: sourceKey }), + directSessionReq("sessions.recover", { agentId: "main", key: sourceKey }), + ]); + + expect(recovered.ok, JSON.stringify(recovered.error)).toBe(true); + expect(recovered.payload).toMatchObject({ + key: expect.stringMatching(/^agent:main:dashboard:/), + sessionId: expect.any(String), + continuation: { status: "started", runId: expect.any(String) }, + }); + const successorKey = recovered.payload?.key ?? ""; + const successorSessionId = recovered.payload?.sessionId ?? ""; + expect(concurrentRetry).toMatchObject({ + ok: true, + payload: { + key: successorKey, + sessionId: successorSessionId, + continuation: { status: "started" }, + }, + }); + expect(loadSessionEntry({ agentId: "main", sessionKey: successorKey, storePath })).toMatchObject({ + agentHarnessId: "codex", + agentRuntimeOverride: "codex", + modelSelectionLocked: true, + modelOverride: "gpt-5.6-sol", + previousSessionId: sourceSessionId, + providerOverride: "openai", + spawnedCwd: "/tmp/recovered-worktree", + }); + const archivedSource = loadSessionEntry({ agentId: "main", sessionKey: sourceKey, storePath }); + expect(archivedSource).toMatchObject({ + archivedAt: expect.any(Number), + mainRestartRecovery: { + revision: 5, + tombstone: { + recoveredSessionId: successorSessionId, + recoveredSessionKey: successorKey, + }, + }, + }); + expect(archivedSource).not.toHaveProperty("pinnedAt"); + await expect( + loadTranscriptEvents({ + agentId: "main", + sessionId: sourceSessionId, + sessionKey: sourceKey, + storePath, + }), + ).resolves.toEqual(sourceTranscriptBefore); + expect( + JSON.stringify( + await loadTranscriptEvents({ + agentId: "main", + sessionId: successorSessionId, + sessionKey: successorKey, + storePath, + }), + ), + ).toContain("finish the interrupted implementation"); + + const repeated = await directSessionReq("sessions.recover", { + agentId: "main", + key: sourceKey, + }); + expect(repeated).toMatchObject({ + ok: true, + payload: { + key: successorKey, + sessionId: successorSessionId, + continuation: { status: "started" }, + }, + }); +}); + +test("sessions.recover rejects a healthy session", async () => { + await createSessionStoreDir(); + const key = "agent:main:dashboard:healthy"; + await writeSessionStore({ entries: { [key]: sessionStoreEntry("healthy-session") } }); + const recovered = await directSessionReq("sessions.recover", { agentId: "main", key }); + expect(recovered).toMatchObject({ + ok: false, + error: { code: "INVALID_REQUEST", message: expect.stringContaining("tombstoned") }, + }); +}); + +test("sessions.recover rejects continuation launch after runtime authority closes", async () => { + const { storePath } = await createSessionStoreDir(); + const sourceKey = "agent:main:dashboard:authority-race"; + const sourceSessionId = "authority-race-source"; + await writeSessionStore({ + entries: { + [sourceKey]: sessionStoreEntry(sourceSessionId, { + status: "failed", + abortedLastRun: true, + mainRestartRecovery: { + cycleId: "cycle-authority-race", + revision: 1, + chargedAttempts: 3, + tombstone: { reason: "automatic recovery exhausted" }, + }, + }), + }, + }); + await seedSessionTranscript({ + agentId: "main", + sessionId: sourceSessionId, + sessionKey: sourceKey, + storePath, + messages: [{ role: "user", content: "continue after recovery" }], + }); + let validations = 0; + + const recovered = await directSessionReq<{ + key: string; + continuation: { status: string; error?: { message?: string } }; + }>( + "sessions.recover", + { agentId: "main", key: sourceKey }, + { + context: { + validateAgentRuntimeApprovalAuthority: () => ++validations < 2, + }, + client: { + connect: { scopes: ["operator.write"] }, + internal: { + agentRuntimeIdentity: { + kind: "agentRuntime", + agentId: "main", + sessionKey: sourceKey, + }, + }, + } as never, + }, + ); + + expect(recovered).toMatchObject({ + ok: true, + payload: { + continuation: { + status: "rejected", + error: { message: "agent runtime authority is no longer active" }, + }, + }, + }); + expect(validations).toBe(2); + expect( + loadSessionEntry({ + agentId: "main", + sessionKey: recovered.payload?.key ?? "", + storePath, + }), + ).toBeDefined(); +}); diff --git a/src/gateway/session-event-payload.ts b/src/gateway/session-event-payload.ts index fa1541e40b42..a0976d63221b 100644 --- a/src/gateway/session-event-payload.ts +++ b/src/gateway/session-event-payload.ts @@ -77,6 +77,7 @@ export function buildGatewaySessionEventFields(params: { sendPolicy: sessionRow.sendPolicy, systemSent: sessionRow.systemSent, abortedLastRun: sessionRow.abortedLastRun, + restartRecoveryStatus: sessionRow.restartRecoveryStatus ?? null, inputTokens: sessionRow.inputTokens, outputTokens: sessionRow.outputTokens, lastChannel: sessionRow.lastChannel, diff --git a/src/gateway/session-plugin-ownership.ts b/src/gateway/session-plugin-ownership.ts index 7af03ad93a24..37edb6d4d49c 100644 --- a/src/gateway/session-plugin-ownership.ts +++ b/src/gateway/session-plugin-ownership.ts @@ -6,7 +6,14 @@ import { } from "../../packages/gateway-protocol/src/index.js"; import type { SessionEntry } from "../config/sessions.js"; -export type PluginSessionOwnershipAction = "adopt" | "delete" | "fork" | "link" | "patch" | "reset"; +export type PluginSessionOwnershipAction = + | "adopt" + | "delete" + | "fork" + | "link" + | "patch" + | "recover" + | "reset"; /** Plugin callers may access an existing session only when they own its exact row. */ export function resolvePluginSessionOwnershipError(params: { diff --git a/src/gateway/session-recovery-entry.ts b/src/gateway/session-recovery-entry.ts new file mode 100644 index 000000000000..301db632b881 --- /dev/null +++ b/src/gateway/session-recovery-entry.ts @@ -0,0 +1,34 @@ +import { buildMainSessionRecoveryClearPatch } from "../agents/main-session-recovery/main-session-recovery-clear.js"; +import type { InternalSessionEntry } from "../config/sessions.js"; +import { buildSessionCreationStamp } from "../config/sessions/session-entry-provenance.js"; +import { inheritSessionSelection } from "../config/sessions/session-entry-selection.js"; +import { mergeSessionEntry } from "../config/sessions/types.js"; +import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; + +/** Builds the fresh runtime identity paired with a recovered transcript. */ +export function buildRestartRecoverySuccessorEntry(params: { + sessionId: string; + source: InternalSessionEntry; + actor?: NonNullable; +}): InternalSessionEntry & { sessionId: string } { + const source = params.source; + const entry = mergeSessionEntry(undefined, { + ...inheritSessionSelection(source), + ...buildSessionCreationStamp({ via: "operator", actor: params.actor }), + delivery: normalizeSessionDeliveryState(), + sessionId: params.sessionId, + previousSessionId: source.sessionId, + spawnDepth: 0, + ...(source.agentHarnessId ? { agentHarnessId: source.agentHarnessId } : {}), + ...(source.modelSelectionLocked === true ? { modelSelectionLocked: true as const } : {}), + ...(source.pluginOwnerId ? { pluginOwnerId: source.pluginOwnerId } : {}), + ...(source.visibility ? { visibility: source.visibility } : {}), + ...(source.spawnedCwd ? { spawnedCwd: source.spawnedCwd } : {}), + ...(source.execHost ? { execHost: source.execHost } : {}), + ...(source.execNode ? { execNode: source.execNode } : {}), + ...(source.execCwd ? { execCwd: source.execCwd } : {}), + ...(source.execSecurity ? { execSecurity: source.execSecurity } : {}), + ...(source.execAsk ? { execAsk: source.execAsk } : {}), + }); + return { ...entry, ...buildMainSessionRecoveryClearPatch(entry), sessionId: params.sessionId }; +} diff --git a/src/gateway/session-recovery-service.ts b/src/gateway/session-recovery-service.ts new file mode 100644 index 000000000000..c157f4e53270 --- /dev/null +++ b/src/gateway/session-recovery-service.ts @@ -0,0 +1,225 @@ +import { randomUUID } from "node:crypto"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { + ErrorCodes, + errorShape, + type ErrorShape, + type SessionsRecoverResult, +} from "../../packages/gateway-protocol/src/index.js"; +import { isEmbeddedAgentRunActive } from "../agents/embedded-agent.js"; +import { inspectMainRestartRecoveryRolloverEligibility } from "../agents/main-session-recovery/main-session-recovery-state.js"; +import { recoverSessionEntryFromRestartTombstone } from "../config/sessions/session-accessor.js"; +import type { SessionCreatedActor } from "../config/sessions/session-entry-provenance.js"; +import type { InternalSessionEntry } from "../config/sessions/types.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + isSessionWorkAdmissionActive, + runExclusiveSessionLifecycleMutation, +} from "../sessions/session-lifecycle-admission.js"; +import { recordSessionCreated } from "../sessions/session-state-events.js"; +import { buildDashboardSessionKey } from "./session-create-service.js"; +import { resolvePluginSessionOwnershipError } from "./session-plugin-ownership.js"; +import { buildRestartRecoverySuccessorEntry } from "./session-recovery-entry.js"; +import { + loadGatewaySessionEntryReadOnly, + resolveGatewaySessionStoreTarget, +} from "./session-utils.js"; + +export type SessionRecoveryContinuationOutcome = SessionsRecoverResult["continuation"]; + +type RecoverGatewaySessionResult = + | { + ok: true; + agentId: string; + created: boolean; + sourceKey: string; + successorEntry: InternalSessionEntry; + successorKey: string; + continuation: SessionRecoveryContinuationOutcome; + } + | { ok: false; error: ErrorShape }; + +function recoveryConflictError(reason: string): ErrorShape { + const unavailable = reason === "successor-missing" || reason === "transcript-missing"; + return errorShape( + unavailable ? ErrorCodes.UNAVAILABLE : ErrorCodes.INVALID_REQUEST, + unavailable + ? "Session recovery state is incomplete." + : "Session changed before recovery; refresh and retry.", + { details: { reason } }, + ); +} + +/** Owns explicit restart recovery from authorization through continuation launch. */ +export async function recoverGatewaySession(params: { + actor?: SessionCreatedActor; + agentId?: string; + authorizedPluginId?: string; + cfg: OpenClawConfig; + commitGuard?: () => void; + key: string; + launchContinuation: (params: { + agentId: string; + idempotencyKey: string; + sessionId: string; + sessionKey: string; + }) => Promise; +}): Promise { + const sourceTarget = resolveGatewaySessionStoreTarget({ + cfg: params.cfg, + key: params.key, + ...(params.agentId ? { agentId: params.agentId } : {}), + }); + const initialSource = loadGatewaySessionEntryReadOnly(sourceTarget.canonicalKey, { + agentId: sourceTarget.agentId, + }).entry as InternalSessionEntry | undefined; + if (!initialSource?.sessionId) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, "Session recovery source was not found."), + }; + } + const initialEligibility = inspectMainRestartRecoveryRolloverEligibility(initialSource); + if (!initialEligibility.eligible && initialEligibility.reason !== "already_recovered") { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + "Session recovery requires a restart-tombstoned session.", + ), + }; + } + const ownershipError = resolvePluginSessionOwnershipError({ + action: "recover", + entry: initialSource, + key: sourceTarget.canonicalKey, + pluginOwnerId: params.authorizedPluginId, + }); + if (ownershipError) { + return { ok: false, error: ownershipError }; + } + + const recovery = initialSource.mainRestartRecovery; + if (!recovery?.tombstone) { + return { + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, "Session is not recoverable."), + }; + } + const generatedSuccessorKey = buildDashboardSessionKey(sourceTarget.agentId); + const successorTarget = resolveGatewaySessionStoreTarget({ + cfg: params.cfg, + key: generatedSuccessorKey, + agentId: sourceTarget.agentId, + }); + const successorSessionId = randomUUID(); + + const committed = await runExclusiveSessionLifecycleMutation({ + targets: [ + { + scope: sourceTarget.storePath, + identities: [sourceTarget.canonicalKey, initialSource.sessionId], + }, + { + scope: successorTarget.storePath, + identities: [successorTarget.canonicalKey, successorSessionId], + }, + ], + run: async () => { + const currentSource = loadGatewaySessionEntryReadOnly(sourceTarget.canonicalKey, { + agentId: sourceTarget.agentId, + }).entry as InternalSessionEntry | undefined; + const currentOwnershipError = resolvePluginSessionOwnershipError({ + action: "recover", + entry: currentSource, + key: sourceTarget.canonicalKey, + pluginOwnerId: params.authorizedPluginId, + }); + if (currentOwnershipError) { + return { ok: false as const, error: currentOwnershipError }; + } + if (!currentSource?.sessionId) { + return { + ok: false as const, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + "Session changed before recovery; refresh and retry.", + ), + }; + } + if ( + isEmbeddedAgentRunActive(currentSource.sessionId) || + isSessionWorkAdmissionActive(sourceTarget.storePath, [ + sourceTarget.canonicalKey, + currentSource.sessionId, + ]) + ) { + return { + ok: false as const, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + "Session recovery is unavailable while the source still has active work.", + ), + }; + } + const successorEntry = buildRestartRecoverySuccessorEntry({ + sessionId: successorSessionId, + source: currentSource, + ...(params.actor ? { actor: params.actor } : {}), + }); + + const result = await recoverSessionEntryFromRestartTombstone({ + agentId: sourceTarget.agentId, + ...(params.actor ? { archivedBy: params.actor } : {}), + ...(params.commitGuard ? { commitGuard: params.commitGuard } : {}), + expected: { + cycleId: recovery.cycleId, + revision: recovery.revision, + sessionId: initialSource.sessionId, + ...(normalizeOptionalString(initialSource.pluginOwnerId) + ? { pluginOwnerId: initialSource.pluginOwnerId } + : {}), + }, + sourceTarget, + storePath: sourceTarget.storePath, + successorEntry, + successorTarget, + }); + if (result.status === "conflict") { + return { ok: false as const, error: recoveryConflictError(result.reason) }; + } + return { + ok: true as const, + created: result.status === "created", + successorEntry: result.successorEntry as InternalSessionEntry, + successorKey: result.successorKey, + }; + }, + }); + if (!committed.ok) { + return committed; + } + + if (committed.created) { + recordSessionCreated({ + sessionKey: committed.successorKey, + entry: committed.successorEntry, + agentId: sourceTarget.agentId, + }); + } + const continuation = await params.launchContinuation({ + agentId: sourceTarget.agentId, + idempotencyKey: `restart-recovery-rollover:${committed.successorEntry.sessionId}`, + sessionId: committed.successorEntry.sessionId, + sessionKey: committed.successorKey, + }); + return { + ok: true, + agentId: sourceTarget.agentId, + created: committed.created, + sourceKey: sourceTarget.canonicalKey, + successorEntry: committed.successorEntry, + successorKey: committed.successorKey, + continuation, + }; +} diff --git a/src/gateway/session-utils-row.ts b/src/gateway/session-utils-row.ts index 30f7436534b4..0db7adf453dc 100644 --- a/src/gateway/session-utils-row.ts +++ b/src/gateway/session-utils-row.ts @@ -21,6 +21,7 @@ import { resolveFreshSessionTotalTokens, resolveSessionGoalDisplayState, SESSION_TOTAL_TOKENS_VERSION, + type InternalSessionEntry, type SessionEntry, } from "../config/sessions.js"; import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js"; @@ -462,6 +463,10 @@ export function buildGatewaySessionRow(params: { sessionId: entry?.sessionId, systemSent: entry?.systemSent, abortedLastRun: entry?.abortedLastRun, + restartRecoveryStatus: (entry as InternalSessionEntry | undefined)?.mainRestartRecovery + ?.tombstone + ? "tombstoned" + : undefined, thinkingLevel: thinkingProjection.thinkingLevel, thinkingLevels: thinkingProjection.thinkingLevels, thinkingOptions: thinkingProjection.thinkingOptions, diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 567b5934f6a8..5bfcbb83f409 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -292,6 +292,30 @@ describe("gateway session utils", () => { ); }); + test("projects restart recovery tombstones", () => { + const row = buildGatewaySessionRow({ + cfg: createModelDefaultsConfig({ primary: "openai/gpt-5.4" }), + storePath: "", + store: {}, + key: "agent:main:dashboard:tombstoned", + entry: { + sessionId: "session-tombstoned", + updatedAt: 1, + mainRestartRecovery: { + cycleId: "cycle-tombstoned", + revision: 1, + chargedAttempts: 3, + tombstone: { reason: "automatic recovery exhausted" }, + }, + } as SessionEntry, + }); + + expect(row.restartRecoveryStatus).toBe("tombstoned"); + expect(buildGatewaySessionEventFields({ sessionRow: row }).restartRecoveryStatus).toBe( + "tombstoned", + ); + }); + test("emits a tombstone when a session has no current control owner", () => { const row = buildGatewaySessionRow({ cfg: createModelDefaultsConfig({ primary: "openai/gpt-5.4" }), diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 23309401120e..98939d42c26c 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -122,6 +122,7 @@ export type GatewaySessionRow = { placement?: SessionPlacement; systemSent?: boolean; abortedLastRun?: boolean; + restartRecoveryStatus?: "tombstoned"; thinkingLevel?: string; thinkingLevels?: GatewayThinkingLevelOption[]; thinkingOptions?: string[]; diff --git a/src/gateway/test/server-sessions.test-helpers.ts b/src/gateway/test/server-sessions.test-helpers.ts index bf8abf83b87a..2394f1f42a50 100644 --- a/src/gateway/test/server-sessions.test-helpers.ts +++ b/src/gateway/test/server-sessions.test-helpers.ts @@ -11,6 +11,7 @@ import type { InternalSessionEntry as SessionEntry } from "../../config/sessions import type { InternalHookEvent } from "../../hooks/internal-hooks.js"; import { resetSystemEventsForTest } from "../../infra/system-events.js"; import { createLazyRuntimeModule } from "../../shared/lazy-runtime.js"; +import { createDirectChatContext } from "../server-chat.agent-events.test-helpers.js"; import type { GatewayRequestContext } from "../server-methods/types.js"; import type { GatewayServerHarness } from "../server.e2e-ws-harness.js"; import { embeddedRunMock, agentDiscoveryMock, testState } from "../test-helpers.runtime-state.js"; @@ -705,6 +706,7 @@ export async function directSessionReq( }; }, context: { + ...createDirectChatContext(), broadcastToConnIds: vi.fn(), chatAbortControllers: new Map(), chatQueuedTurns: new Map(), diff --git a/src/shared/session-method-scopes.test.ts b/src/shared/session-method-scopes.test.ts index ab560d6160d5..1c82ba78e5db 100644 --- a/src/shared/session-method-scopes.test.ts +++ b/src/shared/session-method-scopes.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it } from "vitest"; import { resolveDynamicSessionMutationRequiredScope } from "./session-method-scopes.js"; describe("resolveDynamicSessionMutationRequiredScope", () => { + it("keeps explicit restart recovery at write scope", () => { + expect(resolveDynamicSessionMutationRequiredScope("sessions.recover")).toBe("operator.write"); + }); + it.each([ { agentId: "main", message: "hello", worktree: true }, { agentId: "main", message: "hello", projectId: "openclaw" }, diff --git a/src/shared/session-method-scopes.ts b/src/shared/session-method-scopes.ts index 00da374b392a..61244769d67c 100644 --- a/src/shared/session-method-scopes.ts +++ b/src/shared/session-method-scopes.ts @@ -93,6 +93,9 @@ export function resolveDynamicSessionMutationRequiredScope( method: string, params?: unknown, ): SessionMutationOperatorScope | undefined { + if (method === "sessions.recover") { + return "operator.write"; + } if (method === "sessions.create") { return resolveSessionsCreateRequiredScope(params); } diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 6162bcdf3e92..156e3f3c719d 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4783,6 +4783,10 @@ export const en: TranslationMap = { label: "Session reset", description: "The earlier conversation was cleared.", }, + restartRecoveryTitle: "This session ended during a restart.", + restartRecoveryDisabled: "Its transcript is safe.", + resumeInNewSession: "Resume in new session", + resumingSession: "Resuming…", systemNotice: { restartRecovery: { label: "System · restart recovery", diff --git a/ui/src/lib/sessions/index.ts b/ui/src/lib/sessions/index.ts index 0c58b1582c28..725df774e15a 100644 --- a/ui/src/lib/sessions/index.ts +++ b/ui/src/lib/sessions/index.ts @@ -494,6 +494,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil refreshReplacement: roster.refreshReplacement, createResult: mutations.createResult, create: mutations.create, + recover: mutations.recover, patch: mutations.patch, retireModelOverride: mutations.retireModelOverride, setModelOverride: mutations.setModelOverride, diff --git a/ui/src/lib/sessions/recover.ts b/ui/src/lib/sessions/recover.ts new file mode 100644 index 000000000000..e1cd9b26f950 --- /dev/null +++ b/ui/src/lib/sessions/recover.ts @@ -0,0 +1,16 @@ +import type { + SessionsRecoverParams, + SessionsRecoverResult, +} from "../../../../packages/gateway-protocol/src/index.js"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; + +export async function requestSessionRecovery( + client: Pick, + params: SessionsRecoverParams, +): Promise { + const result = await client.request("sessions.recover", params); + if (!result?.key?.trim() || !result?.sessionId?.trim()) { + throw new Error("sessions.recover returned no successor identity"); + } + return result; +} diff --git a/ui/src/lib/sessions/session-capability.ts b/ui/src/lib/sessions/session-capability.ts index 2d7000d76f17..2b271ba9ce8f 100644 --- a/ui/src/lib/sessions/session-capability.ts +++ b/ui/src/lib/sessions/session-capability.ts @@ -1,4 +1,5 @@ import type { GatewaySessionMessageSubscription } from "@openclaw/gateway-client/browser"; +import type { SessionsRecoverResult } from "../../../../packages/gateway-protocol/src/index.js"; import type { SessionCatalogPullRequestSummary } from "../../../../packages/gateway-protocol/src/schema/sessions-catalog.js"; import type { GatewayBrowserClient, GatewayEventFrame, GatewayHelloOk } from "../../api/gateway.ts"; import type { @@ -169,6 +170,7 @@ export type SessionCapability = { options?: { reconciliation?: SessionCreateReconciliation }, ) => Promise; create: (params?: SessionCreateParams) => Promise; + recover: (params: { key: string; agentId?: string }) => Promise; patch: SessionPatchRoute; setModelOverride: (key: string, value: string | null | undefined) => void; retireModelOverride: (key: string) => void; diff --git a/ui/src/lib/sessions/session-mutations.ts b/ui/src/lib/sessions/session-mutations.ts index 9f3d836560e5..e15bcc912131 100644 --- a/ui/src/lib/sessions/session-mutations.ts +++ b/ui/src/lib/sessions/session-mutations.ts @@ -9,6 +9,7 @@ import { type SessionCreateParams, } from "./create.ts"; import type { SessionPatch, SessionPatchOptions } from "./patch.ts"; +import { requestSessionRecovery } from "./recover.ts"; import type { SessionConnectionOwner, SessionCreateReconciliation, @@ -174,6 +175,27 @@ export function createSessionMutations(host: SessionMutationsHost) { const create = async (params: SessionCreateParams = {}) => (await createResult(params))?.key ?? null; + const recover = async (params: { key: string; agentId?: string }) => { + const scope = host.connection.capture(); + if (!scope) { + return null; + } + try { + const result = await requestSessionRecovery(scope.client, params); + if (!host.connection.isCurrent(scope)) { + return null; + } + host.notifyCreated(result.key); + await host.refreshReplacement(params.agentId); + return host.connection.isCurrent(scope) ? result : null; + } catch (error) { + if (host.connection.isCurrent(scope)) { + host.publish({ ...host.readState(), error: String(error) }, "operation"); + } + return null; + } + }; + const patch = async ( key: string, patchParams: SessionPatch, @@ -464,6 +486,7 @@ export function createSessionMutations(host: SessionMutationsHost) { return { create, createResult, + recover, delete: remove, deleteMany: removeMany, patch, diff --git a/ui/src/pages/chat/chat-composer-disabled-banner.test.ts b/ui/src/pages/chat/chat-composer-disabled-banner.test.ts index f683a48be1cc..d2830a3d2cf0 100644 --- a/ui/src/pages/chat/chat-composer-disabled-banner.test.ts +++ b/ui/src/pages/chat/chat-composer-disabled-banner.test.ts @@ -62,4 +62,41 @@ describe("archived session composer banner", () => { action?.click(); expect(onAction).not.toHaveBeenCalled(); }); + + it("renders a standard primary action with progress feedback", () => { + const container = renderComposer({ + canSend: false, + disabledBanner: { + kind: "composer-replacement", + title: "This session ended during a restart.", + text: "Its transcript is safe.", + tone: "neutral", + icon: "warning", + actionLabel: "Resume in new session", + actionStyle: "primary", + busy: true, + busyLabel: "Resuming…", + onAction: vi.fn(), + }, + }); + + const action = container.querySelector( + ".agent-chat__disabled-banner button", + ); + const banner = container.querySelector(".agent-chat__disabled-banner"); + expect(banner?.classList.contains("info")).toBe(false); + expect(banner?.classList.contains("agent-chat__disabled-banner--neutral")).toBe(true); + expect(banner?.querySelector(".agent-chat__disabled-banner-icon")).not.toBeNull(); + expect(banner?.querySelector(".agent-chat__disabled-banner-title")?.textContent).toContain( + "This session ended during a restart.", + ); + expect(banner?.querySelector(".agent-chat__disabled-banner-detail")?.textContent).toContain( + "Its transcript is safe.", + ); + expect(action?.classList.contains("primary")).toBe(true); + expect(action?.disabled).toBe(true); + expect(action?.getAttribute("aria-busy")).toBe("true"); + expect(action?.textContent).toContain("Resuming…"); + expect(action?.querySelector(".btn__spinner")).not.toBeNull(); + }); }); diff --git a/ui/src/pages/chat/chat-pane-recovery.test.ts b/ui/src/pages/chat/chat-pane-recovery.test.ts new file mode 100644 index 000000000000..17e7c95c6459 --- /dev/null +++ b/ui/src/pages/chat/chat-pane-recovery.test.ts @@ -0,0 +1,138 @@ +/* @vitest-environment jsdom */ + +import { describe, expect, it, vi } from "vitest"; +import { createDeferred } from "../../../../test/helpers/promise.js"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { SessionCapability } from "../../lib/sessions/index.ts"; +import { createTestChatPane, type TestChatPane } from "./chat-pane.test-support.ts"; + +function advertiseSessionRecovery(pane: TestChatPane) { + pane.context.gateway.snapshot.hello = { + auth: { role: "operator", scopes: ["operator.write"] }, + features: { methods: ["sessions.recover"] }, + } as typeof pane.context.gateway.snapshot.hello; +} + +describe("chat pane session recovery", () => { + it("recovers a tombstoned session into a fresh continuing session", async () => { + const created = createDeferred>>(); + const sessions = { + recover: vi.fn(() => created.promise), + } as unknown as SessionCapability; + const client = {} as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions }); + const navigate = vi.fn(); + pane.onPaneSessionChange = navigate; + advertiseSessionRecovery(pane); + + expect(pane.restartRecoveryComposerBanner()).toMatchObject({ + title: "This session ended during a restart.", + text: "Its transcript is safe.", + tone: "neutral", + icon: "warning", + actionLabel: "Resume in new session", + actionStyle: "primary", + busy: false, + }); + + const pending = pane.recoverSession(); + await vi.waitFor(() => expect(sessions.recover).toHaveBeenCalledOnce()); + expect(pane.restartRecoveryComposerBanner()).toMatchObject({ + actionLabel: "Resume in new session", + actionStyle: "primary", + busy: true, + busyLabel: "Resuming…", + }); + created.resolve({ + ok: true, + key: "agent:main:dashboard:recovered", + sessionId: "recovered-session", + continuation: { status: "started", runId: "recovery-run" }, + }); + + await expect(pending).resolves.toBe(true); + + expect(sessions.recover).toHaveBeenCalledWith({ + agentId: "main", + key: "agent:main:current", + }); + expect(navigate).toHaveBeenCalledWith(pane.paneId, "agent:main:dashboard:recovered"); + expect(state.sessionKey).toBe("agent:main:current"); + }); + + it("reuses the recovered session after a same-client reconnect", async () => { + const created = createDeferred>>(); + const recovered = { + ok: true as const, + key: "agent:main:dashboard:recovered", + sessionId: "recovered-session", + continuation: { status: "started" as const, runId: "recovery-run" }, + }; + const sessions = { + recover: vi + .fn() + .mockImplementationOnce(() => created.promise) + .mockResolvedValueOnce(recovered), + } as unknown as SessionCapability; + const client = {} as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions }); + const navigate = vi.fn(); + pane.onPaneSessionChange = navigate; + advertiseSessionRecovery(pane); + + const pending = pane.recoverSession(); + await vi.waitFor(() => expect(sessions.recover).toHaveBeenCalledOnce()); + state.connected = false; + pane.connectionGeneration += 1; + state.connectionEpoch = pane.connectionGeneration; + state.connected = true; + pane.connectionGeneration += 1; + state.connectionEpoch = pane.connectionGeneration; + created.resolve(recovered); + + await expect(pending).resolves.toBe(false); + expect(navigate).not.toHaveBeenCalled(); + + await expect(pane.recoverSession()).resolves.toBe(true); + expect(sessions.recover).toHaveBeenCalledTimes(2); + expect(navigate).toHaveBeenCalledWith(pane.paneId, "agent:main:dashboard:recovered"); + }); + + it("keeps the recovery action available when continuation launch is rejected", async () => { + const successor = { + ok: true as const, + key: "agent:main:dashboard:recovered", + sessionId: "recovered-session", + }; + const sessions = { + recover: vi + .fn() + .mockResolvedValueOnce({ + ...successor, + continuation: { + status: "rejected", + error: { code: "UNAVAILABLE", message: "Continuation was not started." }, + }, + }) + .mockResolvedValueOnce({ + ...successor, + continuation: { status: "started", runId: "recovery-run" }, + }), + } as unknown as SessionCapability; + const { pane, state } = createTestChatPane({ + client: {} as GatewayBrowserClient, + sessions, + }); + const navigate = vi.fn(); + pane.onPaneSessionChange = navigate; + advertiseSessionRecovery(pane); + + await expect(pane.recoverSession()).resolves.toBe(false); + expect(state.chatError).toBe("Continuation was not started."); + expect(navigate).not.toHaveBeenCalled(); + + await expect(pane.recoverSession()).resolves.toBe(true); + expect(sessions.recover).toHaveBeenCalledTimes(2); + expect(navigate).toHaveBeenCalledWith(pane.paneId, successor.key); + }); +}); diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index 3da5927d3519..879e7daae90b 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -22,7 +22,7 @@ import { import { buildAgentMainSessionKey } from "../../lib/sessions/session-key.ts"; import { clearChatHistory } from "./chat-history.ts"; import { resolveChatMessageAccess } from "./chat-message-access.ts"; -import { createChatModelSetupBanner, requiresChatModelSetup } from "./chat-model-setup.ts"; +import { requiresChatModelSetup } from "./chat-model-setup.ts"; import { ChatPaneBrowserAnnotationRender } from "./chat-pane-browser-annotation-render.ts"; import { createChatPaneSessionActionCallbacks, @@ -175,6 +175,7 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { session: selectedSession, }); const gatewaySnapshot = this.context.gateway.snapshot; + const restartRecoveryTombstoned = selectedSession?.restartRecoveryStatus === "tombstoned"; const multiIdentity = this.hasMultipleIdentities(); const suggestionViewer = multiIdentity && @@ -278,6 +279,7 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { sending: cloudStartupPending || state.chatSending || + this.recoveringSession || this.sessionSuggestionAddOperation !== undefined, cloudStartup, onRetryCloudStartup: cloudStartup?.retryable @@ -362,30 +364,21 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { ? this.catalogSession?.canContinue === true : !modelSetupRequired && !selectedSessionArchived && + !restartRecoveryTombstoned && (!sessionParticipationBlocked || suggestionViewer) && !cloudStartupPending, disabledReason: catalogDisabledReason ?? disabledReason, - disabledBanner: - selectedSessionArchived && !catalogDisabledReason - ? { - kind: "composer-replacement", - text: t("chat.archivedSessionDisabled"), - actionLabel: t("common.unarchive"), - disabledReason: !selectedSessionId - ? "Session lifecycle action requires a durable session identity." - : mutationAccess.unarchive.allowed - ? undefined - : mutationAccess.unarchive.reason, - onAction: () => { - if (selectedSessionId && mutationAccess.unarchive.allowed) { - void this.restoreArchivedSession(state.sessionKey, selectedSessionId); - } - }, - } - : modelSetupRequired - ? createChatModelSetupBanner(() => this.context.navigate("model-setup")) - : undefined, - modelSetupRequired: modelSetupRequired && !selectedSessionArchived, + disabledBanner: this.sessionDisabledBanner({ + catalogDisabledReason, + modelSetupRequired, + restartRecoveryTombstoned, + selectedSessionArchived, + selectedSessionId, + sessionKey: state.sessionKey, + unarchiveAccess: mutationAccess.unarchive, + }), + modelSetupRequired: + modelSetupRequired && !selectedSessionArchived && !restartRecoveryTombstoned, onModelSetup: () => this.context.navigate("model-setup"), error: state.lastError, runError: catalogKey ? null : (state.chatRunError ?? placementRunError), diff --git a/ui/src/pages/chat/chat-pane-session-creation.ts b/ui/src/pages/chat/chat-pane-session-creation.ts index 7c58258b0d4c..31937defe511 100644 --- a/ui/src/pages/chat/chat-pane-session-creation.ts +++ b/ui/src/pages/chat/chat-pane-session-creation.ts @@ -1,4 +1,8 @@ -import { readSessionMethodAccess } from "../../lib/session-method-access.ts"; +import { t } from "../../i18n/index.ts"; +import { + readSessionMethodAccess, + type SessionMethodAccess, +} from "../../lib/session-method-access.ts"; import { resolveSessionCreateParams } from "../../lib/sessions/create.ts"; import { scopedAgentParamsForSession } from "../../lib/sessions/index.ts"; import { @@ -7,6 +11,7 @@ import { } from "../../lib/sessions/session-key.ts"; import { cloneChatAttachmentsForIndependentOwner } from "./attachment-payload-store.ts"; import { clearChatHistory } from "./chat-history.ts"; +import { createChatModelSetupBanner } from "./chat-model-setup.ts"; import { ChatPaneRetainedPresentation } from "./chat-pane-retained-presentation.ts"; import { NEW_SESSION_ACTIVE_RUN_MESSAGE, @@ -19,8 +24,155 @@ import { canCreateChatSession } from "./chat-state-route.ts"; /** Creates or resets a conversation while guarding its asynchronous ownership. */ export abstract class ChatPaneSessionCreation extends ChatPaneRetainedPresentation { + protected recoveringSession = false; + protected abstract confirmConversationReset(): Promise; + protected sessionDisabledBanner(params: { + catalogDisabledReason: string | null | undefined; + modelSetupRequired: boolean; + restartRecoveryTombstoned: boolean; + selectedSessionArchived: boolean; + selectedSessionId: string | undefined; + sessionKey: string; + unarchiveAccess: SessionMethodAccess; + }) { + if (params.catalogDisabledReason) { + return undefined; + } + if (params.restartRecoveryTombstoned) { + return this.restartRecoveryComposerBanner(); + } + if (params.selectedSessionArchived) { + return { + kind: "composer-replacement" as const, + text: t("chat.archivedSessionDisabled"), + actionLabel: t("common.unarchive"), + disabledReason: !params.selectedSessionId + ? "Session lifecycle action requires a durable session identity." + : params.unarchiveAccess.allowed + ? undefined + : params.unarchiveAccess.reason, + onAction: () => { + if (params.selectedSessionId && params.unarchiveAccess.allowed) { + void this.restoreArchivedSession(params.sessionKey, params.selectedSessionId); + } + }, + }; + } + return params.modelSetupRequired + ? createChatModelSetupBanner(() => this.context.navigate("model-setup")) + : undefined; + } + + protected restartRecoveryComposerBanner() { + const state = this.state; + if (!state) { + return undefined; + } + const agentId = + scopedAgentParamsForSession(state, state.sessionKey).agentId ?? + resolveAgentIdFromSessionKey(state.sessionKey); + const params = { + ...(agentId ? { agentId } : {}), + key: state.sessionKey, + }; + const access = readSessionMethodAccess(this.context.gateway.snapshot, { + method: "sessions.recover", + params, + }); + return { + kind: "composer-replacement" as const, + title: t("chat.restartRecoveryTitle"), + text: t("chat.restartRecoveryDisabled"), + tone: "neutral" as const, + icon: "warning" as const, + actionLabel: t("chat.resumeInNewSession"), + actionStyle: "primary" as const, + busy: this.recoveringSession, + busyLabel: t("chat.resumingSession"), + disabledReason: access.allowed || this.recoveringSession ? undefined : access.reason, + onAction: () => { + if (access.allowed && !this.recoveringSession) { + void this.recoverSession(); + } + }, + }; + } + + protected readonly recoverSession = async (): Promise => { + const state = this.state; + if (!state || !state.client || !state.connected || this.recoveringSession) { + return false; + } + const context = this.context; + const sessions = context.sessions; + const client = state.client; + const connectionGeneration = this.connectionGeneration; + const isCurrent = () => + this.isConnected && + this.state === state && + this.context === context && + this.context.sessions === sessions && + state.client === client && + state.connected && + this.connectedClient === client && + context.gateway.snapshot.client === client && + context.gateway.snapshot.phase === "connected" && + this.connectionGeneration === connectionGeneration; + const sourceSessionKey = state.sessionKey; + const agentId = + scopedAgentParamsForSession(state, sourceSessionKey).agentId ?? + resolveAgentIdFromSessionKey(sourceSessionKey); + const params = { + ...(agentId ? { agentId } : {}), + key: sourceSessionKey, + }; + const access = readSessionMethodAccess(context.gateway.snapshot, { + method: "sessions.recover", + params, + }); + if (!access.allowed) { + setChatError(state, access.reason); + state.requestUpdate?.(); + return false; + } + + this.recoveringSession = true; + this.requestUpdate(); + setChatError(state, null); + try { + const recovery = await sessions.recover(params); + if (!isCurrent() || state.sessionKey !== sourceSessionKey) { + return false; + } + if (!recovery) { + if (isCurrent()) { + setChatError(state, state.sessionsError ?? NEW_SESSION_CREATE_FAILED_MESSAGE); + state.requestUpdate?.(); + } + return false; + } + if (recovery.continuation.status === "rejected") { + setChatError(state, recovery.continuation.error.message); + state.requestUpdate?.(); + return false; + } + const nextSessionKey = recovery.key; + if (this.onPaneSessionChange?.(this.paneId, nextSessionKey) === false) { + return false; + } + preparePaneSessionHandoff(this.context, this.paneId, nextSessionKey, { + attachments: [], + draft: "", + }); + return true; + } finally { + this.recoveringSession = false; + this.requestUpdate(); + } + }; + protected readonly createSession = async (): Promise => { const state = this.state; if (!state || !state.client || !state.connected) { diff --git a/ui/src/pages/chat/chat-pane.test-support.ts b/ui/src/pages/chat/chat-pane.test-support.ts index 1560afe11047..b24be06c76ee 100644 --- a/ui/src/pages/chat/chat-pane.test-support.ts +++ b/ui/src/pages/chat/chat-pane.test-support.ts @@ -41,6 +41,17 @@ export type TestChatPane = HTMLElement & { connectedCallback: () => void; connectionGeneration: number; createSession: () => Promise; + recoverSession: () => Promise; + restartRecoveryComposerBanner: () => + | { + text: string; + actionLabel: string; + actionStyle?: "primary"; + busy?: boolean; + busyLabel?: string; + onAction: () => void; + } + | undefined; prepareForEviction: () => void; restoreArchivedSession: (sessionKey: string, expectedSessionId: string) => Promise; disconnectedCallback: () => void; diff --git a/ui/src/pages/chat/components/chat-composer-types.ts b/ui/src/pages/chat/components/chat-composer-types.ts index 06a3b5118450..a7881a447b59 100644 --- a/ui/src/pages/chat/components/chat-composer-types.ts +++ b/ui/src/pages/chat/components/chat-composer-types.ts @@ -46,8 +46,14 @@ export type CapabilityMenuProps = Omit< >; type ChatComposerDisabledBannerContent = { + title?: string; text: string; + tone?: "info" | "neutral"; + icon?: "warning"; actionLabel: string; + actionStyle?: "primary"; + busy?: boolean; + busyLabel?: string; disabledReason?: string; onAction: () => void; }; diff --git a/ui/src/pages/chat/components/chat-composer-view.ts b/ui/src/pages/chat/components/chat-composer-view.ts index 45eecaff5083..3c1d4dd83d92 100644 --- a/ui/src/pages/chat/components/chat-composer-view.ts +++ b/ui/src/pages/chat/components/chat-composer-view.ts @@ -107,16 +107,37 @@ export function renderChatComposerView(context: ChatComposerViewContext) { } = context; const disabledBanner = props.disabledBanner ? html` -
- ${props.disabledBanner.text} +
+ ${props.disabledBanner.icon === "warning" + ? html`` + : nothing} +
+ ${props.disabledBanner.title + ? html`
+ ${props.disabledBanner.title} +
` + : nothing} +
${props.disabledBanner.text}
+
${props.disabledBanner.kind === "composer-replacement" && showAbortableUi ? renderChatPrimaryActions(runControlsProps) diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 6c9f767dfc49..9ce1e60e0b0f 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -2644,6 +2644,31 @@ button.chat-reply-preview--message:disabled { flex: 0 0 auto; } +.agent-chat__disabled-banner--neutral { + color: var(--text); +} + +.agent-chat__disabled-banner-icon { + display: inline-flex; + flex: 0 0 auto; + color: color-mix(in srgb, var(--warn) 72%, var(--muted)); +} + +.agent-chat__disabled-banner-icon svg { + width: 18px; + height: 18px; +} + +.agent-chat__disabled-banner-title { + color: var(--text-strong); + font-weight: 600; + line-height: 1.35; +} + +.agent-chat__disabled-banner-detail { + color: var(--muted); +} + .agent-chat__composer-footer { display: flex; align-items: center;