← 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
M desktop-bar/README.mdM desktop-bar/TicketBar.swift
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 →