feat(@applications/@claire-tray): initial commit — native menu-bar tray for Claire

Client + watchdog menu-bar app: polls the claire daemon (status/fleet/
budget/health), shows NEEDS-YOU, and auto-recovers the daemon on silent
DB-write failure via launchctl kickstart. Built on LilithMenuBar /
LilithTrayResources.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-05-31 17:23:53 -06:00
commit 13283c6a60
13 changed files with 658 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.build/

22
Makefile Normal file
View file

@ -0,0 +1,22 @@
.PHONY: build release run install uninstall clean
build:
swift build
release:
swift build -c release --product ClaireTrayApp
# Run the debug binary in the foreground (Ctrl-C to quit). Useful for
# iterating on the menu without going through the launchd install.
run: build
./.build/debug/ClaireTrayApp
install:
./deploy/install.sh
uninstall:
./deploy/install.sh --uninstall
clean:
swift package clean
rm -rf .build

29
Package.swift Normal file
View file

@ -0,0 +1,29 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "ClaireTray",
platforms: [
.macOS(.v13)
],
dependencies: [
.package(path: "../../@packages/@swift/@macos/menu-bar"),
.package(path: "../../@packages/@tray/tray-resources"),
],
targets: [
.executableTarget(
name: "ClaireTrayApp",
dependencies: [
.product(name: "LilithMenuBar", package: "menu-bar"),
.product(name: "LilithTrayResources", package: "tray-resources"),
],
path: "src/client",
exclude: ["Resources"]
),
.testTarget(
name: "ClaireTrayAppTests",
dependencies: ["ClaireTrayApp"],
path: "Tests/ClaireTrayAppTests"
),
]
)

View file

@ -0,0 +1,48 @@
import XCTest
@testable import ClaireTrayApp
final class WatchdogTests: XCTestCase {
private let t0 = Date(timeIntervalSince1970: 1_000_000)
func testHealthyStaysOK() {
var w = Watchdog(failureThreshold: 3, cooldown: 120, maxAttempts: 5)
XCTAssertEqual(w.record(healthy: true, now: t0), .ok)
XCTAssertEqual(w.record(healthy: true, now: t0.addingTimeInterval(25)), .ok)
}
func testRecoversOnlyAfterThreshold() {
var w = Watchdog(failureThreshold: 3, cooldown: 120, maxAttempts: 5)
XCTAssertEqual(w.record(healthy: false, now: t0), .watching) // 1
XCTAssertEqual(w.record(healthy: false, now: t0.addingTimeInterval(25)), .watching) // 2
XCTAssertEqual(w.record(healthy: false, now: t0.addingTimeInterval(50)), .recover) // 3 kick
}
func testCooldownSuppressesRepeatKicks() {
var w = Watchdog(failureThreshold: 1, cooldown: 120, maxAttempts: 5)
XCTAssertEqual(w.record(healthy: false, now: t0), .recover) // kick at t0
// Within cooldown: no second kick.
XCTAssertEqual(w.record(healthy: false, now: t0.addingTimeInterval(60)), .watching)
// After cooldown: kick again.
XCTAssertEqual(w.record(healthy: false, now: t0.addingTimeInterval(130)), .recover)
}
func testGivesUpAfterMaxAttempts() {
var w = Watchdog(failureThreshold: 1, cooldown: 0, maxAttempts: 2)
XCTAssertEqual(w.record(healthy: false, now: t0), .recover) // attempt 1
XCTAssertEqual(w.record(healthy: false, now: t0.addingTimeInterval(1)), .recover) // attempt 2
XCTAssertEqual(w.record(healthy: false, now: t0.addingTimeInterval(2)), .gaveUp) // ceiling hit
XCTAssertTrue(w.gaveUp)
}
func testRecoveryResetsAfterDaemonHealthyAgain() {
var w = Watchdog(failureThreshold: 1, cooldown: 0, maxAttempts: 2)
_ = w.record(healthy: false, now: t0) // recover
_ = w.record(healthy: false, now: t0.addingTimeInterval(1)) // recover
_ = w.record(healthy: false, now: t0.addingTimeInterval(2)) // gaveUp
XCTAssertTrue(w.gaveUp)
XCTAssertEqual(w.record(healthy: true, now: t0.addingTimeInterval(3)), .ok) // recovered
XCTAssertFalse(w.gaveUp)
// Counters reset a fresh failure run can recover again.
XCTAssertEqual(w.record(healthy: false, now: t0.addingTimeInterval(4)), .recover)
}
}

