Skip to content
Closed
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
97 changes: 87 additions & 10 deletions apps/swift-ios/App/NativeFeatureClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2320,22 +2320,61 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,

private func removeCachedApproval(id: String, threadID: String) {
guard var detail = latestDetails[threadID] else { return }
detail.approvals.removeAll { $0.id == id }
if detail.approvals.isEmpty, detail.thread.state == .waitingForApproval {
detail.thread.state = detail.userInputs.isEmpty ? .idle : .waitingForInput
if let cache = detailRenderCaches[threadID] {
cache.approvals.removeAll { $0.id == id }
detail.approvals = cache.approvals
} else {
detail.approvals.removeAll { $0.id == id }
}
reconcilePendingRequestState(&detail, threadID: threadID)
publish(detail, threadID: threadID)
}

private func removeCachedInput(id: String, threadID: String) {
guard var detail = latestDetails[threadID] else { return }
detail.userInputs.removeAll { $0.id == id }
if detail.userInputs.isEmpty, detail.thread.state == .waitingForInput {
detail.thread.state = detail.approvals.isEmpty ? .idle : .waitingForApproval
if let cache = detailRenderCaches[threadID] {
cache.userInputs.removeAll { $0.id == id }
detail.userInputs = cache.userInputs
} else {
detail.userInputs.removeAll { $0.id == id }
}
reconcilePendingRequestState(&detail, threadID: threadID)
publish(detail, threadID: threadID)
}

private func reconcilePendingRequestState(
_ detail: inout FeatureThreadDetail,
threadID: String
) {
let shellThread = threadEnvironmentIDs[threadID].flatMap { environmentID in
threadWireIDs[threadID].flatMap { wireID in
shellsByEnvironmentID[environmentID]?.threads.first { $0.id == wireID }
}
}
let hasApprovals = shellThread?.hasPendingApprovals == true || !detail.approvals.isEmpty
let hasUserInput = shellThread?.hasPendingUserInput == true || !detail.userInputs.isEmpty
if let shellThread {
detail.thread.state = Self.resolveThreadState(
latestTurn: shellThread.latestTurn,
session: shellThread.session,
hasApprovals: hasApprovals,
hasUserInput: hasUserInput,
backgroundLiveness: shellThread.backgroundLiveness
)
} else if hasApprovals {
detail.thread.state = .waitingForApproval
} else if hasUserInput {
detail.thread.state = .waitingForInput
} else if detail.thread.state == .waitingForApproval
|| detail.thread.state == .waitingForInput {
detail.thread.state = .idle
}
detail.thread.settlementInput = detail.thread.settlementInput?.withPendingRequests(
approvals: hasApprovals,
userInput: hasUserInput
)
}

private func workspaceContext(route: NativeThreadRoute) throws -> (
cwd: String,
worktreePath: String?
Expand Down Expand Up @@ -3473,13 +3512,24 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
let backgroundWorkIsActive = backgroundLiveness == .working
let sessionIsLive = shellThread.session?.status == "starting"
|| shellThread.session?.status == "running"
let hasApprovals = shellThread.hasPendingApprovals || !detail.approvals.isEmpty
let hasUserInput = shellThread.hasPendingUserInput || !detail.userInputs.isEmpty
detail.thread.state = Self.resolveThreadState(
latestTurn: shellThread.latestTurn,
session: shellThread.session,
hasApprovals: !detail.approvals.isEmpty,
hasUserInput: !detail.userInputs.isEmpty,
hasApprovals: hasApprovals,
hasUserInput: hasUserInput,
backgroundLiveness: backgroundLiveness
)
detail.thread.settlementInput = FeatureSettlementProjectionInput(
hasPendingApprovals: hasApprovals,
hasPendingUserInput: hasUserInput,
sessionStatus: shellThread.session?.status,
latestUserMessageAt: shellThread.latestUserMessageAt,
latestTurnRequestedAt: shellThread.latestTurn?.requestedAt,
latestTurnStartedAt: shellThread.latestTurn?.startedAt,
latestTurnCompletedAt: shellThread.latestTurn?.completedAt
)
detail.thread.workingStartedAt = workingStartedAt(
latestTurn: shellThread.latestTurn,
session: shellThread.session,
Expand Down Expand Up @@ -3886,6 +3936,15 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
supportsSnooze: environment.descriptor?.capabilities.threadSnooze,
supportsPinning: environment.descriptor?.capabilities.threadPinning,
supportsTitleRegeneration: environment.descriptor?.capabilities.threadTitleRegeneration,
settlementInput: FeatureSettlementProjectionInput(
hasPendingApprovals: thread.hasPendingApprovals,
hasPendingUserInput: thread.hasPendingUserInput,
sessionStatus: thread.session?.status,
latestUserMessageAt: thread.latestUserMessageAt,
latestTurnRequestedAt: thread.latestTurn?.requestedAt,
latestTurnStartedAt: thread.latestTurn?.startedAt,
latestTurnCompletedAt: thread.latestTurn?.completedAt
),
attentionAt: failureDate(
latestTurn: thread.latestTurn,
session: thread.session
Expand Down Expand Up @@ -3955,6 +4014,16 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
supportsSnooze: environment.descriptor?.capabilities.threadSnooze,
supportsPinning: environment.descriptor?.capabilities.threadPinning,
supportsTitleRegeneration: environment.descriptor?.capabilities.threadTitleRegeneration,
settlementInput: FeatureSettlementProjectionInput(
hasPendingApprovals: false,
hasPendingUserInput: false,
sessionStatus: thread.session?.status,
latestUserMessageAt: thread.messages
.last(where: { $0.role == "user" })?.createdAt,
latestTurnRequestedAt: thread.latestTurn?.requestedAt,
latestTurnStartedAt: thread.latestTurn?.startedAt,
latestTurnCompletedAt: thread.latestTurn?.completedAt
),
attentionAt: failureDate(
latestTurn: thread.latestTurn,
session: thread.session
Expand Down Expand Up @@ -4027,13 +4096,21 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
let backgroundWorkIsActive = backgroundLiveness == .working
let sessionIsLive = thread.session?.status == "starting"
|| thread.session?.status == "running"
let shellThread = shellsByEnvironmentID[environment.id]?.threads
.first(where: { $0.id == thread.id })
let hasApprovals = shellThread?.hasPendingApprovals == true || !cache.approvals.isEmpty
let hasUserInput = shellThread?.hasPendingUserInput == true || !cache.userInputs.isEmpty
mappedThread.state = Self.resolveThreadState(
latestTurn: thread.latestTurn,
session: thread.session,
hasApprovals: !cache.approvals.isEmpty,
hasUserInput: !cache.userInputs.isEmpty,
hasApprovals: hasApprovals,
hasUserInput: hasUserInput,
backgroundLiveness: backgroundLiveness
)
mappedThread.settlementInput = mappedThread.settlementInput?.withPendingRequests(
approvals: hasApprovals,
userInput: hasUserInput
)
return FeatureThreadDetail(
thread: mappedThread,
messages: cache.mergedMessages,
Expand Down
75 changes: 75 additions & 0 deletions apps/swift-ios/Features/Shared/FeatureModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl
public var supportsSnooze: Bool?
public var supportsPinning: Bool?
public var supportsTitleRegeneration: Bool?
public var settlementInput: FeatureSettlementProjectionInput?
public var attentionAt: Date?
public var workingStartedAt: Date?
public var latestTurnCompletedAt: Date?
Expand Down Expand Up @@ -252,6 +253,7 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl
supportsSnooze: Bool? = nil,
supportsPinning: Bool? = nil,
supportsTitleRegeneration: Bool? = nil,
settlementInput: FeatureSettlementProjectionInput? = nil,
attentionAt: Date? = nil,
workingStartedAt: Date? = nil,
latestTurnCompletedAt: Date? = nil,
Expand Down Expand Up @@ -286,6 +288,7 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl
self.supportsSnooze = supportsSnooze
self.supportsPinning = supportsPinning
self.supportsTitleRegeneration = supportsTitleRegeneration
self.settlementInput = settlementInput
self.attentionAt = attentionAt
self.workingStartedAt = workingStartedAt
self.latestTurnCompletedAt = latestTurnCompletedAt
Expand All @@ -308,6 +311,78 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl
}
}

public struct FeatureSettlementProjectionInput: Sendable, Equatable, Hashable, Codable {
public var hasPendingApprovals: Bool
public var hasPendingUserInput: Bool
public var sessionStatus: String?
public var latestUserMessageAt: String?
public var latestTurnRequestedAt: String?
public var latestTurnStartedAt: String?
public var latestTurnCompletedAt: String?

public init(
hasPendingApprovals: Bool,
hasPendingUserInput: Bool,
sessionStatus: String?,
latestUserMessageAt: String?,
latestTurnRequestedAt: String?,
latestTurnStartedAt: String?,
latestTurnCompletedAt: String?
) {
self.hasPendingApprovals = hasPendingApprovals
self.hasPendingUserInput = hasPendingUserInput
self.sessionStatus = sessionStatus
self.latestUserMessageAt = latestUserMessageAt
self.latestTurnRequestedAt = latestTurnRequestedAt
self.latestTurnStartedAt = latestTurnStartedAt
self.latestTurnCompletedAt = latestTurnCompletedAt
}

func withPendingRequests(approvals: Bool, userInput: Bool) -> Self {
var copy = self
copy.hasPendingApprovals = approvals
copy.hasPendingUserInput = userInput
return copy
}
}

enum FeatureSettlementProjection {
static let queuedTurnStartGrace: TimeInterval = 2 * 60

static func canSettle(
_ input: FeatureSettlementProjectionInput,
now: Date
) -> Bool {
if input.hasPendingApprovals || input.hasPendingUserInput { return false }
if input.sessionStatus == "starting" || input.sessionStatus == "running" { return false }
if input.sessionStatus == "error" { return true }
guard let rawMessageAt = input.latestUserMessageAt,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Shared/FeatureModels.swift:359

canSettle returns true for a fresh message whose turn has only been requested, allowing Settle while the turn is still waiting to start. latestTurnRequestedAt being newer than latestUserMessageAt makes allSatisfy false, and abs(now.timeIntervalSince(messageAt)) also treats a sufficiently future server timestamp as stale and returns true; keep future timestamps blocked and treat a request without started/completed timestamps as queued.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/swift-ios/Features/Shared/FeatureModels.swift around line 359:

`canSettle` returns `true` for a fresh message whose turn has only been requested, allowing Settle while the turn is still waiting to start. `latestTurnRequestedAt` being newer than `latestUserMessageAt` makes `allSatisfy` false, and `abs(now.timeIntervalSince(messageAt))` also treats a sufficiently future server timestamp as stale and returns `true`; keep future timestamps blocked and treat a request without started/completed timestamps as queued.

let messageAt = parseDate(rawMessageAt) else {
return true
}
let messageAge = now.timeIntervalSince(messageAt)
if messageAge < 0 { return false }
if messageAge > queuedTurnStartGrace { return true }
if let completedAt = input.latestTurnCompletedAt.flatMap(parseDate),
completedAt >= messageAt {
return true
}
return false
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Settlement projection misses turn adoption

Medium Severity

FeatureSettlementProjection.canSettle does not match the shared hasQueuedTurnStart contract. Within the grace window it only clears the block via latestTurnCompletedAt, leaving latestTurnRequestedAt and latestTurnStartedAt unused, so an adopted turn still blocks Settle. Future-stamped messages also always fail (messageAge &lt; 0) instead of using the shared absolute grace bound, so clock skew can hide Settle long after the server would accept it.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bb748e6. Configure here.


private static func parseDate(_ value: String) -> Date? {
fractionalDateFormatter.date(from: value) ?? dateFormatter.date(from: value)
}
Comment thread
cursor[bot] marked this conversation as resolved.

private static let fractionalDateFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()

private static let dateFormatter = ISO8601DateFormatter()
}

public enum FeatureMessageRole: String, Sendable, Codable {
case user
case assistant
Expand Down
16 changes: 13 additions & 3 deletions apps/swift-ios/Features/Workspace/DailyUXModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -635,23 +635,33 @@ extension FeatureThread {
.first(where: { $0.id == projectID })?
.environmentID
let resolvedEnvironmentID = environmentID ?? projectEnvironmentID
let providers = resolvedEnvironmentID.flatMap {
snapshot.providersByEnvironment?[$0]
} ?? []
let providers: [FeatureProvider]
if let providersByEnvironment = snapshot.providersByEnvironment {
providers = resolvedEnvironmentID.flatMap { providersByEnvironment[$0] } ?? []
} else {
providers = snapshot.providers
}
return providers.first(where: { $0.id == providerID })?.name ?? providerID
}

var needsAttention: Bool {
state == .waitingForApproval || state == .waitingForInput || state == .failed
}

var canSettle: Bool {
isSettled || settlementInput.map {
FeatureSettlementProjection.canSettle($0, now: .now)
} == true
}

func isEffectivelySettled(at now: Date) -> Bool {
switch state {
case .queued, .working, .monitoring, .waitingForApproval, .waitingForInput:
return false
case .idle, .failed, .completed:
break
}
guard canSettle else { return false }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Settled rows ignore queued-turn blocker

Medium Severity

canSettle short-circuits to true whenever isSettled is set, and isEffectivelySettled gates on that value before applying settle state. A thread that is still marked settled can therefore stay on the Settled shelf and offer Reopen even while a newer user message is inside the queued-turn grace window. The shared effectiveSettled path only forgives that blocker when settledAt is at least as new as the message.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bb748e6. Configure here.

if isSettled {
return true
}
Expand Down
Loading
Loading