Skip to content

Commit 6df0fb8

Browse files
authored
feat: add session thread management (#98510)
* feat: add session thread management Squash of codex/thread-management (025aefc3ad1) onto origin/main: pin/archive/rename sessions via sessions.patch, archived-aware sessions.list, lifecycle fencing, read-only archived chat, SDK + Swift protocol support, Control UI session management. * refactor(ui): minimal session rows with hover-revealed management Chat picker and sidebar recents share session-row primitives: single-line rows, relative timestamps, rename/archive/pin revealed on hover or focus, accent pin badge for pinned rows, and an active-run spinner in the trail slot. Sidebar floats pinned sessions above recency via the shared comparator and gains archive/pin actions through the unified sessions-view patch fallback. Archive eligibility is one shared policy (canArchiveSessionRow); the sidebar/picker active-run tooltip now uses the real sessionsView.activeRun locale key. * fix: align session admission with mailbox-era main Integration fixes after rebasing onto current main: sessions_list mailbox test expectations learn the archived/pinned row fields and archived:false list param; gateway agent admission treats a session as deleted only when both the requested and canonical alias sets miss it (legacy bare-main stores and exec-approval followups read under different spellings); cron persist tests keep a consistent store across claim-guarded persist calls; the ACP abort hook test asserts abort propagation instead of signal identity; drop dead lifecycle writes flagged by no-useless-assignment and fix the promise-executor return in the codex compact test. * fix(qa): align UI e2e and shard fixtures with redesigned session rows Sidebar session rows are wrapper divs with an inner link now: update the navigation browser tests and chat-flow Playwright selectors. Seed a real per-test session store for the auto-fallback admission guard instead of depending on leftover host files at /tmp/sessions.json. Teach the test-projects routing fixture about the suites that newly import the shared temp-dir helper. Document the Codex thread-format contract for archivedAt/pinnedAt (flag derived from server-stamped timestamp, epoch ms here vs Codex epoch seconds) at the type and in the session docs. * test: route auto-fallback suite through temp-dir helper plans The auto-fallback suite now imports the shared temp-dir helper for its seeded session store, so the top-level helper routing fixture must list it in the auto-reply plan.
1 parent 3644946 commit 6df0fb8

271 files changed

Lines changed: 20663 additions & 4894 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/ios/Sources/Chat/IOSGatewayChatTransport.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import OSLog
77
struct IOSGatewayChatTransport: OpenClawChatTransport {
88
static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "ios.chat.transport")
99
static let defaultChatSendTimeoutMs = 30000
10+
static let compactionRequestTimeoutSeconds = 0
1011
private let gateway: GatewayNodeSession
1112

1213
private struct CreateSessionParams: Codable {
@@ -205,7 +206,11 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
205206

206207
func compactSession(sessionKey: String) async throws {
207208
let json = try Self.makeSessionKeyParamsJSON(sessionKey)
208-
_ = try await self.gateway.request(method: "sessions.compact", paramsJSON: json, timeoutSeconds: 10)
209+
let response = try await self.gateway.request(
210+
method: "sessions.compact",
211+
paramsJSON: json,
212+
timeoutSeconds: Self.compactionRequestTimeoutSeconds)
213+
try OpenClawSessionsCompactResponse.requireSuccess(from: response)
209214
}
210215

211216
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {

apps/ios/Tests/IOSGatewayChatTransportTests.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ import Testing
2727
#expect(IOSGatewayChatTransport.agentWaitRequestTimeoutSeconds(timeoutMs: 30000) == 35)
2828
}
2929

30+
@Test func compactionLeavesTerminalTimeoutToGateway() {
31+
#expect(IOSGatewayChatTransport.compactionRequestTimeoutSeconds == 0)
32+
}
33+
3034
@Test func agentWaitCompletionDecodesFallbackRunId() throws {
3135
let data = Data(#"{"status":"completed"}"#.utf8)
3236
let completion = try IOSGatewayChatTransport.decodeAgentWaitCompletion(data, fallbackRunId: "run-local")

apps/macos/Sources/OpenClaw/ControlChannel.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,8 @@ final class ControlChannel {
245245
func request(
246246
method: String,
247247
params: [String: AnyHashable]? = nil,
248-
timeoutMs: Double? = nil) async throws -> Data
248+
timeoutMs: Double? = nil,
249+
retryTransportFailures: Bool = true) async throws -> Data
249250
{
250251
do {
251252
let rawParams = params?.reduce(into: [String: OpenClawKit.AnyCodable]()) {
@@ -254,7 +255,8 @@ final class ControlChannel {
254255
let data = try await GatewayConnection.shared.request(
255256
method: method,
256257
params: rawParams,
257-
timeoutMs: timeoutMs)
258+
timeoutMs: timeoutMs,
259+
retryTransportFailures: retryTransportFailures)
258260
self.setStateThrottled(.connected)
259261
return data
260262
} catch {

apps/macos/Sources/OpenClaw/GatewayConnection.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,8 @@ actor GatewayConnection {
163163
func request(
164164
method: String,
165165
params: [String: AnyCodable]?,
166-
timeoutMs: Double? = nil) async throws -> Data
166+
timeoutMs: Double? = nil,
167+
retryTransportFailures: Bool = true) async throws -> Data
167168
{
168169
let cfg = try await self.configProvider()
169170
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
@@ -174,7 +175,7 @@ actor GatewayConnection {
174175
do {
175176
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
176177
} catch {
177-
if error is GatewayResponseError || error is GatewayDecodingError {
178+
if !retryTransportFailures || error is GatewayResponseError || error is GatewayDecodingError {
178179
throw error
179180
}
180181

apps/macos/Sources/OpenClaw/SessionActions.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import AppKit
22
import Foundation
3+
import OpenClawKit
34

45
enum SessionActions {
56
static func patchSession(
@@ -32,9 +33,12 @@ enum SessionActions {
3233
}
3334

3435
static func compactSession(key: String, maxLines: Int = 400) async throws {
35-
_ = try await ControlChannel.shared.request(
36+
let response = try await ControlChannel.shared.request(
3637
method: "sessions.compact",
37-
params: ["key": AnyHashable(key), "maxLines": AnyHashable(maxLines)])
38+
params: ["key": AnyHashable(key), "maxLines": AnyHashable(maxLines)],
39+
timeoutMs: 0,
40+
retryTransportFailures: false)
41+
try OpenClawSessionsCompactResponse.requireSuccess(from: response)
3842
}
3943

4044
@MainActor

apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,12 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
131131
}
132132

133133
func compactSession(sessionKey: String) async throws {
134-
_ = try await GatewayConnection.shared.request(
134+
let response = try await GatewayConnection.shared.request(
135135
method: "sessions.compact",
136136
params: ["key": AnyCodable(sessionKey)],
137-
timeoutMs: 10000)
137+
timeoutMs: 0,
138+
retryTransportFailures: false)
139+
try OpenClawSessionsCompactResponse.requireSuccess(from: response)
138140
}
139141

140142
func setActiveSessionKey(_ sessionKey: String) async throws {

apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,30 @@ struct GatewayConnectionTests {
9292
#expect(session.snapshotMakeCount() == 1)
9393
}
9494

95+
@Test func `request can disable retries for non idempotent mutations`() async throws {
96+
let session = GatewayTestWebSocketSession(
97+
taskFactory: {
98+
GatewayTestWebSocketTask(sendHook: { _, _, sendIndex in
99+
if sendIndex > 0 {
100+
throw URLError(.timedOut)
101+
}
102+
})
103+
})
104+
let (conn, _) = try self.makeConnection(session: session)
105+
106+
do {
107+
_ = try await conn.request(
108+
method: "sessions.compact",
109+
params: nil,
110+
timeoutMs: 10,
111+
retryTransportFailures: false)
112+
Issue.record("expected sessions.compact transport failure")
113+
} catch {}
114+
115+
#expect(session.snapshotMakeCount() == 1)
116+
#expect(session.latestTask()?.snapshotSendCount() == 2)
117+
}
118+
95119
@Test func `subscribe replays latest snapshot`() async throws {
96120
let session = self.makeSession()
97121
let (conn, _) = try self.makeConnection(session: session)

apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,10 @@ final class GatewayTestWebSocketTask: WebSocketTasking, @unchecked Sendable {
175175
self.lock.withLock { self.connectRequestID }
176176
}
177177

178+
func snapshotSendCount() -> Int {
179+
self.lock.withLock { self.sendCount }
180+
}
181+
178182
func resume() {
179183
self.state = .running
180184
}

apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,10 @@ private enum GatewayConnectErrorCodes {
241241
}
242242

243243
public actor GatewayChannelActor {
244+
nonisolated static func resolveRequestTimeoutMs(_ timeoutMs: Double?, defaultMs: Double) -> Double? {
245+
timeoutMs == 0 ? nil : (timeoutMs ?? defaultMs)
246+
}
247+
244248
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway")
245249
private var task: WebSocketTaskBox?
246250
private var pending: [String: CheckedContinuation<GatewayFrame, Error>] = [:]
@@ -1174,14 +1178,17 @@ public actor GatewayChannelActor {
11741178
timeoutMs: Double? = nil) async throws -> Data
11751179
{
11761180
try await self.connectOrThrow(context: "gateway connect")
1177-
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
1181+
// Zero leaves terminal-operation deadlines to the Gateway owner.
1182+
let effectiveTimeout = Self.resolveRequestTimeoutMs(timeoutMs, defaultMs: self.defaultRequestTimeoutMs)
11781183
let payload = try self.encodeRequest(method: method, params: params, kind: "request")
11791184
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
11801185
self.pending[payload.id] = cont
1181-
Task { [weak self] in
1182-
guard let self else { return }
1183-
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
1184-
await self.timeoutRequest(id: payload.id, timeoutMs: effectiveTimeout)
1186+
if let effectiveTimeout {
1187+
Task { [weak self] in
1188+
guard let self else { return }
1189+
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
1190+
await self.timeoutRequest(id: payload.id, timeoutMs: effectiveTimeout)
1191+
}
11851192
}
11861193
Task {
11871194
do {
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import Foundation
2+
3+
public struct OpenClawSessionsCompactResponse: Decodable, Sendable {
4+
public let ok: Bool
5+
public let reason: String?
6+
7+
public static func requireSuccess(from data: Data) throws {
8+
let response = try JSONDecoder().decode(Self.self, from: data)
9+
guard response.ok else {
10+
throw OpenClawSessionsCompactError(reason: response.reason)
11+
}
12+
}
13+
}
14+
15+
public struct OpenClawSessionsCompactError: Error, LocalizedError, Sendable {
16+
public let reason: String?
17+
18+
public var errorDescription: String? {
19+
let detail = self.reason?.trimmingCharacters(in: .whitespacesAndNewlines)
20+
return detail?.isEmpty == false ? detail : "Session compaction failed"
21+
}
22+
23+
public init(reason: String?) {
24+
self.reason = reason
25+
}
26+
}

0 commit comments

Comments
 (0)