[object Object]

← back to Ticket System

desktop-bar: stop re-parsing the 70MB ticket ledger every 4s (TK-11892 ~100% CPU)

e983cf75aaad699e0fe900ec256f6331fd936bfd · 2026-09-18 08:29:06 -0700 · Steve Abrams

refresh() loaded ~/.claude/tickets/events.jsonl (70MB / 540k lines) into a String,
Character-split it and JSON-parsed every line on a 4s timer with no in-flight guard,
so parses stacked and pinned a core for 25h+; it ALSO fetched the unfiltered 22MB
/api/tickets and spawned refreshSystem()'s top/pgrep/curl every 4s.

Now: one GET of the board's materialized cache (?status=doing,blocked,open&fields=summary,
~92KB/4ms) for counts/lists/rank; Latest via a bounded 64KB tail-read; main-thread
in-flight guard; refreshSystem stays on its own 15s timer; 3-strike 'board unreachable'
and 'ledger unavailable' signals (never stale-as-fresh); generation counter drops stale
tail-read completions; TICKETBAR_LEDGER test seam. Measured: 2:09 CPU per 3:02 -> 0.58s
per 120s (~0.5%). README: rebuilt binary must be bootout+bootstrap'd, never kickstart -k.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q72cEwhtCyUiMbuX8DeKKb

Files touched

Diff

commit e983cf75aaad699e0fe900ec256f6331fd936bfd
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 18 08:29:06 2026 -0700

    desktop-bar: stop re-parsing the 70MB ticket ledger every 4s (TK-11892 ~100% CPU)
    
    refresh() loaded ~/.claude/tickets/events.jsonl (70MB / 540k lines) into a String,
    Character-split it and JSON-parsed every line on a 4s timer with no in-flight guard,
    so parses stacked and pinned a core for 25h+; it ALSO fetched the unfiltered 22MB
    /api/tickets and spawned refreshSystem()'s top/pgrep/curl every 4s.
    
    Now: one GET of the board's materialized cache (?status=doing,blocked,open&fields=summary,
    ~92KB/4ms) for counts/lists/rank; Latest via a bounded 64KB tail-read; main-thread
    in-flight guard; refreshSystem stays on its own 15s timer; 3-strike 'board unreachable'
    and 'ledger unavailable' signals (never stale-as-fresh); generation counter drops stale
    tail-read completions; TICKETBAR_LEDGER test seam. Measured: 2:09 CPU per 3:02 -> 0.58s
    per 120s (~0.5%). README: rebuilt binary must be bootout+bootstrap'd, never kickstart -k.
    
    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01Q72cEwhtCyUiMbuX8DeKKb
---
 desktop-bar/README.md       |  23 +++++
 desktop-bar/TicketBar.swift | 211 +++++++++++++++++++++++++++-----------------
 2 files changed, 154 insertions(+), 80 deletions(-)

diff --git a/desktop-bar/README.md b/desktop-bar/README.md
index ad930fda..fa290afc 100644
--- a/desktop-bar/README.md
+++ b/desktop-bar/README.md
@@ -11,6 +11,29 @@ terminal processes responsive.
 
 Build with `./build.sh`. The launch agent uses the compiled binary in `bin/`.
 
+**After a rebuild, RELOAD the job — do not `kickstart -k`** (TK-11892). launchd/BTM
+registered the service against the old executable's CDHash; re-exec'ing the replaced
+file under that registration dies 10 ms in with `Launch Constraint Violation` and
+`KeepAlive` stalls. Use:
+
+    launchctl bootout gui/$(id -u)/com.steve.ticket-desktop-bar
+    launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.ticket-desktop-bar.plist
+
+then confirm `launchctl print gui/$(id -u)/com.steve.ticket-desktop-bar | grep -E 'runs =|last exit'`
+shows `runs = 1` / `never exited`.
+
+## Data sources (TK-11892 — never re-parse the ledger)
+
+- Counts / lists / rank+priority come from ONE filtered GET of the board's materialized
+  cache: `http://127.0.0.1:9794/api/tickets?status=doing,blocked,open&fields=summary`
+  (~92 KB / ~4 ms). The bar used to re-parse the whole `~/.claude/tickets/events.jsonl`
+  (70 MB, 540k lines) in Swift every 4 s with no in-flight guard, which pinned a core
+  for 25 h+. If the board is unreachable for 3 consecutive ticks the tiles show `—` and
+  `Working · board unreachable` — never stale counts as fresh, never a ledger fallback.
+- `Latest ·` is a bounded 64 KB tail-read of the ledger (newest action/comment/status
+  event fleet-wide, done tickets included), independent of the board.
+- System stats (`CPU`, `SWAP`, ollama reachability) run on their own 15 s timer only.
+
 ## Layout controls
 
 - Click any ticket tile to open its detail dropdown.