6
VERSION.json Normal file
View file

@ -0,0 +1,6 @@
{
"version": "0.1.0",
"major": 0,
"merges": 0,
"builds": 1
}

130
deploy/install.sh Executable file
View file

@ -0,0 +1,130 @@
#!/usr/bin/env bash
#
# Build, sign, bundle, and install the ClaireTray menu-bar app as a LaunchAgent
# on this machine (plum). Local-only — no remote rsync. Idempotent.
#
# ./deploy/install.sh build + install + (re)load the LaunchAgent
# ./deploy/install.sh --uninstall bootout + remove the LaunchAgent and bundle
#
# Signing: a personal Apple Development cert (override with
# CLAIRE_TRAY_SIGNING_IDENTITY). The tray holds NO TCC permissions, so the
# stable-keychain dance mac-sync uses (to preserve permission grants) is
# unnecessary here.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_NAME="ClaireTrayApp" # executable (SwiftPM product)
DISPLAY_NAME="Claire" # CFBundleName
BUNDLE_ID="com.lilith.claire-tray"
LABEL="com.lilith.claire-tray"
APP_BUNDLE="$HOME/Applications/ClaireTray.app"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
LOG_DIR="$HOME/Library/Application Support/ClaireTray"
CONFIG_DIR="$HOME/.config/$BUNDLE_ID"
CONFIG_FILE="$CONFIG_DIR/config.json"
CLAIRE_VENV="${CLAIRE_VENV:-$HOME/Code/@projects/@claire/.venv}"
SIGNING_IDENTITY="${CLAIRE_TRAY_SIGNING_IDENTITY:-Apple Development: hinataliesterling@icloud.com (X8424J5CTB)}"
say() { printf '\033[1;35m▸\033[0m %s\n' "$*"; }
uninstall() {
say "Uninstalling $LABEL"
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
rm -f "$PLIST"
rm -rf "$APP_BUNDLE"
say "Removed bundle + LaunchAgent (config + logs left at $CONFIG_DIR, $LOG_DIR)"
}
if [[ "${1:-}" == "--uninstall" ]]; then
uninstall
exit 0
fi
# --- 1. build --------------------------------------------------------------
say "Building release binary"
( cd "$ROOT" && swift build -c release --product "$APP_NAME" )
BIN="$ROOT/.build/release/$APP_NAME"
[[ -f "$BIN" ]] || { echo "binary not found: $BIN" >&2; exit 1; }
# --- 2. assemble bundle ----------------------------------------------------
say "Assembling $APP_BUNDLE"
MACOS_DIR="$APP_BUNDLE/Contents/MacOS"
RES_DIR="$APP_BUNDLE/Contents/Resources"
mkdir -p "$MACOS_DIR" "$RES_DIR"
rm -rf "$MACOS_DIR"/*.bundle "$RES_DIR"/*.bundle
cp "$BIN" "$MACOS_DIR/$APP_NAME"
chmod +x "$MACOS_DIR/$APP_NAME"
# SPM resource bundles (the generated tray-resources icons).
for b in "$ROOT/.build/release/"*.bundle; do
[[ -d "$b" ]] || continue
cp -R "$b" "$RES_DIR/$(basename "$b")"
say " bundled $(basename "$b")"
done
# --- 3. Info.plist from template + VERSION.json ----------------------------
VERSION="0.1.0"; BUILD="1"
if [[ -f "$ROOT/VERSION.json" ]]; then
VERSION="$(python3 -c "import json;print(json.load(open('$ROOT/VERSION.json')).get('version','0.1.0'))")"
BUILD="$(python3 -c "import json;print(json.load(open('$ROOT/VERSION.json')).get('builds',1))")"
fi
sed -e "s/{{VERSION}}/$VERSION/g" -e "s/{{BUILD}}/$BUILD/g" \
"$ROOT/src/client/Resources/Info.plist.template" \
> "$APP_BUNDLE/Contents/Info.plist"
# --- 4. codesign -----------------------------------------------------------
say "Signing as $BUNDLE_ID"
if ! codesign --force --identifier "$BUNDLE_ID" -s "$SIGNING_IDENTITY" "$APP_BUNDLE" 2>/dev/null; then
say " signing identity unavailable — falling back to ad-hoc"
codesign --force --identifier "$BUNDLE_ID" -s - "$APP_BUNDLE"
fi
codesign --verify --strict "$APP_BUNDLE"
# --- 5. config.json: base URL from claire's OWN config loader --------------
# Reuse claire's config + `_client_host` (wildcard→loopback) rather than
# re-parsing the TOML or guessing its path — single source of truth.
say "Writing $CONFIG_FILE"
mkdir -p "$CONFIG_DIR"
BASE_URL="$("$CLAIRE_VENV/bin/python" -c "
from claire.config import load_or_init
from claire.orchestrator.bootstrap import _client_host
c = load_or_init()
print(f'http://{_client_host(c.web.host)}:{c.web.port}')
" 2>/dev/null || echo "http://127.0.0.1:8765")"
printf '{\n "baseURL": "%s",\n "daemonLabel": "com.lilith.clare-serve"\n}\n' "$BASE_URL" > "$CONFIG_FILE"
say " baseURL = $BASE_URL"
# --- 6. LaunchAgent --------------------------------------------------------
say "Installing LaunchAgent $LABEL"
mkdir -p "$LOG_DIR" "$(dirname "$PLIST")"
cat > "$PLIST" <<PLISTEOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>$LABEL</string>
<key>ProgramArguments</key>
<array><string>$MACOS_DIR/$APP_NAME</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key><false/>
<key>Crashed</key><true/>
</dict>
<key>StandardOutPath</key><string>$LOG_DIR/stdout.log</string>
<key>StandardErrorPath</key><string>$LOG_DIR/stderr.log</string>
</dict>
</plist>
PLISTEOF
DOMAIN="gui/$(id -u)"
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
# Wait for full unload — bootstrap races bootout and returns EIO (5) if the
# label is still registered.
for _ in $(seq 1 20); do
launchctl print "$DOMAIN/$LABEL" >/dev/null 2>&1 || break
sleep 0.2
done
# One retry covers the residual transient EIO.
launchctl bootstrap "$DOMAIN" "$PLIST" 2>/dev/null \
|| { sleep 1; launchctl bootstrap "$DOMAIN" "$PLIST"; }
say "Done — Claire menu-bar icon should appear shortly. Logs: $LOG_DIR"

View file

@ -0,0 +1,128 @@
import AppKit
import LilithMenuBar
import LilithTrayResources
/// Menu-bar agent: polls the claire daemon, renders status, offers controls,
/// and runs the recovery watchdog. All state is `@MainActor`-confined; network
/// work hops off via async `ClaireClient`, and blocking OS calls (launchctl)
/// run in detached tasks.
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private let config = TrayConfig.load()
private let client: ClaireClient?
private var agent: MenuBarAgent?
private var watchdog = Watchdog()
private var timer: Timer?
private var latest = FleetSnapshot(
healthy: false, needsYou: 0, liveSessions: 0, totalCap: 0,
budgetFraction: 0, budgetCapped: false, reachable: false
)
override init() {
client = ClaireClient(baseURLString: config.baseURL)
super.init()
}
func applicationDidFinishLaunching(_ notification: Notification) {
let agent = MenuBarAgent(icon: icon(for: latest), menu: menuItems())
agent.install(activationPolicy: .accessory)
self.agent = agent
NSLog("ClaireTray: status item installed (baseURL=\(config.baseURL))")
let t = Timer.scheduledTimer(withTimeInterval: 25, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in await self?.refresh() }
}
t.tolerance = 5
timer = t
Task { @MainActor [weak self] in await self?.refresh() } // immediate first poll
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false }
// MARK: - Poll + render
private func refresh() async {
guard let client else { return }
latest = await client.snapshot()
agent?.updateIcon(icon(for: latest))
agent?.updateMenu(menuItems())
if case .recover = watchdog.record(healthy: latest.healthy, now: Date()) {
NSLog("ClaireTray: watchdog recovering \(config.daemonLabel)")
let label = config.daemonLabel
Task.detached { DaemonControl.restartDaemon(label: label) }
}
}
private func icon(for s: FleetSnapshot) -> MenuBarIcon {
let color: TrayColor
if !s.reachable || !s.healthy {
color = .red
} else if s.needsYou > 0 || s.budgetCapped {
color = .yellow
} else {
color = .green
}
// coloredImage (not .image) so the green/yellow/red status tint is kept
// .image renders as a monochrome template and would discard the color.
return .coloredImage(TrayResources.image(template: .claireMark, color: color, pointSize: 18))
}
private func menuItems() -> [MenuBarItem] {
let s = latest
var items: [MenuBarItem] = []
if !s.reachable {
items.append(.label(title: "Claire — unreachable"))
} else {
items.append(.label(title: s.healthy ? "Claire — healthy" : "Claire — DB unwritable ⚠"))
items.append(.label(title: "fleet: \(s.liveSessions)/\(s.totalCap) sessions"))
let pct = Int((s.budgetFraction * 100).rounded())
items.append(.label(title: s.budgetCapped ? "budget: \(pct)% — OVER CAP" : "budget: \(pct)% of cap"))
items.append(.label(title: s.needsYou > 0 ? "⚠ NEEDS YOU: \(s.needsYou)" : "needs you: 0"))
}
if watchdog.gaveUp {
items.append(.label(title: "watchdog: gave up — see logs"))
}
items.append(.separator)
items.append(.action(title: "Open Dashboard") { [weak self] in
Task { @MainActor [weak self] in self?.openDashboard() }
})
items.append(.action(title: "Force Rounds Tick") { [weak self] in
Task { @MainActor [weak self] in self?.forceRounds() }
})
items.append(.action(title: "Restart Claire Daemon") { [weak self] in
Task { @MainActor [weak self] in self?.restartDaemon() }
})
items.append(.separator)
items.append(.action(title: "Refresh Now") { [weak self] in
Task { @MainActor [weak self] in await self?.refresh() }
})
items.append(.action(title: "Quit ClaireTray", key: "q") {
Task { @MainActor in NSApp.terminate(nil) }
})
return items
}
// MARK: - Actions (MainActor; offload blocking/async work)
private func openDashboard() {
DaemonControl.openDashboard(baseURL: config.baseURL)
}
private func restartDaemon() {
let label = config.daemonLabel
Task.detached { DaemonControl.restartDaemon(label: label) }
}
private func forceRounds() {
guard let client else { return }
Task {
do {
try await client.postRoundsTick()
NSLog("ClaireTray: rounds tick posted")
} catch {
NSLog("ClaireTray: rounds tick failed: \(error)")
}
}
}
}

View file

@ -0,0 +1,128 @@
import Foundation
// MARK: - Wire models (decode only the fields the menu uses; extra keys ignored)
struct StatusResponse: Codable, Sendable {
struct Project: Codable, Sendable {
struct Counts: Codable, Sendable {
let userReview: Int
enum CodingKeys: String, CodingKey { case userReview = "user_review" }
}
let name: String
let counts: Counts
}
let projects: [Project]
}
struct FleetLoad: Codable, Sendable {
struct Host: Codable, Sendable {
let host: String
let liveSessions: Int
let cap: Int
enum CodingKeys: String, CodingKey {
case host
case liveSessions = "live_sessions"
case cap
}
}
let hosts: [Host]
}
struct Budget: Codable, Sendable {
let fractionUsed: Double
let dailyTokenCap: Int
let overCap: Bool
enum CodingKeys: String, CodingKey {
case fractionUsed = "fraction_used"
case dailyTokenCap = "daily_token_cap"
case overCap = "over_cap"
}
}
/// Aggregated view of one poll cycle what the menu + icon render from.
struct FleetSnapshot: Sendable {
var healthy: Bool
var needsYou: Int
var liveSessions: Int
var totalCap: Int
var budgetFraction: Double
var budgetCapped: Bool
var reachable: Bool
}
// MARK: - Client
/// Thin async HTTP client for the local claire daemon. No auth the API is
/// unauthenticated on the WireGuard LAN, so `URLSession` + `Codable` is all we
/// need (no reason to drag in agent-core's bearer/keychain machinery). Sendable
/// (immutable), so it's safe to call from any task.
struct ClaireClient: Sendable {
let baseURL: URL
private let session: URLSession
init?(baseURLString: String) {
guard let url = URL(string: baseURLString) else { return nil }
self.baseURL = url
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 5 // a wedged daemon must fail fast
cfg.waitsForConnectivity = false
self.session = URLSession(configuration: cfg)
}
private func get<T: Decodable>(_ path: String, as _: T.Type) async throws -> T {
let (data, resp) = try await session.data(from: baseURL.appendingPathComponent(path))
try Self.check(resp)
return try JSONDecoder().decode(T.self, from: data)
}
private static func check(_ resp: URLResponse) throws {
guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
let code = (resp as? HTTPURLResponse)?.statusCode ?? -1
throw URLError(.badServerResponse, userInfo: ["status": code])
}
}
/// Deep health: real DB round-trip. Returns false on 503 or unreachable.
func deepHealthy() async -> Bool {
do {
let (_, resp) = try await session.data(
from: baseURL.appendingPathComponent("api/v1/health/deep"))
try Self.check(resp)
return true
} catch {
return false
}
}
/// Fire one rounds tick (best-effort; the endpoint returns 202 immediately).
func postRoundsTick() async throws {
var req = URLRequest(url: baseURL.appendingPathComponent("api/v1/rounds/tick"))
req.httpMethod = "POST"
let (_, resp) = try await session.data(for: req)
try Self.check(resp)
}
/// One poll cycle. Health is authoritative for reachability; the other
/// fetches degrade gracefully so a single slow endpoint can't blank the menu.
func snapshot() async -> FleetSnapshot {
async let healthy = deepHealthy()
async let status = try? get("api/v1/status", as: StatusResponse.self)
async let fleet = try? get("api/v1/fleet/load", as: FleetLoad.self)
async let budget = try? get("api/v1/budget", as: Budget.self)
let s = await status
let f = await fleet
let b = await budget
let h = await healthy
return FleetSnapshot(
healthy: h,
needsYou: s?.projects.reduce(0) { $0 + $1.counts.userReview } ?? 0,
liveSessions: f?.hosts.reduce(0) { $0 + $1.liveSessions } ?? 0,
totalCap: f?.hosts.reduce(0) { $0 + $1.cap } ?? 0,
budgetFraction: b?.fractionUsed ?? 0,
budgetCapped: b?.overCap ?? false,
reachable: h || s != nil || f != nil || b != nil
)
}
}

View file

@ -0,0 +1,15 @@
import AppKit
/// Entry point. A pure menu-bar (`.accessory`) agent no Dock icon, no main
/// window. Wiring + menu live in `AppDelegate`.
@main
enum ClaireTrayApp {
static func main() {
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.accessory)
NSLog("ClaireTray: starting")
app.run()
}
}

View file

@ -0,0 +1,35 @@
import AppKit
import Foundation
/// OS-level control of the `claire web` daemon and dashboard. All entry points
/// are nonisolated and safe to call from a detached task they shell out to
/// `launchctl` / open a URL and don't touch UI state.
enum DaemonControl {
/// Restart the daemon: `launchctl kickstart -k gui/<uid>/<label>`.
/// `-k` kills the running instance first, then relaunches it under launchd.
/// This is the watchdog's recovery action and the "Restart" menu item.
static func restartDaemon(label: String) {
let status = run(["/bin/launchctl", "kickstart", "-k", "gui/\(getuid())/\(label)"])
NSLog("ClaireTray: kickstart \(label) -> exit \(status)")
}
static func openDashboard(baseURL: String) {
guard let url = URL(string: baseURL) else { return }
NSWorkspace.shared.open(url)
}
@discardableResult
private static func run(_ argv: [String]) -> Int32 {
let p = Process()
p.executableURL = URL(fileURLWithPath: argv[0])
p.arguments = Array(argv.dropFirst())
do {
try p.run()
p.waitUntilExit()
return p.terminationStatus
} catch {
NSLog("ClaireTray: process \(argv) failed: \(error)")
return -1
}
}
}

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>ClaireTrayApp</string>
<key>CFBundleIdentifier</key>
<string>com.lilith.claire-tray</string>
<key>CFBundleName</key>
<string>Claire</string>
<key>CFBundleDisplayName</key>
<string>Claire</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleVersion</key>
<string>{{BUILD}}</string>
<key>CFBundleShortVersionString</key>
<string>{{VERSION}}</string>
<key>LSUIElement</key>
<true/>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<!-- The tray polls the daemon over plain HTTP on the WireGuard LAN;
ATS blocks cleartext by default, so allow local networking. -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,28 @@
import Foundation
/// Runtime config written by `deploy/install.sh` to
/// `~/.config/com.lilith.claire-tray/config.json`. Derived from claire's own
/// config so the tray always points at the daemon's real bind address.
struct TrayConfig: Codable, Sendable {
let baseURL: String
let daemonLabel: String
/// Fallback if the file is missing (fresh checkout / pre-install run).
static let fallback = TrayConfig(
baseURL: "http://127.0.0.1:8765",
daemonLabel: "com.lilith.clare-serve"
)
static func load() -> TrayConfig {
let path = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".config/com.lilith.claire-tray/config.json")
guard
let data = try? Data(contentsOf: path),
let cfg = try? JSONDecoder().decode(TrayConfig.self, from: data)
else {
NSLog("ClaireTray: config.json missing/unreadable — using fallback")
return .fallback
}
return cfg
}
}

53
src/client/Watchdog.swift Normal file
View file

@ -0,0 +1,53 @@
import Foundation
/// Decides when to recover a wedged daemon, with a thrash guard.
///
/// Recovery fires only after `failureThreshold` consecutive unhealthy polls,
/// then enters a `cooldown` (no further kicks until it elapses). After
/// `maxAttempts` kicks without recovery it **gives up** and logs, rather than
/// fighting launchd's own restart throttle forever.
///
/// IMPORTANT: `kickstart` is *mitigation*, not a fix. It gives the daemon a
/// fresh process (hence a fresh DB fd, which resolves the 2026-05-30 silent
/// outage), but it does not address whatever rewrote `claire.db` underneath
/// the long-lived server. If that recurs, the tray will keep restarting it.
struct Watchdog {
let failureThreshold: Int
let cooldown: TimeInterval
let maxAttempts: Int
private var consecutiveFailures = 0
private var attempts = 0
private var lastKickAt: Date?
private(set) var gaveUp = false
init(failureThreshold: Int = 3, cooldown: TimeInterval = 120, maxAttempts: Int = 5) {
self.failureThreshold = failureThreshold
self.cooldown = cooldown
self.maxAttempts = maxAttempts
}
enum Decision: Equatable { case ok, watching, recover, gaveUp }
/// Feed each poll's health verdict. Returns whether to kick the daemon now.
mutating func record(healthy: Bool, now: Date) -> Decision {
if healthy {
consecutiveFailures = 0
attempts = 0
gaveUp = false
return .ok
}
consecutiveFailures += 1
if consecutiveFailures < failureThreshold { return .watching }
if gaveUp { return .gaveUp }
if let last = lastKickAt, now.timeIntervalSince(last) < cooldown { return .watching }
if attempts >= maxAttempts {
gaveUp = true
NSLog("ClaireTray: watchdog giving up after \(attempts) kicks — daemon still unhealthy")
return .gaveUp
}
attempts += 1
lastKickAt = now
return .recover
}
}