From 7d0fbc133aa97846b0382fec229f9a73bab94d1d Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Fri, 31 Jul 2026 22:47:55 +0700 Subject: [PATCH] queen(gHashTag/trios#1107): Let the report interval come from the environment and have the self-test wait for a real report and log its text --- trios/main.swift | 50 ++++ .../rings/SR-02/QueenBackgroundService.swift | 213 ++++++++++++++++++ 2 files changed, 263 insertions(+) diff --git a/trios/main.swift b/trios/main.swift index 70761e695..f012645a9 100644 --- a/trios/main.swift +++ b/trios/main.swift @@ -65,6 +65,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { // self-improvement audit loop. It survives chat switches and panel close. await QueenBackgroundService.shared.start() await runDelegationSelfTestIfRequested() + await runQueenReportSelfTestIfRequested() } } @@ -276,6 +277,55 @@ class AppDelegate: NSObject, NSApplicationDelegate { ) } + /// Wait for one Queen registry report and log its text as a separate + /// event so the self-test run gives a verdict, not silence. + /// + /// The delegation self-test proves the bee flew; this proves the Queen + /// noticed and said something about it. Without it, the report is lost to + /// the chat transcript that nobody reads after the app closes. + /// + /// Set `TRIOS_E2E_QUEEN_REPORT=1` to opt in. The report comes from the + /// same `walkRegistryAndReport()` the loop calls, so the text is exactly + /// what the Queen would say on a timer wake. + @MainActor + private func runQueenReportSelfTestIfRequested() async { + let environment = ProcessInfo.processInfo.environment + guard environment["TRIOS_E2E_QUEEN_REPORT"] != nil else { return } + + TriosLogBus.shared.info( + .queen, + "queen.selftest.report.start", + "Waiting for a Queen registry report", + [:] + ) + + await QueenBackgroundService.shared.walkRegistryAndReport() + + guard let text = QueenBackgroundService.shared.lastReportText else { + TriosLogBus.shared.error( + .queen, + "queen.selftest.report.failed", + "Queen produced no report text", + [:] + ) + return + } + + // Reasonably truncated — enough to see what she said without flooding + // the log. The full text lives in the Queen chat transcript. + let limit = 500 + let truncated = String(text.prefix(limit)) + TriosLogBus.shared.info( + .queen, + "queen.selftest.report", + truncated, + [ + "length": String(text.count), + "truncated": text.count > limit ? "true" : "false" + ] + ) + } + func applicationWillTerminate(_ notification: Notification) { sessionGuard?.stopMonitoring() cladeGuard?.stopMonitoring() diff --git a/trios/rings/SR-02/QueenBackgroundService.swift b/trios/rings/SR-02/QueenBackgroundService.swift index d3758a5b4..1459c22dd 100644 --- a/trios/rings/SR-02/QueenBackgroundService.swift +++ b/trios/rings/SR-02/QueenBackgroundService.swift @@ -32,6 +32,17 @@ final class QueenBackgroundService: ObservableObject { @Published private(set) var lastAudit: QueenAuditEvent? @Published private(set) var proposals: [QueenProposal] = [] + /// How often the Queen wakes, walks the delegation registry, and writes a + /// report to the master chat. Default is 30 minutes (1800 s). Override at + /// launch with the `TRIOS_QUEEN_REPORT_SECONDS` environment variable. + @Published var reportingIntervalSeconds: TimeInterval = { + if let raw = ProcessInfo.processInfo.environment["TRIOS_QUEEN_REPORT_SECONDS"], + let seconds = TimeInterval(raw), seconds > 0 { + return seconds + } + return 30 * 60 + }() + private var queenService: QueenSelfImprovementService? private var a2aClient: A2ARegistryClient? private var persister: ChatPersisterProtocol? @@ -42,6 +53,21 @@ final class QueenBackgroundService: ObservableObject { private let maxA2AReconnectAttempts = 5 private var a2aStreamHealthy = false + /// Background task that wakes the Queen on `reportingIntervalSeconds` and + /// walks the delegation registry to produce a conversational report. + private var reportLoopTask: Task? + + /// Snapshot of the last report's state fingerprint. When a new wake + /// produces the same signature the Queen says so in one line instead of + /// repeating the previous report. + private var lastReportSignature: String? + + /// Text of the most recent registry report — the full prose when the + /// swarm changed, or the "nothing has changed" one-liner when it did + /// not. The self-test reads this to log what the Queen actually said + /// without parsing the chat transcript. + @Published private(set) var lastReportText: String? + weak var delegate: QueenBackgroundServiceDelegate? private init() {} @@ -194,6 +220,7 @@ final class QueenBackgroundService: ObservableObject { await registerA2A() startAuditLoop() + startReportLoop() // Publish initial state so any observing view model is in sync. objectWillChange.send() @@ -204,6 +231,9 @@ final class QueenBackgroundService: ObservableObject { isRunning = false auditLoopTask?.cancel() auditLoopTask = nil + reportLoopTask?.cancel() + reportLoopTask = nil + lastReportSignature = nil a2aStreamTask?.cancel() a2aStreamTask = nil a2aRouter = nil @@ -368,6 +398,189 @@ final class QueenBackgroundService: ObservableObject { } } + // MARK: - Registry report loop + + /// Starts the periodic wake that walks the delegation registry and writes a + /// conversational report to the Queen chat. + /// + /// Separate from the audit loop because they answer different questions: the + /// audit asks "where is the codebase weak?" while this loop asks "what is + /// the swarm doing?" Running them on different cadences lets the Queen + /// report twice as often as she audits — a supervisor checks in often but + /// thinks deeply less frequently. + private func startReportLoop() { + reportLoopTask?.cancel() + reportLoopTask = Task { [weak self, interval = reportingIntervalSeconds] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + guard let self, self.isRunning else { return } + await self.walkRegistryAndReport() + } + } + } + + /// Walks the delegation registry and posts a conversational report to the + /// Queen chat. + /// + /// This is what the acceptance criteria describe: the Queen wakes, looks at + /// every task, and says what she sees — not as a table but as prose that + /// explains what moved, what is stuck, and what she proposes. When the + /// swarm has not changed since the last wake, she says so in one line + /// instead of repeating herself. + func walkRegistryAndReport() async { + let registry = QueenDelegationRegistry.shared + + // Housekeeping before reporting, so the digest describes the swarm + // after reaping rather than before — the same policy the existing + // review scheduler follows. + _ = registry.archiveTerminalTasks() + + let now = Date() + let swarm = registry.open + let stalled = QueenReviewDigest.stalled(swarm, now: now) + let pendingProposals = proposals.filter { $0.status == .pending } + let spentToday = registry.spentToday(now: now) + + // Build a fingerprint of everything that matters. When it matches the + // last report, nothing has changed and the Queen says so in one line. + let signature = Self.registrySignature( + swarm: swarm, proposals: proposals, spentToday: spentToday + ) + if signature == lastReportSignature { + let oneLiner = SystemNoticeClassifier.infoMarker + + "Nothing has changed since my last look — all quiet." + lastReportText = oneLiner + await appendQueenSystemMessage(oneLiner) + return + } + lastReportSignature = signature + + // Generate the conversational digest. Returns nil when nothing is + // running and nothing is waiting — but proposals may still be worth + // mentioning, so we compose around that. + let digest = QueenReviewDigest.text(for: swarm, now: now) + + var report = SystemNoticeClassifier.infoMarker + + if let digest { + report += digest + } else if pendingProposals.isEmpty { + report += "I checked the hive at \(Self.reportTimestamp(now)). " + + "Everything is quiet — no workers running, nothing waiting " + + "for review." + } else { + report += "I checked the hive at \(Self.reportTimestamp(now)). " + + "No workers are running and nothing is waiting for review, " + + "but I have some thoughts about the repository." + } + + if !stalled.isEmpty { + report += "\n\n" + QueenReviewDigest.stallParagraph(stalled, now: now) + } + + // Proposals: the Queen's decisions about how to develop the repository. + // She explains her reasoning, not just the facts — the difference + // between a dashboard and a supervisor. + if let proposalsText = Self.proposalsDigest(pendingProposals) { + report += "\n\n" + proposalsText + } + + if let budgetNote = QueenReviewDigest.budgetParagraph( + spentToday: spentToday, budget: .default + ) { + report += "\n\n" + budgetNote + } + + lastReportText = report + await appendQueenSystemMessage(report) + + TriosLogBus.shared.info( + .queen, + "queen.report.posted", + "Posted a registry report", + [ + "open": String(swarm.count), + "stalled": String(stalled.count), + "proposals": String(pendingProposals.count), + ] + ) + } + + // MARK: - Report helpers + + /// A compact fingerprint of the swarm and proposals, used to detect + /// "nothing changed since last time." + /// + /// Task identity is slug + state (not `updatedAt`, which changes on every + /// usage record and would make the signature thrash). Proposal identity is + /// id + status. Spend is rounded so a penny's difference does not count as + /// movement. + nonisolated static func registrySignature( + swarm: [DelegatedTask], + proposals: [QueenProposal], + spentToday: Double + ) -> String { + let taskPart = swarm + .map { "\($0.issue.slug):\($0.state.rawValue)" } + .sorted() + .joined(separator: "|") + let proposalPart = proposals + .map { "\($0.id.uuidString.prefix(8)):\($0.status.rawValue)" } + .sorted() + .joined(separator: "|") + let spendBucket = String(Int(spentToday * 100)) + return "\(taskPart)##\(proposalPart)##\(spendBucket)" + } + + /// Conversational text about pending improvement proposals. + /// + /// The Queen explains *why* she thinks each change matters, not just what + /// she would change — criterion 3. Returns nil when there are no pending + /// proposals, so callers can skip the paragraph cleanly. + nonisolated static func proposalsDigest( + _ proposals: [QueenProposal] + ) -> String? { + let pending = proposals.filter { $0.status == .pending } + guard !pending.isEmpty else { return nil } + + var lines: [String] = [] + if pending.count == 1, let p = pending.first { + lines.append( + "I have a proposal for the repository: \(p.rationale) " + + "I would change `\(p.targetFile)` — say " + + "`/evolve-apply \(p.id.uuidString.prefix(8))` if you " + + "agree, or `/evolve-reject \(p.id.uuidString.prefix(8))` " + + "if you do not." + ) + } else { + lines.append( + "I have \(pending.count) proposals for how to develop the " + + "repository:" + ) + for p in pending { + lines.append( + " - \(p.rationale) I would touch " + + "`\(p.targetFile)`." + ) + } + lines.append( + "Use `/evolve-apply ` to approve any of them, or " + + "`/evolve-list` to see the full patches." + ) + } + return lines.joined(separator: "\n") + } + + nonisolated static func reportTimestamp(_ date: Date) -> String { + Self.reportTimeFormatter.string(from: date) + } + + private nonisolated static let reportTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter + }() + // MARK: - Audit loop private func startAuditLoop() {