diff --git a/desktop-bar/TicketBar.swift b/desktop-bar/TicketBar.swift
index 1591200f..7d645888 100644
--- a/desktop-bar/TicketBar.swift
+++ b/desktop-bar/TicketBar.swift
@@ -1,18 +1,12 @@
 import AppKit
 import WebKit
 
-private let ledgerURL = FileManager.default.homeDirectoryForCurrentUser
-  .appendingPathComponent(".claude/tickets/events.jsonl")
+// TICKETBAR_LEDGER is a TEST seam only (point a throwaway instance at a missing/garbage file
+// to prove the "ledger unavailable" path goes red) — the launchd plist must never set it.
+private let ledgerURL = ProcessInfo.processInfo.environment["TICKETBAR_LEDGER"].map { URL(fileURLWithPath: $0) }
+  ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".claude/tickets/events.jsonl")
 private let viewerURL = URL(string: "http://127.0.0.1:9794/?section=all&layout=grid")!
 
-private struct TicketState {
-  var id = ""
-  var title = ""
-  var status = "open"
-  var updated = ""
-  var created = ""
-}
-
 private final class BarTile: NSButton {
   let key: String
   let label: NSTextField
@@ -104,6 +98,11 @@ private final class BarController: NSObject, NSApplicationDelegate, NSWindowDele
   private var ticketPopup: NSWindow?
   private var timer: Timer?
   private var systemTimer: Timer?
+  // TK-11892 — all three are MAIN-THREAD ONLY (set/cleared inside DispatchQueue.main hops).
+  private var refreshInFlight = false   // a slow board tick must never stack on the next one
+  private var boardFailStreak = 0       // consecutive board-fetch failures; reset on any success
+  private var ledgerFailStreak = 0      // consecutive tail-read misses; reset on any success
+  private var latestGeneration = 0      // tags each tail-read so a stale completion can't overwrite a newer one
   private var dragMonitor: Any?
   private var draggingKey: String?
   private var dragStartX: CGFloat?
@@ -1045,86 +1044,138 @@ private final class BarController: NSObject, NSApplicationDelegate, NSWindowDele
     return "🕓 " + stampFmt.string(from: d)
   }
 
