diff --git a/macos/ControlCenter/AetherflowControlCenter.xcodeproj/project.pbxproj b/macos/ControlCenter/AetherflowControlCenter.xcodeproj/project.pbxproj index 30fa3ba..6d3e638 100644 --- a/macos/ControlCenter/AetherflowControlCenter.xcodeproj/project.pbxproj +++ b/macos/ControlCenter/AetherflowControlCenter.xcodeproj/project.pbxproj @@ -52,6 +52,13 @@ fileRef B00000000000000000000007 + A00000000000000000000007 + + isa + PBXBuildFile + fileRef + B00000000000000000000008 + B00000000000000000000001 isa @@ -118,6 +125,17 @@ sourceTree <group> + B00000000000000000000008 + + isa + PBXFileReference + lastKnownFileType + sourcecode.swift + path + MonitoringStore.swift + sourceTree + <group> + B00000000000000000000006 isa @@ -156,6 +174,7 @@ A00000000000000000000004 A00000000000000000000005 A00000000000000000000006 + A00000000000000000000007 runOnlyForDeploymentPostprocessing 0 @@ -221,6 +240,7 @@ B00000000000000000000004 B00000000000000000000005 B00000000000000000000007 + B00000000000000000000008 path AetherflowControlCenter diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift index 326515e..4a54a02 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift @@ -453,7 +453,8 @@ private struct DetailColumn: View { private var headerTitle: String { switch selectedSection { case .sessions: - return monitoring.selectedDetail?.session.workRef.nonEmptyValue + return monitoring.selectedDetail?.agent.taskTitle.nonEmptyValue + ?? monitoring.selectedDetail?.session.workRef.nonEmptyValue ?? monitoring.selectedDetail?.workloadID ?? "Live session monitor" case .queue: @@ -466,9 +467,12 @@ private struct DetailColumn: View { private var headerSummary: String { switch selectedSection { case .sessions: - return monitoring.selectedDetail?.session.sessionID.nonEmptyValue.map { - "Showing live status, tool activity, and recent events for \($0)." - } ?? monitoring.note + if let detail = monitoring.selectedDetail { + let sessionRoute = detail.session.sessionID.nonEmptyValue ?? "pending session" + let lifecycle = detail.agent.lifecycleState.nonEmptyValue ?? detail.session.status.nonEmptyValue ?? "unknown" + return "Showing live status, tool activity, and recent events for \(sessionRoute) in \(lifecycle) state." + } + return monitoring.note case .queue: return monitoring.queue.isEmpty ? monitoring.note : "The queue lane mirrors the daemon's waiting work without leaving the monitoring shell." case .overview, .diagnostics: @@ -519,9 +523,11 @@ private struct DetailHighlightsPanel: View { switch section { case .sessions: if let detail = monitoring.selectedDetail { + let pidLabel = detail.agent.pid > 0 ? String(detail.agent.pid) : "pending" return [ - "Selected workload: \(detail.workloadID).", + "Selected workload: \(detail.workloadID) on pid \(pidLabel).", "Session route: \(detail.session.serverRef.nonEmptyValue ?? "pending server") / \(detail.session.sessionID.nonEmptyValue ?? "pending session").", + "Task lane: \(detail.agent.taskID.nonEmptyValue ?? detail.session.workRef.nonEmptyValue ?? "manual spawn"). Lifecycle: \(detail.agent.lifecycleState.nonEmptyValue ?? detail.agent.state.nonEmptyValue ?? "unknown").", "Recent tool calls: \(detail.toolCalls.count). Event lines cached: \(detail.eventLines.count).", detail.errors.isEmpty ? "No daemon-reported errors on the selected workload." : "Daemon reported \(detail.errors.count) error\(detail.errors.count == 1 ? "" : "s") for this workload." ] @@ -529,7 +535,7 @@ private struct DetailHighlightsPanel: View { return [ "Monitoring phase: \(monitoring.phase.rawValue).", "Visible workloads: \(monitoring.workloads.count).", - "Selection will load session metadata, tool calls, and recent daemon events in place." + "Selection follows the same session when the daemon rekeys a workload after reconnect." ] case .queue: if monitoring.queue.isEmpty { @@ -926,6 +932,12 @@ private struct MonitoringSummaryRow: View { var body: some View { HStack(spacing: 12) { MiniChip(text: snapshot.phase.rawValue.capitalized, tone: phaseTone) + if let poolMode = snapshot.poolMode.nonEmptyValue { + MiniChip(text: "Pool \(poolMode)", tone: ShellPalette.brass) + } + if let spawnPolicy = snapshot.spawnPolicy.nonEmptyValue { + MiniChip(text: "Policy \(spawnPolicy)", tone: ShellPalette.moss) + } MiniChip(text: "Workloads \(snapshot.workloads.count)", tone: ShellPalette.ember) MiniChip(text: "Queue \(snapshot.queue.count)", tone: ShellPalette.moss) Spacer(minLength: 0) @@ -967,6 +979,9 @@ private struct MonitoringWorkloadRow: View { Text(workload.kind == .poolAgent ? "POOL" : "SPAWN") .font(.system(size: 10, weight: .bold, design: .monospaced)) .foregroundStyle(workload.kind == .poolAgent ? ShellPalette.ember : ShellPalette.moss) + Text(workload.workRef) + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .foregroundStyle(ShellPalette.mutedInk) if workload.attentionNeeded { Text("ATTN") .font(.system(size: 10, weight: .bold, design: .monospaced)) @@ -990,7 +1005,8 @@ private struct MonitoringWorkloadRow: View { HStack(spacing: 10) { DetailPill(label: "role", value: workload.role) - DetailPill(label: "session", value: workload.sessionID.nonEmptyValue ?? "pending") + DetailPill(label: "session", value: workload.sessionID.nonEmptyValue ?? "claiming") + DetailPill(label: "pid", value: workload.pid > 0 ? String(workload.pid) : "pending") DetailPill(label: "activity", value: workload.lastActivityAt.map(Self.relativeTimestamp) ?? "waiting") } } @@ -1077,10 +1093,26 @@ private struct SessionsDetailPanel: View { if let detail = snapshot.selectedDetail { VStack(alignment: .leading, spacing: 14) { HStack(spacing: 12) { - DetailPill(label: "work", value: detail.session.workRef.nonEmptyValue ?? detail.workloadID) + DetailPill(label: "work", value: detail.agent.taskID.nonEmptyValue ?? detail.session.workRef.nonEmptyValue ?? detail.workloadID) DetailPill(label: "session", value: detail.session.sessionID.nonEmptyValue ?? "pending") DetailPill(label: "origin", value: detail.session.originType.nonEmptyValue ?? "unknown") - DetailPill(label: "status", value: detail.session.status.nonEmptyValue ?? "unknown") + DetailPill(label: "status", value: detail.agent.lifecycleState.nonEmptyValue ?? detail.session.status.nonEmptyValue ?? "unknown") + } + + HStack(spacing: 12) { + DetailPill(label: "agent", value: detail.workloadID) + DetailPill(label: "pid", value: detail.agent.pid > 0 ? String(detail.agent.pid) : "pending") + DetailPill(label: "server", value: detail.session.serverRef.nonEmptyValue ?? "pending") + DetailPill(label: "attach", value: detail.session.attachable ? "ready" : "pending") + } + + if let lastLog = detail.agent.lastLog.nonEmptyValue { + VStack(alignment: .leading, spacing: 8) { + Text("Latest daemon note") + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .foregroundStyle(ShellPalette.mutedInk) + EventLineRow(line: lastLog, tone: ShellPalette.brass) + } } if !detail.toolCalls.isEmpty { @@ -1122,8 +1154,10 @@ private struct SessionsDetailPanel: View { } } else { MonitoringEmptyRow( - title: snapshot.workloads.isEmpty ? "No session selected" : "Select a workload", - detail: snapshot.note + title: snapshot.workloads.isEmpty ? "No session selected" : "Select a session lane", + detail: snapshot.workloads.isEmpty + ? snapshot.note + : "The detail pane will hold session route, tool activity, and recent daemon events without changing selection." ) } } diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift index cd3e9ff..835f211 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/MonitoringStore.swift @@ -17,11 +17,14 @@ struct MonitoringWorkloadSummary: Identifiable, Equatable, Sendable { let id: String let kind: MonitoringWorkloadKind let role: String + let workRef: String let title: String let subtitle: String let sessionID: String let lifecycleState: String + let pid: Int let attentionNeeded: Bool + let spawnedAt: Date let lastActivityAt: Date? } @@ -34,6 +37,7 @@ struct MonitoringQueueItem: Identifiable, Equatable, Sendable { struct MonitoringSelectionDetail: Equatable, Sendable { let workloadID: String let session: DaemonSessionMetadataPayload + let agent: DaemonAgentStatusPayload let toolCalls: [DaemonToolCallPayload] let eventLines: [String] let lastEventTimestamp: Int64 @@ -74,6 +78,11 @@ struct MonitoringSnapshot: Equatable, Sendable { final class MonitoringStore: ObservableObject { @Published private(set) var snapshot: MonitoringSnapshot + private struct SelectionAnchor: Equatable { + let workloadID: String + let sessionID: String? + } + private let context: ShellBootstrapContext private let controller: DaemonControlling private let isDaemonAbsent: (Error) -> Bool @@ -82,6 +91,7 @@ final class MonitoringStore: ObservableObject { private var monitorTask: Task? private var hasConnected = false private var needsAuthoritativeReload = true + private var selectionAnchor: SelectionAnchor? init( context: ShellBootstrapContext, @@ -132,6 +142,12 @@ final class MonitoringStore: ObservableObject { return } needsAuthoritativeReload = true + selectionAnchor = id.flatMap { workloadID in + guard let workload = snapshot.workloads.first(where: { $0.id == workloadID }) else { + return SelectionAnchor(workloadID: workloadID, sessionID: nil) + } + return SelectionAnchor(workloadID: workloadID, sessionID: workload.sessionID.nonEmptyValue) + } snapshot = MonitoringSnapshot( phase: snapshot.phase, project: snapshot.project, @@ -150,8 +166,8 @@ final class MonitoringStore: ObservableObject { func refresh() async { do { let status = try await controller.fetchStatus(daemonURL: context.daemonURL) - let workloads = buildWorkloads(status: status) - let selectedWorkloadID = resolvedSelectionID(workloads: workloads, preferredID: snapshot.selectedWorkloadID) + let workloads = orderedWorkloads(incoming: buildWorkloads(status: status), previous: snapshot.workloads) + let selectedWorkloadID = resolvedSelectionID(workloads: workloads) var selectedDetail = snapshot.selectedDetail let reconnected = needsAuthoritativeReload || snapshot.phase != .connected @@ -177,6 +193,12 @@ final class MonitoringStore: ObservableObject { lastError: status.errors.last?.nonEmptyValue, updatedAt: .now ) + selectionAnchor = selectedWorkloadID.flatMap { workloadID in + guard let workload = workloads.first(where: { $0.id == workloadID }) else { + return SelectionAnchor(workloadID: workloadID, sessionID: selectedDetail?.session.sessionID.nonEmptyValue) + } + return SelectionAnchor(workloadID: workloadID, sessionID: workload.sessionID.nonEmptyValue) + } hasConnected = true needsAuthoritativeReload = false } catch { @@ -201,19 +223,13 @@ final class MonitoringStore: ObservableObject { afterTimestamp: afterTimestamp ) - let lines: [String] - let lastEventTimestamp: Int64 - if previous == nil { - lines = events.lines - lastEventTimestamp = events.lastTS - } else { - lines = previous!.eventLines + events.lines - lastEventTimestamp = max(previous!.lastEventTimestamp, events.lastTS) - } + let lines = (previous?.eventLines ?? []) + events.lines + let lastEventTimestamp = max(previous?.lastEventTimestamp ?? 0, events.lastTS) return MonitoringSelectionDetail( workloadID: workloadID, session: events.session.sessionID.nonEmptyValue == nil ? detail.session : events.session, + agent: detail.agent, toolCalls: detail.toolCalls, eventLines: lines, lastEventTimestamp: lastEventTimestamp, @@ -247,11 +263,14 @@ final class MonitoringStore: ObservableObject { id: $0.id, kind: .poolAgent, role: $0.role, + workRef: $0.taskID.nonEmptyValue ?? $0.id, title: $0.taskTitle.nonEmptyValue ?? $0.id, subtitle: $0.lastLog.nonEmptyValue ?? $0.taskID.nonEmptyValue ?? "Pool agent", sessionID: $0.sessionID, lifecycleState: $0.lifecycleState.nonEmptyValue ?? $0.state, + pid: $0.pid, attentionNeeded: $0.attentionNeeded, + spawnedAt: $0.spawnTime, lastActivityAt: $0.lastActivityAt ) } @@ -260,25 +279,62 @@ final class MonitoringStore: ObservableObject { id: $0.spawnID, kind: .spawn, role: "spawn", + workRef: $0.spawnID, title: $0.prompt.nonEmptyValue ?? $0.spawnID, - subtitle: $0.state.nonEmptyValue ?? "Spawned session", + subtitle: $0.sessionID.nonEmptyValue ?? ($0.state.nonEmptyValue ?? "Spawned session"), sessionID: $0.sessionID, lifecycleState: $0.lifecycleState.nonEmptyValue ?? $0.state, + pid: $0.pid, attentionNeeded: $0.attentionNeeded, + spawnedAt: $0.spawnTime, lastActivityAt: $0.lastActivityAt ?? $0.exitedAt ) } return agents + spawns } - private func resolvedSelectionID( - workloads: [MonitoringWorkloadSummary], - preferredID: String? - ) -> String? { - if let preferredID, workloads.contains(where: { $0.id == preferredID }) { - return preferredID + private func orderedWorkloads( + incoming: [MonitoringWorkloadSummary], + previous: [MonitoringWorkloadSummary] + ) -> [MonitoringWorkloadSummary] { + let incomingByID = Dictionary(uniqueKeysWithValues: incoming.map { ($0.id, $0) }) + let retained = previous.compactMap { incomingByID[$0.id] } + let retainedIDs = Set(retained.map(\.id)) + let appended = incoming + .filter { !retainedIDs.contains($0.id) } + .sorted(by: compareNewWorkloads) + return retained + appended + } + + private func compareNewWorkloads( + _ lhs: MonitoringWorkloadSummary, + _ rhs: MonitoringWorkloadSummary + ) -> Bool { + if lhs.attentionNeeded != rhs.attentionNeeded { + return lhs.attentionNeeded && !rhs.attentionNeeded + } + if lhs.kind != rhs.kind { + return lhs.kind == .poolAgent + } + if lhs.spawnedAt != rhs.spawnedAt { + return lhs.spawnedAt > rhs.spawnedAt + } + return lhs.id.localizedStandardCompare(rhs.id) == .orderedAscending + } + + private func resolvedSelectionID(workloads: [MonitoringWorkloadSummary]) -> String? { + if let sessionID = selectionAnchor?.sessionID, + let match = workloads.first(where: { $0.sessionID == sessionID }) { + return match.id + } + if let workloadID = selectionAnchor?.workloadID, + workloads.contains(where: { $0.id == workloadID }) { + return workloadID + } + guard let preferred = workloads.first else { + return nil } - return workloads.first?.id + return preferred.id } private func monitoringNote(for status: DaemonStatusPayload, workloadCount: Int) -> String { diff --git a/macos/ControlCenter/Tests/AetherflowControlCenterTests/MonitoringStoreTests.swift b/macos/ControlCenter/Tests/AetherflowControlCenterTests/MonitoringStoreTests.swift index 3c28759..c43e6c4 100644 --- a/macos/ControlCenter/Tests/AetherflowControlCenterTests/MonitoringStoreTests.swift +++ b/macos/ControlCenter/Tests/AetherflowControlCenterTests/MonitoringStoreTests.swift @@ -171,6 +171,97 @@ final class MonitoringStoreTests: XCTestCase { XCTAssertEqual(eventAgentNames, ["agent-1", "spawn-1"]) } + func testSelectionTracksSameSessionWhenWorkloadIDChanges() async throws { + let bootstrap = Self.bootstrap + let controller = RecordingDaemonController( + statusResults: [ + .success(Self.status(taskTitle: "Initial task")), + .success(Self.status(agentID: "agent-2", taskTitle: "Recovered task", sessionID: "ses-1")), + ], + detailResults: [ + .success(Self.detail(agentID: "agent-1", workRef: "ts-c9cdd2", sessionID: "ses-1")), + .success(Self.detail(agentID: "agent-2", workRef: "ts-c9cdd2", sessionID: "ses-1")), + ], + eventResults: [ + .success(Self.events(lines: ["session.created"], sessionID: "ses-1", workRef: "ts-c9cdd2", agentID: "agent-1", lastTS: 101)), + .success(Self.events(lines: ["session.reclaimed"], sessionID: "ses-1", workRef: "ts-c9cdd2", agentID: "agent-2", lastTS: 202)), + ] + ) + let store = MonitoringStore( + context: bootstrap, + controller: controller, + isDaemonAbsent: { _ in false }, + autoStartMonitoring: false + ) + + await store.refresh() + await store.refresh() + + XCTAssertEqual(store.snapshot.selectedWorkloadID, "agent-2") + XCTAssertEqual(store.snapshot.selectedDetail?.workloadID, "agent-2") + XCTAssertEqual(store.snapshot.selectedDetail?.session.sessionID, "ses-1") + + let detailAgentNames = await controller.recordedDetailAgentNames() + XCTAssertEqual(detailAgentNames, ["agent-1", "agent-2"]) + } + + func testRefreshPreservesExistingOrderAndAppendsNewWorkloads() async throws { + let bootstrap = Self.bootstrap + let controller = RecordingDaemonController( + statusResults: [ + .success(Self.statusWithAgentAndSpawn()), + .success(Self.statusWithAdditionalAgent()), + ], + detailResults: [ + .success(Self.detail(agentID: "agent-1", workRef: "ts-c9cdd2", sessionID: "ses-1")), + .success(Self.detail(agentID: "agent-1", workRef: "ts-c9cdd2", sessionID: "ses-1")), + ], + eventResults: [ + .success(Self.events(lines: ["session.created"], sessionID: "ses-1", workRef: "ts-c9cdd2", agentID: "agent-1", lastTS: 101)), + .success(Self.events(lines: ["task.updated"], sessionID: "ses-1", workRef: "ts-c9cdd2", agentID: "agent-1", lastTS: 120)), + ] + ) + let store = MonitoringStore( + context: bootstrap, + controller: controller, + isDaemonAbsent: { _ in false }, + autoStartMonitoring: false + ) + + await store.refresh() + await store.refresh() + + XCTAssertEqual(store.snapshot.workloads.map(\.id), ["agent-1", "spawn-1", "agent-2"]) + } + + func testRefreshOrdersNewWorkloadsByAttentionKindSpawnTimeAndID() async throws { + let bootstrap = Self.bootstrap + let controller = RecordingDaemonController( + statusResults: [ + .success(Self.statusForNewWorkloadOrdering()), + ], + detailResults: [ + .success(Self.detail(agentID: "agent-attn", workRef: "ts-attn", sessionID: "ses-attn")), + ], + eventResults: [ + .success(Self.events(lines: ["session.created"], sessionID: "ses-attn", workRef: "ts-attn", agentID: "agent-attn", lastTS: 101)), + ] + ) + let store = MonitoringStore( + context: bootstrap, + controller: controller, + isDaemonAbsent: { _ in false }, + autoStartMonitoring: false + ) + + await store.refresh() + + XCTAssertEqual( + store.snapshot.workloads.map(\.id), + ["agent-attn", "agent-newer", "agent-older", "spawn-a", "spawn-b"] + ) + } + private static var bootstrap: ShellBootstrapContext { ShellBootstrapContext( projectName: "aetherflow", @@ -180,7 +271,11 @@ final class MonitoringStoreTests: XCTestCase { ) } - private static func status(taskTitle: String) -> DaemonStatusPayload { + private static func status( + agentID: String = "agent-1", + taskTitle: String, + sessionID: String = "ses-1" + ) -> DaemonStatusPayload { DaemonStatusPayload( poolSize: 1, poolMode: "active", @@ -188,14 +283,14 @@ final class MonitoringStoreTests: XCTestCase { spawnPolicy: "manual", agents: [ DaemonAgentStatusPayload( - id: "agent-1", + id: agentID, taskID: "ts-c9cdd2", role: "worker", pid: 42, spawnTime: .now, taskTitle: taskTitle, lastLog: "working", - sessionID: "ses-1", + sessionID: sessionID, state: "running", lifecycleState: "running", lastActivityAt: .now, @@ -210,6 +305,61 @@ final class MonitoringStoreTests: XCTestCase { ) } + private static func statusWithAdditionalAgent() -> DaemonStatusPayload { + DaemonStatusPayload( + poolSize: 2, + poolMode: "active", + project: "aetherflow", + spawnPolicy: "manual", + agents: [ + DaemonAgentStatusPayload( + id: "agent-1", + taskID: "ts-c9cdd2", + role: "worker", + pid: 42, + spawnTime: .now.addingTimeInterval(-120), + taskTitle: "Implement HTTP transport", + lastLog: "working", + sessionID: "ses-1", + state: "running", + lifecycleState: "running", + lastActivityAt: .now, + attentionNeeded: false + ), + DaemonAgentStatusPayload( + id: "agent-2", + taskID: "ts-next", + role: "worker", + pid: 43, + spawnTime: .now, + taskTitle: "Follow-up task", + lastLog: "queued for handoff", + sessionID: "ses-3", + state: "running", + lifecycleState: "running", + lastActivityAt: .now, + attentionNeeded: true + ), + ], + spawns: [ + DaemonSpawnStatusPayload( + spawnID: "spawn-1", + pid: 99, + sessionID: "ses-2", + state: "running", + lifecycleState: "running", + lastActivityAt: .now, + attentionNeeded: false, + prompt: "Manual validation run", + spawnTime: .now.addingTimeInterval(-60), + exitedAt: nil + ) + ], + queue: [], + errors: [] + ) + } + private static func statusWithAgentAndSpawn() -> DaemonStatusPayload { DaemonStatusPayload( poolSize: 1, @@ -251,6 +401,89 @@ final class MonitoringStoreTests: XCTestCase { ) } + private static func statusForNewWorkloadOrdering() -> DaemonStatusPayload { + let baseTime = Date(timeIntervalSince1970: 1_700_000_000) + + return DaemonStatusPayload( + poolSize: 3, + poolMode: "active", + project: "aetherflow", + spawnPolicy: "manual", + agents: [ + DaemonAgentStatusPayload( + id: "agent-attn", + taskID: "ts-attn", + role: "worker", + pid: 0, + spawnTime: baseTime.addingTimeInterval(-300), + taskTitle: "Needs attention", + lastLog: "waiting for reconnect", + sessionID: "ses-attn", + state: "pending", + lifecycleState: "starting", + lastActivityAt: nil, + attentionNeeded: true + ), + DaemonAgentStatusPayload( + id: "agent-newer", + taskID: "ts-newer", + role: "worker", + pid: 41, + spawnTime: baseTime.addingTimeInterval(-60), + taskTitle: "Newest agent", + lastLog: "running", + sessionID: "ses-newer", + state: "running", + lifecycleState: "running", + lastActivityAt: baseTime, + attentionNeeded: false + ), + DaemonAgentStatusPayload( + id: "agent-older", + taskID: "ts-older", + role: "worker", + pid: 40, + spawnTime: baseTime.addingTimeInterval(-120), + taskTitle: "Older agent", + lastLog: "running", + sessionID: "ses-older", + state: "running", + lifecycleState: "running", + lastActivityAt: baseTime.addingTimeInterval(-10), + attentionNeeded: false + ), + ], + spawns: [ + DaemonSpawnStatusPayload( + spawnID: "spawn-b", + pid: 51, + sessionID: "ses-spawn-b", + state: "running", + lifecycleState: "running", + lastActivityAt: baseTime, + attentionNeeded: false, + prompt: "Spawn B", + spawnTime: baseTime.addingTimeInterval(-180), + exitedAt: nil + ), + DaemonSpawnStatusPayload( + spawnID: "spawn-a", + pid: 50, + sessionID: "ses-spawn-a", + state: "running", + lifecycleState: "running", + lastActivityAt: baseTime, + attentionNeeded: false, + prompt: "Spawn A", + spawnTime: baseTime.addingTimeInterval(-180), + exitedAt: nil + ) + ], + queue: [], + errors: [] + ) + } + private static func detail( agentID: String = "agent-1", workRef: String = "ts-c9cdd2",