Skip to content

Commit 7dfd75c

Browse files
Alpaca233claude
andauthored
feat: acquisition watchdog — Slack alert on prematurely-ended acquisitions (#565)
## Summary Adds an **independent `acquisition_watchdog` process** that posts a single Slack alert when an acquisition ends prematurely — process **crash / hang / kill**, **fatal error**, or **user abort** — covering acquisitions launched from the GUI *and* from the MCP control server, on **Ubuntu and Windows**. The core idea: *a crashing process can't report its own death*, so the in-process `SlackNotifier` can never catch a segfault/OOM-kill/power-loss/freeze. The watchdog runs out-of-process and watches on-disk breadcrumbs the engine leaves behind. - **Breadcrumb protocol** — new dependency-free `squid/acquisition_state.py` writes a single `run.json` atomically: `running` at start, a throttled heartbeat (+progress) during the run, and `ended` with a computed `reason` in the worker's `finally`. Written in the **engine** (`MultiPointController`/`MultiPointWorker`), so GUI- and server-launched runs are both covered with no extra code. - **Watchdog** — `acquisition_watchdog/` (config, alerts, monitor, CLI). Polls `run.json`; classifies `running`+dead-PID/stale-heartbeat → crash/hang, and `ended`+{error, user_abort, completed_with_errors} → alert; `completed` is silent. De-duplicates per `run_id` (persisted, survives restart). Lightweight — never imports `control`/Qt. - **Notifier split** — extracted a shared `squid/slack.py` sender; the in-process `SlackNotifier` now announces **only clean completions** (the watchdog owns premature-end alerts → no double-alerting). Both read credentials from the same `cache/slack_settings.yaml` the GUI writes. - **Shutdown** — quitting mid-acquisition aborts+joins the worker so it records `user_abort` rather than looking like a crash. - **Deployment** — systemd user unit (Linux) + Task Scheduler `install.ps1` (Windows) to run it as an always-on service. The same code can later run as a remote monitor for power-loss coverage (see spec "Future work"). - Design spec + implementation plan under `docs/superpowers/`. ## How it works ``` GUI/server process acquisition_watchdog (independent) run_acquisition → run.json(running) poll run.json every ~5s worker loop → heartbeat+progress running + (pid dead | stale) → crash/hang → Slack finally → run.json(ended,reason) ended + non-clean reason → Slack ended + completed → silent ``` ## Test plan - [x] Unit: breadcrumb writer (atomic write, throttled heartbeat), `squid.slack` sender, watchdog config (reads `cache/slack_settings.yaml`), alert formatting, monitor classify/dedup/PID-degrade, worker end-reason logic — all pass. - [x] Integration (simulation): full `running → ended` breadcrumb lifecycle via the engine. - [x] `black --check .` clean (224 files); targeted feature suite 104 passed; full suite 1434 passed / 8 skipped. - [x] **Real deployment check:** configure Slack in the GUI, start an acquisition, `kill -9` it (and separately abort it), confirm one alert per event. ## Notes - **CI caveat:** on the dev machine the *full* pytest run exits 139 (segfault) at **interpreter teardown** — *after* all tests report pass. The faulting thread is in C-extension/Qt/cupy finalization (this repo already disables memory-profiling in CI to dodge a related fork+threads hazard). The two new Qt integration tests run a full simulated-acquisition teardown that the suite otherwise skips (the equivalent `test_MultiPointWorker` tests are `@pytest.mark.skip` for a `QApplication.processEvents()` issue). If CI exits 139, the fix is to skip-mark those two tests consistent with that existing convention. - **v1 scope:** progress-stall detection and machine-power-loss coverage are intentionally deferred (spec "Future work"). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8c62827 commit 7dfd75c

30 files changed

Lines changed: 1240 additions & 61 deletions
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Acquisition Watchdog
2+
3+
An independent process that watches Squid acquisitions and posts a **single Slack alert**
4+
when one ends prematurely — a process **crash / hang / kill**, a **fatal error**, or a
5+
**user abort**. Clean completions stay silent ("no news is good news"). It covers
6+
acquisitions started from the **GUI** and from the **MCP control server**, on **Ubuntu and
7+
Windows**.
8+
9+
Because it runs as a *separate* process, it can report failures the in-app Slack notifier
10+
can't — a segfault in a camera SDK, an OOM-kill, a frozen UI, or the whole process dying.
11+
12+
## How it works
13+
14+
The Squid GUI writes a small `run.json` breadcrumb into a shared state dir: `status=running`
15+
at acquisition start, a throttled heartbeat (+ progress) during the run, and `status=ended`
16+
with a reason at the end. This watchdog polls that file and alerts when a run's process has
17+
died / gone silent, or ended with a non-clean reason. One alert per run (de-duplicated).
18+
19+
## Prerequisites
20+
21+
1. **Slack configured in the GUI.** Open *Settings → Slack Notifications*, enter your **Bot
22+
Token** (`xoxb-…`) and **Channel ID** (`C…`), and click *Save*. That writes
23+
`cache/slack_settings.yaml`, which the watchdog reads — there is no separate config.
24+
(Need to create the token? See [`../docs/slack_notifications.md`](../docs/slack_notifications.md).)
25+
2. **"Enable watchdog alerts" checked** (the default) in that same dialog — or the
26+
`watchdog_enabled` key in `cache/slack_settings.yaml`.
27+
28+
## Run it
29+
30+
From the `software/` directory:
31+
32+
```bash
33+
cd software
34+
python3 -m acquisition_watchdog
35+
```
36+
37+
Leave it running. Start an acquisition; if it crashes / hangs / aborts / errors, you get a
38+
Slack alert. A clean finish produces nothing.
39+
40+
### Options
41+
42+
| Flag | Default | Purpose |
43+
|---|---|---|
44+
| `--heartbeat-timeout` | `120` | Seconds of heartbeat silence (with a live process) before declaring a hang. Raise it if you have very long single exposures / fluidics steps. |
45+
| `--poll-interval` | `5` | Seconds between checks. |
46+
| `--once` || Run a single check and exit (handy for testing or a cron probe). |
47+
| `--slack-settings <path>` | `cache/slack_settings.yaml` | Only needed if you don't run from `software/`. |
48+
| `--state-dir <path>` | platformdirs user-state dir | Must match the GUI's; override here or via `$SQUID_WATCHDOG_STATE_DIR`. |
49+
50+
## Run it always-on (recommended for a lab microscope)
51+
52+
A manual run stops when you close the terminal or reboot. To keep it up independently of the
53+
GUI:
54+
55+
- **Linux (systemd user service)** — run these from the `software/` directory:
56+
```bash
57+
mkdir -p ~/.config/systemd/user
58+
cp acquisition_watchdog/systemd/squid-acquisition-watchdog.service ~/.config/systemd/user/
59+
# The shipped unit's WorkingDirectory is a placeholder (%h/Squid/software); point it here:
60+
sed -i "s#^WorkingDirectory=.*#WorkingDirectory=$PWD#" ~/.config/systemd/user/squid-acquisition-watchdog.service
61+
systemctl --user daemon-reload
62+
systemctl --user enable --now squid-acquisition-watchdog # auto-start at login + start now
63+
systemctl --user status squid-acquisition-watchdog # verify it's active
64+
```
65+
Enable it once and it comes up at every login and restarts on failure (`Restart=always`) —
66+
no need to launch it by hand. Logs: `journalctl --user -u squid-acquisition-watchdog -f`.
67+
To keep it running before/without a graphical login, also run `loginctl enable-linger $USER`
68+
once. (The unit runs `/usr/bin/python3`; if Squid runs on a different interpreter/venv, edit
69+
the `ExecStart=` line to that python.)
70+
- **Windows (Task Scheduler):** run `windows/install.ps1` in PowerShell from `software\`. It
71+
registers a logon-triggered task (via `pythonw.exe`; make sure it's on `PATH`, or edit the
72+
path in the script).
73+
74+
## Verify it works
75+
76+
```bash
77+
python3 -m acquisition_watchdog --once # one check, then exits — no error means it's healthy
78+
```
79+
80+
End-to-end: start a `--simulation` acquisition, `kill -9` the GUI process, and you should get
81+
a crash alert — within one poll (~5 s) when `psutil` is installed (it is, by default),
82+
otherwise within `--heartbeat-timeout` seconds.
83+
84+
## What triggers an alert
85+
86+
| Situation | Alert |
87+
|---|---|
88+
| Process died — `running` breadcrumb + PID gone (crash / OOM-kill / power loss) | 🔴 crash |
89+
| Process alive but no heartbeat past the timeout | 🟠 hang |
90+
| Fatal error / auto-abort (timeout, failed save job, camera/frame failure) | 🔴 error |
91+
| Finished, but some save/job errors occurred | 🟠 completed-with-errors |
92+
| Aborted by the user, the MCP server, or by closing the GUI mid-run | 🟡 aborted |
93+
| Finished cleanly | *(silent)* |
94+
95+
Each alert includes the machine name, experiment, reason, and progress (e.g. "stopped at
96+
timepoint 3/10").
97+
98+
## Good to know
99+
100+
- **Independent of the GUI.** Restarting the software neither starts nor stops the watchdog —
101+
it just picks up the next run. That decoupling is the whole point: a watchdog spawned by the
102+
GUI couldn't survive the GUI crashing.
103+
- **Start order doesn't matter.** Start it before, during, or after the GUI. If it starts
104+
mid-run it monitors from there; if it starts *after* a crash already happened, it reads the
105+
stale `running` breadcrumb, sees the PID is dead, and alerts once.
106+
- **One alert per run.** Alerted run IDs persist in `<state_dir>/alerted.json`, so it never
107+
double-alerts and never re-alerts after a restart.
108+
- **Turn alerts off** on a machine (without disabling the GUI notifier): uncheck *"Enable
109+
watchdog alerts"*, or set `watchdog_enabled: false` in `cache/slack_settings.yaml`. Takes
110+
effect on the next check — no watchdog restart needed.
111+
- **Runs on the same machine as the GUI** (it reads local breadcrumb files). For coverage of a
112+
full machine death / power loss, run it on another host pointed at a shared/synced state dir
113+
(see *Remote / power-loss coverage*).
114+
115+
## Troubleshooting
116+
117+
| Symptom | Check |
118+
|---|---|
119+
| No alerts at all | Is the watchdog process actually running? Are `bot_token`/`channel_id` set (GUI → *Test Connection*)? Is *"Enable watchdog alerts"* checked? |
120+
| Log says Slack not configured | Run from `software/` so `cache/slack_settings.yaml` resolves, or pass `--slack-settings <path>`. |
121+
| False "hang" alerts | Raise `--heartbeat-timeout` — a single very long exposure/fluidics step can exceed the default. |
122+
| Crash reported slowly (~2 min) | `psutil` missing → falls back to the heartbeat timeout. Install `psutil` for instant PID-based detection. |
123+
124+
## State dir
125+
126+
Defaults to `platformdirs.user_state_path("squid", "cephla")/watchdog`. The GUI (writer) and
127+
the watchdog (reader) must agree on it — run both as the same user, or set
128+
`SQUID_WATCHDOG_STATE_DIR` on both (or `--state-dir` on the watchdog).
129+
130+
## Remote / power-loss coverage (future)
131+
132+
Point `--state-dir` at a shared/synced mount on another host and run this process there.
133+
Per-machine `run-<machine>.json` naming and a clock-skew tolerance are needed first (see the
134+
design spec).

software/acquisition_watchdog/__init__.py

Whitespace-only changes.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# acquisition_watchdog/__main__.py
2+
"""CLI entry point: python -m acquisition_watchdog"""
3+
import argparse
4+
import time
5+
from pathlib import Path
6+
from typing import Optional, Sequence
7+
8+
import squid.logging
9+
from acquisition_watchdog.monitor import Monitor
10+
11+
12+
def main(argv: Optional[Sequence[str]] = None) -> None:
13+
parser = argparse.ArgumentParser(
14+
prog="acquisition_watchdog",
15+
description="Alert on prematurely-ended Squid acquisitions (crash/hang/abort/error).",
16+
)
17+
parser.add_argument(
18+
"--slack-settings",
19+
help="Path to the Slack settings YAML (defaults to ./cache/slack_settings.yaml, "
20+
"the same file the GUI writes).",
21+
)
22+
parser.add_argument("--state-dir", help="Override the watchdog state directory.")
23+
parser.add_argument("--poll-interval", type=float, default=5.0, help="Seconds between checks (default 5).")
24+
parser.add_argument(
25+
"--heartbeat-timeout",
26+
type=float,
27+
default=120.0,
28+
help="Seconds of heartbeat silence (with a live PID) before declaring a hang (default 120).",
29+
)
30+
parser.add_argument("--once", action="store_true", help="Run a single check and exit.")
31+
args = parser.parse_args(argv)
32+
33+
log = squid.logging.get_logger("acquisition_watchdog")
34+
monitor = Monitor(
35+
state_dir=Path(args.state_dir) if args.state_dir else None,
36+
slack_settings=args.slack_settings,
37+
poll_interval=args.poll_interval,
38+
heartbeat_timeout=args.heartbeat_timeout,
39+
)
40+
if args.once:
41+
monitor.check_once(time.time())
42+
else:
43+
try:
44+
monitor.run_forever()
45+
except KeyboardInterrupt:
46+
log.info("Acquisition watchdog stopped.")
47+
48+
49+
if __name__ == "__main__":
50+
main()
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# acquisition_watchdog/alerts.py
2+
"""Format watchdog Slack alerts (text + Block Kit blocks)."""
3+
from datetime import datetime, timezone
4+
from typing import Optional, Tuple
5+
6+
_KIND_TITLE = {
7+
"crash": ":red_circle: Acquisition process died",
8+
"hang": ":large_orange_circle: Acquisition hung (no heartbeat)",
9+
"error": ":red_circle: Acquisition ended with a fatal error",
10+
"completed_with_errors": ":large_orange_circle: Acquisition finished with errors",
11+
"user_abort": ":large_yellow_circle: Acquisition aborted",
12+
}
13+
14+
15+
def _fmt_ts(epoch: Optional[float]) -> str:
16+
if not epoch:
17+
return "unknown"
18+
return datetime.fromtimestamp(epoch, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
19+
20+
21+
def _progress_line(run: dict) -> str:
22+
prog = run.get("progress") or {}
23+
expected = run.get("expected") or {}
24+
tp = prog.get("timepoint", "?")
25+
exp_tp = prog.get("expected_timepoints", expected.get("timepoints", "?"))
26+
images = prog.get("images", "?")
27+
return f"timepoint {tp}/{exp_tp}, {images} images"
28+
29+
30+
def format_alert(kind: str, run: dict) -> Tuple[str, list]:
31+
title = _KIND_TITLE.get(kind, f"Acquisition alert: {kind}")
32+
experiment = run.get("experiment_id", "unknown")
33+
machine = run.get("machine", "unknown")
34+
text = f"{title}: {experiment} on {machine}"
35+
36+
last_seen = run.get("ended_at") or run.get("heartbeat_at")
37+
detail = (
38+
f"*Experiment:* {experiment}\n"
39+
f"*Machine:* {machine}\n"
40+
f"*Progress:* {_progress_line(run)}\n"
41+
f"*Started:* {_fmt_ts(run.get('started_at'))}\n"
42+
f"*Last seen:* {_fmt_ts(last_seen)}\n"
43+
f"*Output:* {run.get('output_path', 'unknown')}"
44+
)
45+
blocks = [
46+
{"type": "section", "text": {"type": "mrkdwn", "text": f"*{title}*"}},
47+
{"type": "section", "text": {"type": "mrkdwn", "text": detail}},
48+
]
49+
return text, blocks
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# acquisition_watchdog/config.py
2+
"""Load Slack credentials from the same source the Squid GUI uses.
3+
4+
The GUI stores Slack settings (bot token, channel, enabled) in
5+
`cache/slack_settings.yaml` (written by the Slack settings dialog and loaded at
6+
GUI startup via control.widgets_slack.load_slack_settings_from_cache). This module
7+
reads that same YAML so the watchdog alerts to the same workspace — without
8+
importing the heavy control stack.
9+
"""
10+
import os
11+
from pathlib import Path
12+
from typing import NamedTuple, Optional
13+
14+
import yaml
15+
16+
17+
class SlackConfig(NamedTuple):
18+
bot_token: Optional[str]
19+
channel_id: Optional[str]
20+
watchdog_enabled: bool
21+
22+
23+
DEFAULT_SLACK_SETTINGS = "cache/slack_settings.yaml"
24+
25+
26+
def resolve_slack_settings_path(cli_path: Optional[str]) -> Path:
27+
"""Priority: --slack-settings > $SQUID_SLACK_SETTINGS > cache/slack_settings.yaml (cwd-relative)."""
28+
if cli_path:
29+
return Path(cli_path)
30+
env = os.environ.get("SQUID_SLACK_SETTINGS")
31+
if env:
32+
return Path(env)
33+
return Path(DEFAULT_SLACK_SETTINGS)
34+
35+
36+
def load_slack_config(path: Optional[Path]) -> SlackConfig:
37+
p = Path(path) if path else Path(DEFAULT_SLACK_SETTINGS)
38+
if not p.exists():
39+
return SlackConfig(None, None, True)
40+
try:
41+
with open(p) as f:
42+
data = yaml.safe_load(f) or {}
43+
except Exception:
44+
return SlackConfig(None, None, True)
45+
if not isinstance(data, dict):
46+
return SlackConfig(None, None, True)
47+
return SlackConfig(
48+
bot_token=(data.get("bot_token") or None),
49+
channel_id=(data.get("channel_id") or None),
50+
watchdog_enabled=bool(data.get("watchdog_enabled", True)),
51+
)

0 commit comments

Comments
 (0)