Files
Peter Steinberger 73ae583263 refactor(tasks): simplify subagent activity tracking (#122198)
* refactor(tasks): simplify subagent activity tracking

* fix(android): single-source subagent activity expiry clock

* chore(i18n): refresh native source baseline
2026-08-11 12:30:49 -07:00

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)
}
}
}
}