-  // Best-effort pull of server-computed rank/priority from the viewer API.
-  // Empty (no priority shown) if the viewer is unreachable — counts still work.
-  private func fetchPriorities() -> [String: (rank: Int?, priority: Double)] {
-    guard let url = URL(string: "http://127.0.0.1:9794/api/tickets") else { return [:] }
-    var req = URLRequest(url: url, timeoutInterval: 2.5)
-    req.setValue("Basic " + Data("admin:DW2024!".utf8).base64EncodedString(), forHTTPHeaderField: "Authorization")
-    var result: [String: (Int?, Double)] = [:]
-    let sem = DispatchSemaphore(value: 0)
-    URLSession.shared.dataTask(with: req) { data, _, _ in
-      defer { sem.signal() }
-      guard let data, let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return }
-      for t in arr {
-        guard let id = t["id"] as? String else { continue }
-        result[id] = (t["rank"] as? Int, (t["priority"] as? NSNumber)?.doubleValue ?? 0)
-      }
+  // ── Ticket state (TK-11892) ────────────────────────────────────────────────
+  // The board (server.js, TK-11506) already keeps a materialized reduce of the ledger keyed
+  // on events.jsonl size+mtime; this filtered summary is ~92KB / ~4ms for the ~40 active
+  // tickets. The bar used to re-parse the whole 70MB / 540k-line ledger in Swift every 4s
+  // (Character-indexed split + JSON per line, no in-flight guard) ON TOP of an unfiltered
+  // 22MB GET here — parses stacked and pinned a core for 25h+. Never re-parse the ledger.
+  private let boardTicketsURL = URL(string: "http://127.0.0.1:9794/api/tickets?status=doing,blocked,open&fields=summary")!
+  private let boardAuth = "Basic " + Data("admin:DW2024!".utf8).base64EncodedString()
+
+  private struct BoardTicket {
+    let id: String, title: String, status: String, updated: String, created: String
+    let rank: Int?, priority: Double
+  }
+
+  // One GET, no semaphore: the completion already runs off-main; callers hop to main for UI.
+  // nil = board unreachable / non-200 / unparseable (the caller decides what to show).
+  private func fetchBoardTickets(_ done: @escaping ([BoardTicket]?) -> Void) {
+    var req = URLRequest(url: boardTicketsURL, timeoutInterval: 2.5)
+    req.setValue(boardAuth, forHTTPHeaderField: "Authorization")
+    URLSession.shared.dataTask(with: req) { data, resp, _ in
+      guard let data, (resp as? HTTPURLResponse)?.statusCode == 200,
+            let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return done(nil) }
+      done(arr.compactMap { t in
+        guard let id = t["id"] as? String, id.hasPrefix("TK-") else { return nil }
+        return BoardTicket(id: id, title: t["title"] as? String ?? id, status: t["status"] as? String ?? "open",
+                           updated: t["updated_at"] as? String ?? "", created: t["created_at"] as? String ?? "",
+                           rank: t["rank"] as? Int, priority: (t["priority"] as? NSNumber)?.doubleValue ?? 0)
+      })
     }.resume()
-    _ = sem.wait(timeout: .now() + 3)
-    return result
+  }
+
+  // "Latest ·" = the newest action/comment/status event ledger-wide (done tickets included),
+  // read from the LAST 64KB of the file only — O(1) regardless of ledger size and independent
+  // of the board. Advances past the first newline before splitting so a window that starts
+  // mid-line / mid-codepoint can't poison the parse. Returns nil when no complete qualifying
+  // line is in the window (a giant line, truncation, unreadable file) — caller keeps last-good.
+  private func readLatestEvent() -> [String: Any]? {
+    guard let fh = try? FileHandle(forReadingFrom: ledgerURL) else { return nil }
+    defer { try? fh.close() }
+    guard let end = try? fh.seekToEnd() else { return nil }
+    let window: UInt64 = 64 * 1024
+    let start = end > window ? end - window : 0
+    guard (try? fh.seek(toOffset: start)) != nil, var data = try? fh.readToEnd(), !data.isEmpty else { return nil }
+    if start > 0, let nl = data.firstIndex(of: 0x0A) { data = data[data.index(after: nl)...] }
+    for line in data.split(separator: 0x0A).reversed() {
+      guard let ev = try? JSONSerialization.jsonObject(with: line) as? [String: Any],
+            let id = ev["id"] as? String, id.hasPrefix("TK-"),
+            let type = ev["type"] as? String, ["action", "comment", "status"].contains(type) else { continue }
+      return ev
+    }
+    return nil
   }
 
   private func refresh() {
-    refreshSystem()
+    // In-flight guard (main thread only): if the previous tick hasn't completed, skip this one.
+    guard !refreshInFlight else { NSLog("TicketBar refresh skipped: in flight"); return }
+    refreshInFlight = true
+
+    // Latest line — cheap bounded file read, off-main, never tied to board reachability.
+    // Same never-stale-as-fresh rule as the board path: a miss is counted, and after 3
+    // consecutive misses the label says so instead of silently freezing on old text.
+    latestGeneration &+= 1
+    let gen = latestGeneration
     DispatchQueue.global(qos: .utility).async { [weak self] in
       guard let self else { return }
-      do {
-        let text = try String(contentsOf: ledgerURL, encoding: .utf8)
-        var states: [String: TicketState] = [:]
-        var newest: [String: Any]?
-        for line in text.split(separator: "\n") {
-          guard let data = line.data(using: .utf8),
-                let event = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
-                let id = event["id"] as? String, id.hasPrefix("TK-") else { continue }
-          let type = event["type"] as? String ?? ""
-          let timestamp = event["ts"] as? String ?? ""
-          if type == "create" {
-            states[id] = TicketState(id: id, title: event["title"] as? String ?? id, status: "open", updated: timestamp, created: timestamp)
-          } else if var ticket = states[id] {
-            if type == "status", let status = event["status"] as? String { ticket.status = status }
-            ticket.updated = timestamp
-            states[id] = ticket
-          }
-          if type == "action" || type == "comment" || type == "status" { newest = event }
-        }
-        let active = states.values.filter { $0.status != "done" && $0.status != "stopped" }
-        let prios = self.fetchPriorities()   // best-effort; empty if viewer down
-        // Highest priority first (lowest rank #); un-ranked fall to the bottom, newest first.
-        let byPrio: (TicketState, TicketState) -> Bool = { a, b in
-          let ra = prios[a.id]?.rank ?? Int.max, rb = prios[b.id]?.rank ?? Int.max
-          if ra != rb { return ra < rb }
-          return a.updated > b.updated
-        }
-        let openList = active.filter { $0.status == "open" }.sorted(by: byPrio)
-        let blockedList = active.filter { $0.status == "blocked" }.sorted(by: byPrio)
-        let doing = active.filter { $0.status == "doing" }.sorted(by: byPrio)
-        let workText = doing.prefix(3).map { "\(self.shortID($0.id)) \(self.fiveWords($0.title))" }.joined(separator: "  •  ")
-        let latestText = self.describe(newest)
-        // Row: TK-xxxx  #rank  p<priority>  · title   🕓 created date/time
-        let row: (TicketState) -> String = { t in
-          var prefix = self.shortID(t.id)
-          if let p = prios[t.id] {
-            if let r = p.rank { prefix += "  #\(r)" }
-            if p.priority > 0 { prefix += String(format: "  p%.1f", p.priority) }
+      let ev = self.readLatestEvent()
+      DispatchQueue.main.async {
+        guard gen == self.latestGeneration else { return }   // a newer read was issued; drop this one
+        if let ev {
+          self.ledgerFailStreak = 0
+          let text = self.describe(ev)
+          self.latest.stringValue = "Latest · \(text)"
+          self.latestDetail = text
+        } else {
+          self.ledgerFailStreak += 1
+          if self.ledgerFailStreak >= 3 {
+            self.latest.stringValue = "Latest · ledger unavailable"
+            self.latestDetail = "ledger unavailable"
           }
-          let when = self.stamp(t.created)
-          return when.isEmpty ? "\(prefix) · \(t.title)" : "\(prefix) · \(t.title)   \(when)"
         }
+      }
+    }
+
+    fetchBoardTickets { [weak self] tickets in
+      // `self` is the process-lifetime app delegate, so this guard is unreachable in practice;
+      // if it ever fired it would leave refreshInFlight stuck — the app is exiting anyway.
+      guard let self else { return }
+      guard let tickets else {
         DispatchQueue.main.async {
-          self.openLabel.stringValue    = "OPEN \(openList.count)"
-          self.blockedLabel.stringValue = "BLOCKED \(blockedList.count)"
-          self.doingLabel.stringValue   = "DOING \(doing.count)"
-          self.statusDetails = ["open": openList.map(row), "blocked": blockedList.map(row), "doing": doing.map(row)]
-          self.working.stringValue = "Working · \(workText.isEmpty ? "none" : workText)"
-          self.latest.stringValue = "Latest · \(latestText)"
-          self.workingDetails = doing.map(row)
-          self.latestDetail = latestText
-          self.panel.orderFrontRegardless()
-          if let popup = self.ticketPopup, !popup.isMiniaturized { popup.orderFrontRegardless() }
+          self.refreshInFlight = false
+          self.boardFailStreak += 1
+          // Never show stale counts as fresh: after ~12s of consecutive failures, say so.
+          if self.boardFailStreak >= 3 {
+            self.openLabel.stringValue    = "OPEN —"
+            self.blockedLabel.stringValue = "BLOCKED —"
+            self.doingLabel.stringValue   = "DOING —"
+            self.working.stringValue = "Working · board unreachable"
+            self.statusDetails = [:]
+            self.workingDetails = []
+          }
         }
-      } catch {
-        DispatchQueue.main.async { self.latest.stringValue = "Latest · ledger unavailable" }
+        return
+      }
+      // Highest priority first (lowest rank #); un-ranked fall to the bottom, newest first.
+      let byPrio: (BoardTicket, BoardTicket) -> Bool = { a, b in
+        let ra = a.rank ?? Int.max, rb = b.rank ?? Int.max
+        if ra != rb { return ra < rb }
+        return a.updated > b.updated
+      }
+      let openList = tickets.filter { $0.status == "open" }.sorted(by: byPrio)
+      let blockedList = tickets.filter { $0.status == "blocked" }.sorted(by: byPrio)
+      let doing = tickets.filter { $0.status == "doing" }.sorted(by: byPrio)
+      let workText = doing.prefix(3).map { "\(self.shortID($0.id)) \(self.fiveWords($0.title))" }.joined(separator: "  •  ")
+      // Row: TK-xxxx  #rank  p<priority>  · title   🕓 created date/time
+      let row: (BoardTicket) -> String = { t in
+        var prefix = self.shortID(t.id)
+        if let r = t.rank { prefix += "  #\(r)" }
+        if t.priority > 0 { prefix += String(format: "  p%.1f", t.priority) }
+        let when = self.stamp(t.created)
+        return when.isEmpty ? "\(prefix) · \(t.title)" : "\(prefix) · \(t.title)   \(when)"
+      }
+      DispatchQueue.main.async {
+        self.refreshInFlight = false
+        self.boardFailStreak = 0
+        self.openLabel.stringValue    = "OPEN \(openList.count)"
+        self.blockedLabel.stringValue = "BLOCKED \(blockedList.count)"
+        self.doingLabel.stringValue   = "DOING \(doing.count)"
+        self.statusDetails = ["open": openList.map(row), "blocked": blockedList.map(row), "doing": doing.map(row)]
+        self.working.stringValue = "Working · \(workText.isEmpty ? "none" : workText)"
+        self.workingDetails = doing.map(row)
+        self.panel.orderFrontRegardless()
+        if let popup = self.ticketPopup, !popup.isMiniaturized { popup.orderFrontRegardless() }
       }
     }
   }

← f2531c3c reopen-from-audit: Step 3 reopen tool for TK-11903 (dry-run  ·  back to Ticket System  ·  TK-11903: reopen 71 unverified overnight closes (Cody correc e9464783 →