mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-13 06:03:39 -06:00
73ae583263
* refactor(tasks): simplify subagent activity tracking * fix(android): single-source subagent activity expiry clock * chore(i18n): refresh native source baseline
248 lines
9.1 KiB
Swift
248 lines
9.1 KiB
Swift
import Foundation
|
|
import OpenClawKit
|
|
import OpenClawProtocol
|
|
|
|
enum ChatSubagentActivityStatus: String, Sendable {
|
|
case queued
|
|
case running
|
|
case completed
|
|
case failed
|
|
case cancelled
|
|
case timedOut = "timed_out"
|
|
|
|
var isWorking: Bool {
|
|
self == .queued || self == .running
|
|
}
|
|
}
|
|
|
|
enum ChatSubagentActivitySource: Sendable {
|
|
case event
|
|
case snapshot
|
|
}
|
|
|
|
struct ChatSubagentActivity: Identifiable, Equatable, Sendable {
|
|
let id: String
|
|
let status: ChatSubagentActivityStatus
|
|
let snippet: String?
|
|
let diffStat: ChatToolDiffStat?
|
|
let updatedAt: Double
|
|
let terminalObservedAt: Double?
|
|
let terminalSummary: String?
|
|
}
|
|
|
|
struct ChatSubagentActivityPresentation: Equatable, Sendable {
|
|
let rows: [ChatSubagentActivity]
|
|
let hiddenWorkingCount: Int
|
|
}
|
|
|
|
struct ChatSubagentActivityState: Equatable, Sendable {
|
|
private(set) var activitiesByID: [String: ChatSubagentActivity] = [:]
|
|
|
|
mutating func upsert(
|
|
_ task: TaskSummary,
|
|
nowMilliseconds: Double,
|
|
source: ChatSubagentActivitySource = .event)
|
|
{
|
|
guard let status = task.status.stringValue.flatMap(ChatSubagentActivityStatus.init(rawValue:))
|
|
else { return }
|
|
let previous = self.activitiesByID[task.id]
|
|
let fallbackSnippet = Self.firstNonBlank(task.lastactivity, task.progresssummary, task.lasttoolname)
|
|
let snippet = if !status.isWorking, previous != nil, Self.nonBlank(task.lastactivity) == nil {
|
|
previous?.snippet
|
|
} else {
|
|
fallbackSnippet ?? previous?.snippet
|
|
}
|
|
let endedAt = Self.timestampMilliseconds(task.endedat)
|
|
let updatedAt = Self.timestampMilliseconds(task.updatedat)
|
|
?? previous?.updatedAt
|
|
?? endedAt
|
|
?? nowMilliseconds
|
|
let terminalObservedAt: Double? = if status.isWorking {
|
|
nil
|
|
} else if let previous, !previous.status.isWorking {
|
|
previous.terminalObservedAt
|
|
} else {
|
|
source == .event ? nowMilliseconds : endedAt ?? updatedAt
|
|
}
|
|
self.activitiesByID[task.id] = ChatSubagentActivity(
|
|
id: task.id,
|
|
status: status,
|
|
snippet: snippet,
|
|
diffStat: Self.diffStat(task.diffstat) ?? previous?.diffStat,
|
|
updatedAt: updatedAt,
|
|
terminalObservedAt: terminalObservedAt,
|
|
terminalSummary: Self.nonBlank(task.terminalsummary) ?? previous?.terminalSummary)
|
|
}
|
|
|
|
mutating func remove(taskID: String) {
|
|
self.activitiesByID[taskID] = nil
|
|
}
|
|
|
|
mutating func removeAll() {
|
|
self.activitiesByID.removeAll()
|
|
}
|
|
|
|
mutating func removeExpired(
|
|
nowMilliseconds: Double,
|
|
retentionMilliseconds: Double = 60000)
|
|
{
|
|
self.activitiesByID = self.activitiesByID.filter { _, activity in
|
|
activity.status.isWorking ||
|
|
(activity.terminalObservedAt.map { nowMilliseconds - $0 < retentionMilliseconds } ?? false)
|
|
}
|
|
}
|
|
|
|
func presentation(limit: Int = 5) -> ChatSubagentActivityPresentation {
|
|
let sorted = self.activitiesByID.values.sorted { lhs, rhs in
|
|
if lhs.updatedAt != rhs.updatedAt {
|
|
return lhs.updatedAt > rhs.updatedAt
|
|
}
|
|
return lhs.id < rhs.id
|
|
}
|
|
let ordered = sorted.filter(\.status.isWorking) + sorted.filter { !$0.status.isWorking }
|
|
let rows = Array(ordered.prefix(limit))
|
|
let visibleIDs = Set(rows.map(\.id))
|
|
let hiddenWorkingCount = ordered.count { activity in
|
|
activity.status == .running && !visibleIDs.contains(activity.id)
|
|
}
|
|
return ChatSubagentActivityPresentation(
|
|
rows: rows,
|
|
hiddenWorkingCount: hiddenWorkingCount)
|
|
}
|
|
|
|
func nextExpiryMilliseconds(retentionMilliseconds: Double = 60000) -> Double? {
|
|
self.activitiesByID.values
|
|
.filter { !$0.status.isWorking }
|
|
.compactMap(\.terminalObservedAt)
|
|
.map { $0 + retentionMilliseconds }
|
|
.min()
|
|
}
|
|
|
|
private static func diffStat(_ value: [String: AnyCodable]?) -> ChatToolDiffStat? {
|
|
guard let added = value?["added"]?.intValue,
|
|
let removed = value?["removed"]?.intValue,
|
|
added >= 0,
|
|
removed >= 0
|
|
else { return nil }
|
|
let files = value?["files"]?.intValue
|
|
return ChatToolDiffStat(
|
|
files: files.map { max(0, $0) },
|
|
added: added,
|
|
removed: removed)
|
|
}
|
|
|
|
private static func timestampMilliseconds(_ value: AnyCodable?) -> Double? {
|
|
if let number = value?.doubleValue, number >= 0 { return number }
|
|
guard let raw = value?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!raw.isEmpty
|
|
else { return nil }
|
|
if let number = Double(raw), number >= 0 { return number }
|
|
let fractional = ISO8601DateFormatter()
|
|
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
let date = fractional.date(from: raw) ?? ISO8601DateFormatter().date(from: raw)
|
|
return date.map { $0.timeIntervalSince1970 * 1000 }
|
|
}
|
|
|
|
private static func firstNonBlank(_ values: String?...) -> String? {
|
|
values.lazy.compactMap(self.nonBlank).first
|
|
}
|
|
|
|
private static func nonBlank(_ value: String?) -> String? {
|
|
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed?.isEmpty == false ? trimmed : nil
|
|
}
|
|
}
|
|
|
|
extension OpenClawChatViewModel {
|
|
func handleTaskEvent(_ event: OpenClawChatTaskEvent) {
|
|
switch event {
|
|
case let .upserted(task):
|
|
self.foldSubagentTask(task)
|
|
case let .deleted(taskID):
|
|
self.updateSubagentActivityState { $0.remove(taskID: taskID) }
|
|
case .restored:
|
|
let session = self.currentSessionSnapshot()
|
|
Task { await self.refreshSubagentActivities(sessionSnapshot: session) }
|
|
}
|
|
}
|
|
|
|
func refreshSubagentActivities(sessionSnapshot: SessionSnapshot) async {
|
|
let baseline = self.subagentActivityState.activitiesByID
|
|
let tasks: [TaskSummary]
|
|
do {
|
|
tasks = try await self.transport.listTasks(
|
|
sessionKey: sessionSnapshot.key,
|
|
agentID: sessionSnapshot.deliveryAgentID)
|
|
} catch {
|
|
return
|
|
}
|
|
guard self.isCurrentSession(sessionSnapshot) else { return }
|
|
self.updateSubagentActivityState { state in
|
|
let now = Date().timeIntervalSince1970 * 1000
|
|
for task in tasks where self.isCurrentSubagentTask(task) {
|
|
// A task event received during this request is newer than its list snapshot.
|
|
guard state.activitiesByID[task.id] == baseline[task.id] else { continue }
|
|
state.upsert(task, nowMilliseconds: now, source: .snapshot)
|
|
}
|
|
state.removeExpired(nowMilliseconds: now)
|
|
}
|
|
}
|
|
|
|
func clearSubagentActivities() {
|
|
self.subagentActivityCleanupTask?.cancel()
|
|
self.subagentActivityCleanupTask = nil
|
|
self.subagentActivityState.removeAll()
|
|
self.subagentActivities = []
|
|
self.hiddenWorkingSubagentCount = 0
|
|
}
|
|
|
|
private func foldSubagentTask(_ task: TaskSummary) {
|
|
guard self.isCurrentSubagentTask(task) else { return }
|
|
self.updateSubagentActivityState { state in
|
|
let now = Date().timeIntervalSince1970 * 1000
|
|
state.upsert(task, nowMilliseconds: now)
|
|
state.removeExpired(nowMilliseconds: now)
|
|
}
|
|
}
|
|
|
|
private func isCurrentSubagentTask(_ task: TaskSummary) -> Bool {
|
|
guard task.runtime == "subagent",
|
|
let requesterSessionKey = task.sessionkey
|
|
else { return false }
|
|
return self.matchesCurrentSessionKey(
|
|
incoming: requesterSessionKey,
|
|
agentId: task.agentid,
|
|
current: self.sessionKey)
|
|
}
|
|
|
|
private func updateSubagentActivityState(
|
|
_ update: (inout ChatSubagentActivityState) -> Void)
|
|
{
|
|
let previous = self.subagentActivityState
|
|
update(&self.subagentActivityState)
|
|
guard self.subagentActivityState != previous else { return }
|
|
let presentation = self.subagentActivityState.presentation()
|
|
self.subagentActivities = presentation.rows
|
|
self.hiddenWorkingSubagentCount = presentation.hiddenWorkingCount
|
|
self.scheduleSubagentActivityCleanup()
|
|
self.markTimelineChanged()
|
|
}
|
|
|
|
private func scheduleSubagentActivityCleanup() {
|
|
self.subagentActivityCleanupTask?.cancel()
|
|
guard let expiry = self.subagentActivityState.nextExpiryMilliseconds() else {
|
|
self.subagentActivityCleanupTask = nil
|
|
return
|
|
}
|
|
let now = Date().timeIntervalSince1970 * 1000
|
|
let delay = max(0, Int64((expiry - now).rounded(.up)))
|
|
self.subagentActivityCleanupTask = Task { [weak self] in
|
|
try? await Task.sleep(for: .milliseconds(delay))
|
|
guard !Task.isCancelled, let self else { return }
|
|
self.updateSubagentActivityState { state in
|
|
state.removeExpired(nowMilliseconds: Date().timeIntervalSince1970 * 1000)
|
|
}
|
|
}
|
|
}
|
|
}
|