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
3 changes: 2 additions & 1 deletion CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28535,7 +28535,7 @@ struct CMUXCLI {
// Pending background work keeps the pane out of the
// hibernatable .idle state so the planner cannot SIGTERM
// a live task (mirrors the antigravity fullyIdle flip).
agentLifecycle: stopFailure != nil ? .needsInput : (hasUnsettledWork ? .running : .idle),
agentLifecycle: stopFailure != nil ? .needsInput : (hasUnsettledWork ? .backgroundWorkPending : .idle),
hookEventName: reportedHookEventName(from: parsedInput) ?? "Stop",
lastSubtitle: completion?.subtitle,
lastBody: completion?.body,
Expand Down Expand Up @@ -36641,6 +36641,7 @@ export default CMUXSessionRestore;
case nil:
switch latest.agentLifecycle {
case .running?: correctedPhase = .running
case .backgroundWorkPending?: correctedPhase = .backgroundWorkPending
case .idle?: correctedPhase = .idle
case .needsInput?: correctedPhase = .needsInput
case .unknown?: correctedPhase = .unknown
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public struct AgentJournalReplayPolicy: Sendable {
phases[surfaceId, default: [:]][agentKey] = phase
newest[surfaceId, default: [:]][agentKey] =
snapshot.newestOccurredAtMs[surfaceId]?[agentKey] ?? 0
case .running, .idle, .unknown:
case .running, .backgroundWorkPending, .idle, .unknown:
continue
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@ public enum AgentLifecycleActivity: String, Sendable, Equatable, CaseIterable {
switch current {
case .idle:
return previous == .running ? .turnFinished : nil
case .backgroundWorkPending:
return nil
case .needsInput:
return .needsInput
case .error:
return .error
case .running:
switch previous {
case nil, .unknown, .idle: return .promptSubmitted
case nil, .unknown, .idle, .backgroundWorkPending: return .promptSubmitted
case .needsInput, .error, .running: return nil
}
case .unknown:
Expand Down Expand Up @@ -78,7 +80,7 @@ public enum AgentLifecycleActivity: String, Sendable, Equatable, CaseIterable {
// attentionResolved does.
guard current == .running else { return nil }
switch previous {
case nil, .unknown, .idle, .error: return .promptSubmitted
case nil, .unknown, .idle, .backgroundWorkPending, .error: return .promptSubmitted
case .needsInput, .running: return nil
}
default:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ public enum AgentLifecyclePhase: String, Codable, Sendable, CaseIterable, Equata
case unknown
/// The agent is actively working on a turn.
case running
/// The last turn completed while background work remains active.
case backgroundWorkPending
/// The agent is blocked on the user (approval, question, or plan review).
case needsInput
/// The last turn completed and nothing is pending.
Expand All @@ -19,15 +21,16 @@ public enum AgentLifecyclePhase: String, Codable, Sendable, CaseIterable, Equata
/// Precedence used when several sessions of the same agent share one
/// surface: the surface shows the most demanding live session.
///
/// Order (most to least demanding): `running` > `needsInput` > `error` >
/// Order (most to least demanding): `running` > `backgroundWorkPending` > `needsInput` > `error` >
/// `unknown` > `idle`.
public var combinePrecedence: Int {
switch self {
case .running: 5
case .needsInput: 4
case .error: 3
case .unknown: 2
case .idle: 1
case .backgroundWorkPending: 4
case .needsInput: 3
case .error: 2
case .unknown: 1
case .idle: 0
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,14 @@ public struct AgentLifecycleReducer: Sendable {
case .sessionStarted:
return (.unknown, false)
case .turnStarted:
// A new turn is active even when the session still reports work
// from an earlier watcher or background task. Pending work only
// changes the completed-turn projection below.
return (.running, false)
case .attentionResolved:
return (draft.declaredPhase ?? (draft.pendingWork ? .running : .idle), false)
return (draft.declaredPhase ?? (draft.pendingWork ? .backgroundWorkPending : .idle), false)
case .turnCompleted, .idleObserved:
return (draft.pendingWork ? .running : .idle, false)
return (draft.pendingWork ? .backgroundWorkPending : .idle, false)
case .approvalRequested, .questionRequested, .planReviewRequested:
return (.needsInput, false)
case .errorReported:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,17 @@ struct AgentLifecycleReducerTests {
#expect(state.combinedPhase(surfaceId: surface, agentKey: "claude_code") == .error)
}

@Test func pendingWorkKeepsTurnCompletedRunning() {
@Test func pendingWorkUsesBackgroundPhase() {
let state = fold([event(1, .turnStarted), event(2, .turnCompleted, pendingWork: true)])
#expect(state.combinedPhase(surfaceId: surface, agentKey: "claude_code") == .backgroundWorkPending)
}

@Test func newTurnRemainsRunningWhileBackgroundWorkIsPending() {
let state = fold([
event(1, .turnStarted),
event(2, .turnCompleted, pendingWork: true),
event(3, .turnStarted, pendingWork: true),
])
#expect(state.combinedPhase(surfaceId: surface, agentKey: "claude_code") == .running)
}

Expand Down
6 changes: 6 additions & 0 deletions Sources/AgentHibernation/AgentHibernationLifecycleState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Foundation
enum AgentHibernationLifecycleState: String, Codable, Sendable, Equatable, CaseIterable {
case unknown
case running
case backgroundWorkPending
case idle
case needsInput

Expand Down Expand Up @@ -36,6 +37,7 @@ enum AgentHibernationLifecycleState: String, Codable, Sendable, Equatable, CaseI
return fallback ?? .unknown
}
if states.contains(.running) { return .running }
if states.contains(.backgroundWorkPending) { return .backgroundWorkPending }
if states.contains(.needsInput) { return .needsInput }
if states.contains(.unknown) { return .unknown }
if states.contains(.idle) { return .idle }
Expand All @@ -59,6 +61,8 @@ enum AgentHibernationLifecycleState: String, Codable, Sendable, Equatable, CaseI
switch state {
case .running:
return .running
case .backgroundWorkPending:
hasUnknown = true
case .needsInput:
hasNeedsInput = true
case .unknown:
Expand All @@ -84,6 +88,8 @@ enum AgentHibernationLifecycleState: String, Codable, Sendable, Equatable, CaseI
return .unknown
case "running":
return .running
case "backgroundworkpending", "background-work-pending":
return .backgroundWorkPending
case "idle":
return .idle
case "needsinput", "needs-input":
Expand Down
1 change: 1 addition & 0 deletions Sources/AgentJournalLifecycleCenter+Projection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ extension AgentJournalLifecycleCenter {
switch phase {
case .unknown: .unknown
case .running: .running
case .backgroundWorkPending: .backgroundWorkPending
case .needsInput: .needsInput
case .idle: .idle
case .error: .needsInput
Expand Down
13 changes: 8 additions & 5 deletions Sources/Sidebar/SidebarCompactStatusGlyph+Resolve.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ extension SidebarCompactStatusGlyph {
let kind: Kind
if input.agentEntries.contains(where: Self.reportsError) {
kind = .error
} else if input.hasActiveAgent || input.lifecycleStates.contains(.running) || input.lifecycleStates.contains(.backgroundWorkPending) {
kind = .running
} else if input.lifecycleStates.contains(.needsInput) {
kind = .needsInput
} else if input.hasActiveAgent || input.lifecycleStates.contains(.running) {
kind = .running
} else if input.lifecycleStates.contains(.unknown) {
kind = .pending
// A stale pull request is data repeated refresh failures could not
Expand Down Expand Up @@ -95,12 +95,15 @@ extension SidebarCompactStatusGlyph {
}

private static func lifecycleText(_ states: [AgentHibernationLifecycleState]) -> String? {
if states.contains(.needsInput) {
return String(localized: "feed.status.needsInput", defaultValue: "Needs input")
}
if states.contains(.running) {
return String(localized: "agent.generic.status.running", defaultValue: "Running")
}
if states.contains(.backgroundWorkPending) {
return String(localized: "agent.generic.notification.subtitle.waiting", defaultValue: "Waiting")
}
if states.contains(.needsInput) {
return String(localized: "feed.status.needsInput", defaultValue: "Needs input")
}
if states.contains(.idle) {
return String(localized: "agentSession.web.status.idle", defaultValue: "Idle")
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/SidebarAgentActivitySummary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ enum SidebarAgentActivitySummary {
statesByPanelId: [UUID: [String: AgentHibernationLifecycleState]]
) -> Int {
statesByPanelId.values.reduce(0) { partial, panelStates in
partial + panelStates.values.reduce(0) { $1 == .running ? $0 + 1 : $0 }
partial + panelStates.values.reduce(0) { ($1 == .running || $1 == .backgroundWorkPending) ? $0 + 1 : $0 }
}
}
}
13 changes: 10 additions & 3 deletions Sources/Workspace+SidebarStatusVisibility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,15 +89,22 @@ extension Workspace {
return agentPIDKeysByPanelId[panelId]?.contains { agentStatusKey(forAgentPIDKey: $0) == key } ?? false
}

private static func sidebarStatusUrgencyRank(_ lifecycle: AgentHibernationLifecycleState?) -> Int {
static func sidebarStatusUrgencyRank(_ lifecycle: AgentHibernationLifecycleState?) -> Int {
switch lifecycle {
case .needsInput: 3
case .running: 2
case .running: 4
case .backgroundWorkPending: 3
case .needsInput: 2
case .unknown, nil: 1
case .idle: 0
}
}

func sidebarStatusUrgencyRank(forKey key: String) -> Int {
agentLifecycleStatesByPanelId.values
.compactMap { Self.sidebarStatusUrgencyRank($0[key]) }
.max() ?? 0
}

private func shouldDisplaySidebarStatusEntry(
_ entry: SidebarStatusEntry,
visibleStructuredStatusKeys: Set<String>
Expand Down
6 changes: 3 additions & 3 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6731,11 +6731,11 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos
}

func sidebarStatusEntriesInDisplayOrder() -> [SidebarStatusEntry] {
let keysNeedingInput = sidebarStatusKeysNeedingInput()
return sidebarStatusEntriesVisibleForDisplay().sorted { lhs, rhs in
if lhs.priority != rhs.priority { return lhs.priority > rhs.priority }
let lhsNeedsInput = keysNeedingInput.contains(lhs.key)
if lhsNeedsInput != keysNeedingInput.contains(rhs.key) { return lhsNeedsInput }
let lhsUrgency = sidebarStatusUrgencyRank(forKey: lhs.key)
let rhsUrgency = sidebarStatusUrgencyRank(forKey: rhs.key)
if lhsUrgency != rhsUrgency { return lhsUrgency > rhsUrgency }
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
return lhs.key < rhs.key
}
Expand Down
5 changes: 2 additions & 3 deletions cmuxTests/SidebarCompactAgentStatusTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,10 @@ struct SidebarCompactAgentStatusTests {
}

@Test
func needsInputIsAYellowDotAboveRunningAndPullRequests() {
func needsInputIsAYellowDotAbovePullRequestsWhenNoActiveWork() {
let glyph = Glyph.resolve(.init(
agentEntries: [Self.entry("claude_code", "Needs input", icon: "bell.fill")],
lifecycleStates: [.running, .needsInput],
hasActiveAgent: true,
lifecycleStates: [.needsInput],
pullRequests: [Self.openPR],
branch: "main"
))
Expand Down
14 changes: 8 additions & 6 deletions cmuxTests/WorkspaceSidebarObservationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,9 @@ struct WorkspaceSidebarObservationTests {

/// Two Claude panes share the `claude_code` key (and its one PID, held by
/// the pane that started last). The row is a workspace aggregate: the pane
/// waiting on the person wins over a pane that reported Running later.
/// An active turn wins over a pane waiting on the person, even when its
/// status report is newer. Lifecycle urgency is shared with the compact
/// glyph: running, background work, then needs input.
@Test func sharedAgentStatusKeyShowsTheMostUrgentPane() throws {
let workspace = Workspace()
let waitingPanelId = try #require(workspace.focusedPanelId)
Expand All @@ -444,8 +446,8 @@ struct WorkspaceSidebarObservationTests {
workspace.setAgentLifecycle(key: "claude_code", panelId: runningPanelId, lifecycle: .running)

#expect(
workspace.sidebarStatusEntriesInDisplayOrder().first { $0.key == "claude_code" }?.value == "Needs input",
"A later Running report from another pane must not hide the pane waiting on the person."
workspace.sidebarStatusEntriesInDisplayOrder().first { $0.key == "claude_code" }?.value == "Running",
"An active turn must outrank a pane waiting on the person."
)

_ = workspace.clearAgentLifecycle(key: "claude_code", panelId: waitingPanelId)
Expand All @@ -455,8 +457,8 @@ struct WorkspaceSidebarObservationTests {
)
}

/// Across different agents of the same priority, one waiting on the person
/// sorts first even when another reported more recently.
/// Across different agents, an active turn sorts first even when another
/// agent is waiting on the person and reported more recently.
@Test func needsInputStatusSortsAheadOfNewerEntries() throws {
let workspace = Workspace()
let codexPanelId = try #require(workspace.focusedPanelId)
Expand All @@ -474,7 +476,7 @@ struct WorkspaceSidebarObservationTests {
workspace.setAgentLifecycle(key: "claude_code", panelId: claudePanelId, lifecycle: .needsInput)
workspace.setAgentLifecycle(key: "codex", panelId: codexPanelId, lifecycle: .running)

#expect(workspace.sidebarStatusEntriesInDisplayOrder().map(\.key) == ["claude_code", "codex"])
#expect(workspace.sidebarStatusEntriesInDisplayOrder().map(\.key) == ["codex", "claude_code"])
}
}

Expand Down
Loading