native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
8.5 kB · 219 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220import Foundation
/// Internal detection facts, separate from public hook and workflow signals.nonisolated enum AgentDetectionEvent: Sendable { case screen(AgentScreenDetection, contentID: Int? = nil) case native(AgentNativeSnapshot) case inventory(Set<String>) case turnStarted(session: String, turn: String) case turnEnded(session: String, turn: String) case childStarted(root: String, child: String, work: String) case childScheduled(root: String, child: String, work: String) case childEnded(root: String, child: String, work: String?) case unavailable case suspended case interaction case tick}
nonisolated enum AgentScreenFallback: String, Sendable { case logUnavailable = "screen.logUnavailable" case ambiguousLogs = "screen.ambiguousLogs" case noLiveTurn = "screen.noLiveTurn" case retainedCompletion = "screen.retainedCompletion" case afterTurn = "screen.afterTurn"}
nonisolated enum AgentStateDecisionReason: Equatable, Sendable { case screen(AgentScreenDetectionReason) case fallback(AgentScreenFallback) case native(AgentRawState) case logOpenWork case logTurnEnded
var identifier: String { switch self { case .screen(let reason): reason.identifier case .fallback(let reason): reason.rawValue case .native(let state): "native.\(state.rawValue)" case .logOpenWork: "log.openWork" case .logTurnEnded: "log.turnEnded" } }}
nonisolated struct AgentStateDecision: Equatable, Sendable { var state: AgentRawState var reason: AgentStateDecisionReason var screenReason: AgentScreenDetectionReason? var logSessionID: String? var hasOutstandingWork = false}
/// Pure transition policy. Time is monotonic seconds supplied by the coordinator.nonisolated struct AgentStateMachine: Sendable { var activityWindow: TimeInterval = 120 private struct Root: Sendable { var turn: String? var children: [String: String] = [:] var lastActivity: TimeInterval? var busy: Bool { turn != nil || !children.isEmpty } }
private var roots: [String: Root] = [:] private var native: AgentNativeSnapshot? private var nativeAvailable = false private var available = false private var hasLogProvider = false private var screen = AgentScreenDetection(state: .unknown, reason: .noRuleMatched) private var stableScreen: AgentRawState = .unknown private var suppressedScreen: AgentScreenDetection? private var screenContentID: Int? private var suppressedContentID: Int? private var suppressedSessionID: String? private(set) var decision = AgentStateDecision(state: .unknown, reason: .screen(.noRuleMatched))
@discardableResult mutating func receive(_ event: AgentDetectionEvent, now: TimeInterval) -> AgentStateDecision { switch event { case .screen(let detection, let contentID): observeScreen(detection, contentID: contentID) case .native(let snapshot): observeNative(snapshot) case .inventory(let sessions): hasLogProvider = true available = true roots = roots.filter { sessions.contains($0.key) } for session in sessions where roots[session] == nil { roots[session] = Root() } case .turnStarted(let session, let turn): if roots[session] != nil, roots[session]?.turn != turn { roots[session]?.turn = turn roots[session]?.lastActivity = now suppressedScreen = nil } case .turnEnded(let session, let turn): if roots[session]?.turn == turn { roots[session]?.turn = nil roots[session]?.lastActivity = now suppressCompletedScreen(session: session) } case .childScheduled(let root, let child, let work): scheduleChild(root: root, child: child, work: work) case .childStarted(let root, let child, let work): roots[root]?.children[child] = work case .childEnded(let root, let child, let work): endChild(root: root, child: child, work: work) case .suspended: nativeAvailable = false hasLogProvider = true available = false case .unavailable: native = nil nativeAvailable = false hasLogProvider = true available = false roots.removeAll() suppressedScreen = nil case .interaction: suppressedScreen = nil case .tick: break } decision = resolve(now: now) decision.screenReason = screen.reason return decision }
private mutating func observeNative(_ snapshot: AgentNativeSnapshot) { if let native, native.sessionID == snapshot.sessionID, snapshot.statusUpdatedAt < native.statusUpdatedAt { return } let changed = native != snapshot // Busy can acknowledge a retained prompt, but cannot dismiss a newly changed blocker. let canFence = screen.state != .blocked || snapshot.state == .blocked || (snapshot.state == .idle && native != nil) || suppressedScreen == screen native = snapshot nativeAvailable = true hasLogProvider = true if changed, canFence { suppressedSessionID = snapshot.sessionID suppressedScreen = screen suppressedContentID = screenContentID } }
private mutating func observeScreen(_ detection: AgentScreenDetection, contentID: Int?) { screen = detection screenContentID = contentID if detection.state != .unknown { stableScreen = detection.state } if suppressedScreen != detection || suppressedContentID != contentID { suppressedScreen = nil } }
private mutating func endChild(root: String, child: String, work: String?) { if let current = roots[root]?.children[child], work == current { roots[root]?.children.removeValue(forKey: child) suppressCompletedScreen(session: root) } }
private mutating func scheduleChild(root: String, child: String, work: String) { if roots[root]?.children[child] == nil { roots[root]?.children[child] = work } }
private mutating func suppressCompletedScreen(session: String) { if decision.logSessionID == session, roots[session]?.busy == false { suppressedSessionID = session suppressedScreen = screen suppressedContentID = screenContentID } }
private func resolve(now: TimeInterval) -> AgentStateDecision { if let native, nativeAvailable { let suppressed = suppressedSessionID == native.sessionID && suppressedScreen == screen let blocked = native.state == .blocked || (screen.state == .blocked && !suppressed) return AgentStateDecision( state: blocked ? .blocked : native.state, reason: blocked && native.state != .blocked ? .screen(screen.reason) : .native(native.state), logSessionID: native.sessionID, hasOutstandingWork: native.state != .idle) }
if let native, native.state == .idle, suppressedSessionID == native.sessionID, suppressedScreen == screen { return AgentStateDecision(state: .idle, reason: .fallback(.retainedCompletion)) } let eligible = roots.filter { _, root in root.busy || root.lastActivity.map { now - $0 < activityWindow } == true } guard available, eligible.count == 1, let (id, root) = eligible.first else { // Expiry removes log authority, but does not make an unchanged completed // frame new evidence. Keep this fence scoped to the sole known root. if roots.count == 1, let id = roots.keys.first, roots[id]?.busy == false, suppressedSessionID == id, suppressedScreen == screen { return AgentStateDecision(state: .idle, reason: .fallback(.retainedCompletion)) } let fallback: AgentScreenFallback = !available ? .logUnavailable : eligible.count > 1 ? .ambiguousLogs : .noLiveTurn return AgentStateDecision( state: stableScreen, reason: hasLogProvider ? .fallback(fallback) : .screen(screen.reason)) } let suppressed = suppressedSessionID == id && suppressedScreen == screen if screen.state == .blocked, !suppressed { return AgentStateDecision( state: .blocked, reason: .screen(screen.reason), logSessionID: id, hasOutstandingWork: root.busy) } if root.busy { return AgentStateDecision(state: .working, reason: .logOpenWork, logSessionID: id, hasOutstandingWork: true) } // A turn end beats its retained frame, but cannot suppress a subsequent UI interaction. if !suppressed, screen.state == .working { return AgentStateDecision(state: .working, reason: .fallback(.afterTurn), logSessionID: id) } return AgentStateDecision(state: .idle, reason: .logTurnEnded, logSessionID: id) }}