Tray Architecture & Behavior Spec
Scope: this document is the source of truth for how the
runex traymenu-bar icon behaves.Status: ported from runexfab (2026-07-07). Updated by every change that touches the tray process model, callback wiring, or button semantics.
1. Process model
┌──────────────────────────────────────────────────────────────────┐
│ User's menu bar (RX icon) │
│ 4 items + separator │
└──────────────────────────────────────────────────────────────────┘
▲
│ pystray NSStatusItem (Cocoa runloop)
▼
┌──────────────────────────────────────────────────────────────────┐
│ Process: runex │
│ │
│ ┌────────────────────────┐ ┌──────────────────────────────┐ │
│ │ Main thread │ │ Background daemon threads │ │
│ │ (Cocoa runloop) │ │ - _worker_toggle_pause │ │
│ │ - pystray menu cb's │ │ - _watch_listener (health) │ │
│ │ - icon.update_menu() │ │ │ │
│ └────────┬───────────────┘ └──────────┬───────────────────┘ │
│ │ spawn via subprocess.Popen │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Child process: runex ontology listen │ │
│ │ - filesystem watcher (FSEvents on macOS) │ │
│ │ - runex engine event loop │ │
│ └─────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘2. Threading rules
| Operation | Allowed thread | Why |
|---|---|---|
icon.update_menu() | main thread only | pystray manipulates NSStatusItem, must be on Cocoa runloop |
icon.icon = X (setter) | any thread | pystray’s setter internally posts via performSelectorOnMainThread |
_notify() (osascript subprocess) | any thread | external subprocess, no UI thread requirement |
state.kill_listen() / state.spawn_listen() | any thread | hold state.lock for the whole body |
_worker_toggle_pause body | daemon thread | keeps callback <10 ms |
_watch_listener body | daemon thread | polls listen_child.poll() every 5 s |
3. Menu
4 items + 1 separator:
| Item | Action |
|---|---|
| 暂停 / 工作中 (toggle) | Pause/resume background sync + listen host |
| 开机自启 (toggle) | Install/uninstall LaunchAgent plist |
| 关于 | Display dialog with version |
| 退出 | Quit tray |
See src/runex/tray/app.py for full implementation.
4. Pause semantics
- 暂停: SIGTERM the listen child + dispatch
pause_connectionfor each active IngestConnection via in-process Python API - 工作中: dispatch
resume_connectionfor each paused connection + re-spawn listen child - Manual
runex node updatefrom terminal still fires guards while paused - DB is never touched by pause/resume — data survives
5. Failure modes
| Failure | Detection | Recovery | Data loss |
|---|---|---|---|
| Listener crashes | _watch_listener polls every 5 s | Auto-respawn | none |
| Tray killed externally | macOS orphan reaper on next launch | _reap_orphaned_listen() for stale PIDs | none |
| Vault file edited | watcher detects | in-place mirror, node_id preserved | none |
| Vault file deleted | watcher detects | node marked mirror_state=orphan | none |
Last updated on