Optional best-effort mirror server. Each heartbeat and each forwarded event can now be sent to a second ("mirror") server in addition to the primary, so a standby box (e.g. the office NAS) stays a continuous replica during a migration — making the eventual cutover a non-event. Default off — blank mirror_api_url/mirror_sfm_url means existing installs are unchanged after auto-update.
Heartbeat mirror (best-effort). After the primary heartbeat POST, the same payload is fired at mirror_api_url when set. Bounded by the existing API timeout and fully wrapped — it can never delay or fail the primary heartbeat; its result is ignored except for a debug log line.
Event mirror (reliable, isolated). Events are re-forwarded to mirror_sfm_url using a separate sha256 state file (mirror_sfm_state_file, blank → <log_dir>/thor_forwarded_mirror.json), so the mirror tracks its own delivery independently of the primary. A quick reachability probe (~3 s) skips the mirror pass when the mirror server is down, so a dead mirror never stalls the loop on per-event timeouts; skipped events stay pending in the mirror state and deliver when the mirror returns — no data loss.
Isolation invariant (the one rule). Nothing on the mirror path can delay or fail the primary: its own state file, its own try/except, the reachability guard + bounded timeouts, all exceptions swallowed-and-logged. The primary path stays exactly as reliable as before.
Mirror tab in the Settings dialog with Mirror API URL + Mirror SFM URL fields (blank = off). The mirror rides along with the primary — there is no separate enable flag (heartbeat-mirror active iff the primary heartbeat is on andmirror_api_url is set; event-mirror active iff primary forwarding is on andmirror_sfm_url is set).
New unit tests in test_event_forwarder.py covering mirror reachability, the down-mirror skip, separate-state idempotency, and the isolation invariant (a mirror failure leaves the primary's result + state untouched).
Configuration
New config.json keys (all default-empty = off; existing 0.3.x deployments don't change behaviour on auto-update):
Key
Default
Notes
mirror_api_url
""
Second heartbeat destination — same URL form as api_url (blank = off)
mirror_sfm_url
""
Second SFM base URL, e.g. http://10.0.0.x:8200 (blank = off)
mirror_sfm_state_file
""
Override the mirror's forwarded-sha256 state file. Blank → <log_dir>\thor_forwarded_mirror.json
Changed
Bumped VERSION to 0.4.0
Operational note (migration seeding). To make the mirror re-deliver only the gap since a one-time replica snapshot, copy the primary state file to the mirror state file and drop entries whose forwarded_at is after the snapshot. SFM-side dedup covers any overlap. See docs/mirror-dual-send-design.md.
## [0.4.0] - 2026-06-25
### Added — Mirror (dual-send)
- **Optional best-effort mirror server.** Each heartbeat and each forwarded event can now be sent to a *second* ("mirror") server in addition to the primary, so a standby box (e.g. the office NAS) stays a continuous replica during a migration — making the eventual cutover a non-event. **Default off** — blank `mirror_api_url`/`mirror_sfm_url` means existing installs are unchanged after auto-update.
- **Heartbeat mirror (best-effort).** After the primary heartbeat POST, the same payload is fired at `mirror_api_url` when set. Bounded by the existing API timeout and fully wrapped — it can never delay or fail the primary heartbeat; its result is ignored except for a debug log line.
- **Event mirror (reliable, isolated).** Events are re-forwarded to `mirror_sfm_url` using a *separate* sha256 state file (`mirror_sfm_state_file`, blank → `<log_dir>/thor_forwarded_mirror.json`), so the mirror tracks its own delivery independently of the primary. A quick reachability probe (~3 s) skips the mirror pass when the mirror server is down, so a dead mirror never stalls the loop on per-event timeouts; skipped events stay pending in the mirror state and deliver when the mirror returns — **no data loss**.
- **Isolation invariant (the one rule).** Nothing on the mirror path can delay or fail the primary: its own state file, its own try/except, the reachability guard + bounded timeouts, all exceptions swallowed-and-logged. The primary path stays exactly as reliable as before.
- **Mirror tab in the Settings dialog** with `Mirror API URL` + `Mirror SFM URL` fields (blank = off). The mirror rides along with the primary — there is no separate enable flag (heartbeat-mirror active iff the primary heartbeat is on *and* `mirror_api_url` is set; event-mirror active iff primary forwarding is on *and* `mirror_sfm_url` is set).
- New unit tests in `test_event_forwarder.py` covering mirror reachability, the down-mirror skip, separate-state idempotency, and the isolation invariant (a mirror failure leaves the primary's result + state untouched).
### Configuration
New `config.json` keys (all default-empty = off; existing 0.3.x deployments don't change behaviour on auto-update):
| Key | Default | Notes |
|---|---|---|
| `mirror_api_url` | `""` | Second heartbeat destination — same URL form as `api_url` (blank = off) |
| `mirror_sfm_url` | `""` | Second SFM base URL, e.g. `http://10.0.0.x:8200` (blank = off) |
| `mirror_sfm_state_file` | `""` | Override the mirror's forwarded-sha256 state file. Blank → `<log_dir>\thor_forwarded_mirror.json` |
### Changed
- Bumped `VERSION` to `0.4.0`
> **Operational note (migration seeding).** To make the mirror re-deliver only the gap since a one-time replica snapshot, copy the primary state file to the mirror state file and drop entries whose `forwarded_at` is after the snapshot. SFM-side dedup covers any overlap. See `docs/mirror-dual-send-design.md`.
Optional second destination so each heartbeat and event is posted to a
mirror server (the office NAS) alongside the primary — dual-write to
de-risk the migration. Default off (blank mirror URLs); existing installs
unchanged.
- event_forwarder: mirror_reachable() fast-fail probe + mirror_forward_pass()
(reliable event mirror with its OWN state file, total exception isolation,
never raises into the primary path).
- series4_ingest: mirror_api_url/mirror_sfm_url/mirror_sfm_state_file config;
best-effort heartbeat mirror; isolated event-mirror pass after the primary.
- settings dialog: new 'Mirror' tab.
- 9 new tests incl. the isolation invariant (down mirror = fast no-op that
never touches primary state). 42 passing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main's v0.3.x event-forwarding (delivered via the event-db branch) is
content-identical to dev's, except for older version strings and the
pre-fix MLG timestamp handling that dev's 4742ed9 already superseded.
Keep dev's tree verbatim (-s ours); this only records main as merged so
the dev->main PR for v0.4.0 (mirror dual-send) merges without conflicts.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
[0.4.0] - 2026-06-25
Added — Mirror (dual-send)
mirror_api_url/mirror_sfm_urlmeans existing installs are unchanged after auto-update.mirror_api_urlwhen set. Bounded by the existing API timeout and fully wrapped — it can never delay or fail the primary heartbeat; its result is ignored except for a debug log line.mirror_sfm_urlusing a separate sha256 state file (mirror_sfm_state_file, blank →<log_dir>/thor_forwarded_mirror.json), so the mirror tracks its own delivery independently of the primary. A quick reachability probe (~3 s) skips the mirror pass when the mirror server is down, so a dead mirror never stalls the loop on per-event timeouts; skipped events stay pending in the mirror state and deliver when the mirror returns — no data loss.Mirror API URL+Mirror SFM URLfields (blank = off). The mirror rides along with the primary — there is no separate enable flag (heartbeat-mirror active iff the primary heartbeat is on andmirror_api_urlis set; event-mirror active iff primary forwarding is on andmirror_sfm_urlis set).test_event_forwarder.pycovering mirror reachability, the down-mirror skip, separate-state idempotency, and the isolation invariant (a mirror failure leaves the primary's result + state untouched).Configuration
New
config.jsonkeys (all default-empty = off; existing 0.3.x deployments don't change behaviour on auto-update):mirror_api_url""api_url(blank = off)mirror_sfm_url""http://10.0.0.x:8200(blank = off)mirror_sfm_state_file""<log_dir>\thor_forwarded_mirror.jsonChanged
VERSIONto0.4.0