Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,21 +533,29 @@ describe("ade code remote launcher", () => {
children.push(child);
return child;
});
const startedAt = Date.now();

await expect(openRemoteRpcSession({
const target = {
...legacyAccountTarget(),
id: "offline-explicit-ssh",
name: "Offline workstation",
sshUser: "arul",
}, {
};
const routeCount = new Set([
`${target.hostname}:${target.port ?? ""}`,
...(target.routes ?? []).map((route) => `${route.hostname}:${route.port ?? ""}`),
]).size;
const maxRouteRuntimeAttempts =
routeCount * remoteRuntimeLayoutCandidates(process.env).length * 2;
const startedAt = Date.now();

await expect(openRemoteRpcSession(target, {
totalTimeoutMs: 80,
attemptTimeoutMs: 50,
spawnProcess,
})).rejects.toThrow(/bounded route\/runtime combinations.*deadline/i);

expect(Date.now() - startedAt).toBeLessThan(1_000);
expect(spawnProcess.mock.calls.length).toBeLessThanOrEqual(2);
expect(spawnProcess.mock.calls.length).toBeGreaterThan(0);
expect(spawnProcess.mock.calls.length).toBeLessThanOrEqual(maxRouteRuntimeAttempts);
expect(children.every((child) => child.kill.mock.calls.length > 0)).toBe(true);
});

Expand Down
39 changes: 28 additions & 11 deletions apps/ios/ADE/Models/RemoteModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2266,8 +2266,14 @@ extension AgentChatEvent {
case options
case id
case origin
case sampleId
case newConversationId
case stillQueuedUuids
case cancelledUuids
case recoveryId
case messageCount
case expiresAt
case stopMode
case commandUuid
case preview
case goal
Expand Down Expand Up @@ -2487,12 +2493,17 @@ extension AgentChatEvent {
turnId: try container.decodeIfPresent(String.self, forKey: .turnId)
)
case "context_usage":
let usage = try container.decode(AgentChatContextUsage.self, forKey: .usage)
let turnId = try container.decodeIfPresent(String.self, forKey: .turnId)
let origin = try container.decodeIfPresent(String.self, forKey: .origin)
let state = try container.decodeIfPresent(String.self, forKey: .state)
let sampleId = try container.decodeIfPresent(Int.self, forKey: .sampleId)
self = .contextUsage(
usage: try container.decode(AgentChatContextUsage.self, forKey: .usage),
turnId: try container.decodeIfPresent(String.self, forKey: .turnId),
origin: try container.decodeIfPresent(String.self, forKey: .origin),
state: try container.decodeIfPresent(String.self, forKey: .state),
sampleId: try container.decodeIfPresent(Int.self, forKey: .sampleId)
usage: usage,
turnId: turnId,
origin: origin,
state: state,
sampleId: sampleId
)
case "conversation_reset":
self = .conversationReset(
Expand All @@ -2504,16 +2515,22 @@ extension AgentChatEvent {
cancelledUuids: try container.decodeIfPresent([String].self, forKey: .cancelledUuids)
)
case "queue_recovery":
let state = try container.decode(String.self, forKey: .state)
let messageCount = try container.decode(Int.self, forKey: .messageCount)
let expiresAt = try container.decode(String.self, forKey: .expiresAt)
let stopMode = try container.decode(String.self, forKey: .stopMode)
let turnId = try container.decodeIfPresent(String.self, forKey: .turnId)
let recoveryId = try container.decode(String.self, forKey: .recoveryId)
self = .systemNotice(
noticeKind: .queueRecovery,
message: try container.decode(String.self, forKey: .state),
message: state,
detail: .object([
"messageCount": .number(Double(try container.decode(Int.self, forKey: .messageCount))),
"expiresAt": .string(try container.decode(String.self, forKey: .expiresAt)),
"stopMode": .string(try container.decode(String.self, forKey: .stopMode)),
"messageCount": .number(Double(messageCount)),
"expiresAt": .string(expiresAt),
"stopMode": .string(stopMode),
]),
turnId: try container.decodeIfPresent(String.self, forKey: .turnId),
steerId: try container.decode(String.self, forKey: .recoveryId)
turnId: turnId,
steerId: recoveryId
)
case "command_lifecycle":
self = .commandLifecycle(
Expand Down
165 changes: 145 additions & 20 deletions apps/ios/ADE/Services/SyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2811,6 +2811,11 @@ final class SyncService: ObservableObject {
let startedAt: TimeInterval
}

private struct PendingChatUnsubscribe {
let task: Task<Void, Never>
let scheduledAt: Date
}

private struct PendingOutboundChangeset {
var payload: SyncChangesetBatchPayload
var sentAt: TimeInterval
Expand Down Expand Up @@ -2918,6 +2923,13 @@ final class SyncService: ObservableObject {
private var supportsProjectCatalog = false
private var supportsProjectActions = false
private var supportsChatStreaming = false
private let chatSnapshotRequestCoalescingInterval: TimeInterval = 5
private let chatEventUnsubscribeRetentionLimit = 4
private var recentFullChatSnapshotRequestBySession: [
String: (uptime: TimeInterval, connectionGeneration: UInt64)
] = [:]
private var pendingChatUnsubscribesBySession: [String: PendingChatUnsubscribe] = [:]
private var chatSubscriptionsNeedingRemoteActivation: Set<String> = []
private var supportsChangesetAck = false
private var relayAuthorizationLease: SyncRelayAuthorizationLease?
private var relayReauthorizationTask: Task<Void, Never>?
Expand Down Expand Up @@ -8329,41 +8341,144 @@ final class SyncService: ObservableObject {
isRemoteActionQueueable(chatActionName(projectAction, sessionId: sessionId))
}

func subscribeToChatEvents(sessionId: String, requestSnapshot: Bool = false, maxBytes: Int? = nil) async throws {
/// Returns true when the requested subscription state is backed by an
/// envelope dispatched now or, for a full snapshot, a recently dispatched
/// envelope that is still inside the coalescing window.
@discardableResult
func subscribeToChatEvents(
sessionId: String,
requestSnapshot: Bool = false,
maxBytes: Int? = nil
) async throws -> Bool {
let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedSessionId.isEmpty else { return }
guard !trimmedSessionId.isEmpty else { return false }
let wasSubscribed = subscribedChatSessionIds.contains(trimmedSessionId)
if !wasSubscribed {
subscribedChatSessionIds.insert(trimmedSessionId)
localStateRevision += 1
retainChatEventSubscription(sessionId: trimmedSessionId)
let needsRemoteActivation = chatSubscriptionsNeedingRemoteActivation.contains(trimmedSessionId)
let requestUptime = ProcessInfo.processInfo.systemUptime
let canSendChatSubscription = canSendLiveRequests() && supportsChatStreaming
let snapshotRequestWasRecentlySent: Bool
if requestSnapshot,
canSendChatSubscription,
let recentRequest = recentFullChatSnapshotRequestBySession[trimmedSessionId],
recentRequest.connectionGeneration == connectionGeneration,
requestUptime - recentRequest.uptime < chatSnapshotRequestCoalescingInterval {
snapshotRequestWasRecentlySent = true
} else {
snapshotRequestWasRecentlySent = false
}
if snapshotRequestWasRecentlySent {
return true
}
if canSendLiveRequests() && supportsChatStreaming && (!wasSubscribed || requestSnapshot) {
if canSendChatSubscription
&& (!wasSubscribed || needsRemoteActivation || requestSnapshot) {
// Explicit snapshot requests must not advertise a resume point — the
// caller wants the full history, not a delta replay.
let payload = chatSubscriptionPayload(sessionId: trimmedSessionId, maxBytes: maxBytes, includeSinceSeq: !requestSnapshot)
syncChatLog.notice(
"chat_subscribe_send session=\(trimmedSessionId, privacy: .public) requestSnapshot=\(requestSnapshot, privacy: .public) wasSubscribed=\(wasSubscribed, privacy: .public) maxBytes=\((payload["maxBytes"] as? Int) ?? -1, privacy: .public) sinceSeq=\((payload["sinceSeq"] as? Int) ?? -1, privacy: .public) reducedLoad=\(self.prefersReducedSyncLoad, privacy: .public) state=\(self.connectionState.rawValue, privacy: .public)"
)
sendEnvelope(
guard sendEnvelope(
type: "chat_subscribe",
requestId: nil,
payload: payload
) else { return false }
if requestSnapshot {
recentFullChatSnapshotRequestBySession[trimmedSessionId] = (
uptime: requestUptime,
connectionGeneration: connectionGeneration
)
}
syncChatLog.notice(
"chat_subscribe_send session=\(trimmedSessionId, privacy: .public) requestSnapshot=\(requestSnapshot, privacy: .public) wasSubscribed=\(wasSubscribed, privacy: .public) maxBytes=\((payload["maxBytes"] as? Int) ?? -1, privacy: .public) sinceSeq=\((payload["sinceSeq"] as? Int) ?? -1, privacy: .public) reducedLoad=\(self.prefersReducedSyncLoad, privacy: .public) state=\(self.connectionState.rawValue, privacy: .public)"
)
chatSubscriptionsNeedingRemoteActivation.remove(trimmedSessionId)
return true
}
return false
}

func requestFullChatEventSnapshot(sessionId: String) async throws {
try await subscribeToChatEvents(sessionId: sessionId, requestSnapshot: true, maxBytes: syncChatSubscriptionMaxBytes)
/// Mark a visible chat as locally desired even when the host is unreachable.
/// This synchronously cancels pending warm-cache eviction so reconnect
/// restoration cannot omit a chat that is still on screen.
func retainChatEventSubscription(sessionId: String) {
let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedSessionId.isEmpty else { return }
cancelDelayedChatUnsubscribe(sessionId: trimmedSessionId)
if subscribedChatSessionIds.insert(trimmedSessionId).inserted {
chatSubscriptionsNeedingRemoteActivation.insert(trimmedSessionId)
localStateRevision += 1
}
}

@discardableResult
func requestFullChatEventSnapshot(sessionId: String) async throws -> Bool {
try await subscribeToChatEvents(
sessionId: sessionId,
requestSnapshot: true,
maxBytes: syncChatSubscriptionMaxBytes
)
}

func isFullChatEventSnapshotPending(sessionId: String) -> Bool {
let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines)
guard canSendLiveRequests(),
let recentRequest = recentFullChatSnapshotRequestBySession[trimmedSessionId]
else {
return false
}
return recentRequest.connectionGeneration == connectionGeneration
}

func unsubscribeFromChatEvents(sessionId: String) async throws {
let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedSessionId.isEmpty else { return }
guard subscribedChatSessionIds.contains(trimmedSessionId) else { return }
subscribedChatSessionIds.remove(trimmedSessionId)
cancelDelayedChatUnsubscribe(sessionId: trimmedSessionId)
performChatEventUnsubscribe(sessionId: trimmedSessionId)
}

/// Keep a recently-viewed project chat warm briefly so switching back can
/// reuse its render-ready transcript and sequence watermark. Retention is
/// bounded to four chats; older pending subscriptions are released first.
func scheduleChatEventUnsubscribe(
sessionId: String,
delayNanoseconds: UInt64 = 120_000_000_000
) {
let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedSessionId.isEmpty,
subscribedChatSessionIds.contains(trimmedSessionId)
else { return }

cancelDelayedChatUnsubscribe(sessionId: trimmedSessionId)
while pendingChatUnsubscribesBySession.count >= chatEventUnsubscribeRetentionLimit,
let oldestSessionId = pendingChatUnsubscribesBySession.min(by: {
$0.value.scheduledAt < $1.value.scheduledAt
})?.key {
cancelDelayedChatUnsubscribe(sessionId: oldestSessionId)
performChatEventUnsubscribe(sessionId: oldestSessionId)
}

let task = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: delayNanoseconds)
guard let self, !Task.isCancelled else { return }
self.pendingChatUnsubscribesBySession.removeValue(forKey: trimmedSessionId)
self.performChatEventUnsubscribe(sessionId: trimmedSessionId)
}
pendingChatUnsubscribesBySession[trimmedSessionId] = PendingChatUnsubscribe(
task: task,
scheduledAt: Date()
)
}

private func cancelDelayedChatUnsubscribe(sessionId: String) {
pendingChatUnsubscribesBySession.removeValue(forKey: sessionId)?.task.cancel()
}

private func performChatEventUnsubscribe(sessionId: String) {
guard subscribedChatSessionIds.contains(sessionId) else { return }
subscribedChatSessionIds.remove(sessionId)
chatSubscriptionsNeedingRemoteActivation.remove(sessionId)
recentFullChatSnapshotRequestBySession.removeValue(forKey: sessionId)
localStateRevision += 1
if canSendLiveRequests() && supportsChatStreaming {
sendEnvelope(type: "chat_unsubscribe", requestId: nil, payload: chatSubscriptionPayload(sessionId: trimmedSessionId))
sendEnvelope(type: "chat_unsubscribe", requestId: nil, payload: chatSubscriptionPayload(sessionId: sessionId))
}
}

Expand Down Expand Up @@ -14346,6 +14461,7 @@ final class SyncService: ObservableObject {
let dict = payload as? [String: Any],
let snapshot = try? decode(dict, as: SyncChatSubscribeSnapshotPayload.self),
subscribedChatSessionIds.contains(snapshot.sessionId) {
recentFullChatSnapshotRequestBySession.removeValue(forKey: snapshot.sessionId)
let resumed = (dict["resumed"] as? Bool) == true
let previousLastSeq = chatEventLastSeqBySession[snapshot.sessionId]
if !resumed {
Expand Down Expand Up @@ -14731,12 +14847,13 @@ final class SyncService: ObservableObject {
}
}

@discardableResult
private func sendEnvelope(
type: String,
requestId: String?,
payload: Any,
projectIdOverride: String? = nil
) {
) -> Bool {
#if DEBUG
if capturesOutboundEnvelopesForTesting {
let projectId = syncNormalizedCommandScopeValue(projectIdOverride)
Expand All @@ -14747,12 +14864,12 @@ final class SyncService: ObservableObject {
let response = capturedRefreshResponseForTesting(type: type, payload: payload) {
resolve(requestId: requestId, result: .success(response))
}
return
return true
}
#endif
guard let socket else { return }
guard let socket else { return false }
let sendSocket = socket
guard let payloadData = try? adeJSONData(withJSONObject: payload) else { return }
guard let payloadData = try? adeJSONData(withJSONObject: payload) else { return false }

var envelope: [String: Any]
if payloadData.count >= compressionThresholdBytes {
Expand Down Expand Up @@ -14783,7 +14900,7 @@ final class SyncService: ObservableObject {

guard let data = try? adeJSONData(withJSONObject: envelope),
let text = String(data: data, encoding: .utf8)
else { return }
else { return false }

sendSocket.send(.string(text)) { error in
if let error {
Expand All @@ -14792,6 +14909,7 @@ final class SyncService: ObservableObject {
}
}
}
return true
}

private func awaitSocketOpen(_ task: URLSessionWebSocketTask) async throws {
Expand Down Expand Up @@ -15771,6 +15889,7 @@ final class SyncService: ObservableObject {
requestId: nil,
payload: payload
)
chatSubscriptionsNeedingRemoteActivation.remove(sessionId)
}
}

Expand Down Expand Up @@ -16139,7 +16258,13 @@ final class SyncService: ObservableObject {
// tagging lanes with another project's PRs.
laneGithubPrItems = []
laneGithubPrItemsFetchedAt = nil
for pendingUnsubscribe in pendingChatUnsubscribesBySession.values {
pendingUnsubscribe.task.cancel()
}
pendingChatUnsubscribesBySession.removeAll()
subscribedChatSessionIds.removeAll()
chatSubscriptionsNeedingRemoteActivation.removeAll()
recentFullChatSnapshotRequestBySession.removeAll()
// Turn-active hints are scoped to the live connection's event stream —
// a stale "running" hint must not survive a project switch or reconnect.
chatTurnActiveHintBySession.removeAll()
Expand Down
5 changes: 4 additions & 1 deletion apps/ios/ADE/Views/Work/WorkChatSessionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ struct WorkChatSessionView: View {
@State var latestPinTask: Task<Void, Never>?
@State var latestPinGeneration = 0
@State var assistantPreviewCache = WorkAssistantPreviewCache()
@State private var contextUsageViewModelCache = WorkContextUsageViewModelCache()
@State var assistantLineBudgets: [String: Int] = [:]
@State var composerSettingMutationInFlight = false
@State var composerSettingMutationGeneration = 0
Expand Down Expand Up @@ -807,8 +808,10 @@ struct WorkChatSessionView: View {

WorkChatComposerCard(
chatSummary: chatSummaryContext,
usageViewModel: workContextUsageViewModel(
usageViewModel: contextUsageViewModelCache.value(
sessionId: session.id,
transcript: transcript,
transcriptRenderSignature: transcriptRenderSignature,
provider: chatSummaryContext.provider,
fallbackContextWindow: chatSummaryContext.contextWindowFallback
),
Expand Down
Loading