diff --git a/CLAUDE.md b/CLAUDE.md index 22dad0e..27a14c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,10 +29,11 @@ Când adaugi un frame: copiezi din `logs/fires/` → redenumești `{ts}_{color}. ## Telegram commands (live) -`/ss` `/status` `/pause` `/resume` `/resume force` `/3` (interval min) `/stop` +`/ss` `/status` `/pause` `/resume` `/3` (interval min) `/stop` -- `/resume` clears only user pause; Canary drift requires `/resume force`. -- Drift-pause now emits a single Telegram alert (was silent pre-refactor — root cause of the 2026-04-17 hang). +- `/resume` clears BOTH user pause and canary drift-pause in one shot (`/resume force` still accepted as legacy alias). +- Drift-pause emits a single Telegram alert on transition. While paused, `/set_interval` is refused and `/ss` captions warn that detection is off. +- Heartbeat shows `⚠️ pauzat (drift)` instead of `activ` while canary is paused. ## Operating-hours config diff --git a/src/atm/main.py b/src/atm/main.py index 4067ba2..6e84be1 100644 --- a/src/atm/main.py +++ b/src/atm/main.py @@ -597,6 +597,51 @@ def _save_annotated_frame( return None +def _build_heartbeat_alert( + *, + fsm_state: str, + fire_count: int, + uptime_h: float, + canary_paused: bool, +) -> "Alert": + """Construct the periodic heartbeat Alert. + + When canary is drift-paused the title/body reflect it explicitly — + 2026-04-21 bug: previously the heartbeat said "activ ARMED_SELL" while + detection had been dead for hours, misleading the user into thinking + the system was running. + """ + title = "⚠️ pauzat (drift)" if canary_paused else "activ" + state_label = f"{fsm_state} [drift-pause]" if canary_paused else fsm_state + body = f"{state_label} | semnale: {fire_count} | {uptime_h:.1f}h" + return Alert(kind="heartbeat", title=title, body=body) + + +def _emit_arm_alert( + notifier: _NotifierLike, + *, + kind: str, + direction: str, + now: float, + title: str, + snap: Snapshot, + snap_kind: str, + snap_label: str, +) -> None: + """Build + dispatch an arm-family Alert (arm / opposite_rearm / rearm). + + Keeps the three branches in _handle_tick DRY: they differ only in kind, + title, and the snap() label used to save the annotated screenshot. + """ + notifier.send(Alert( + kind=kind, + title=title, + body=f"@ {datetime.fromtimestamp(now).isoformat(timespec='seconds')}", + image_path=snap(snap_kind, snap_label), + direction=direction, + )) + + def _handle_tick( fsm: StateMachine, color: str, @@ -689,13 +734,45 @@ def _handle_tick( # ARM: turquoise (BUY) / yellow (SELL) — doar la tranziție nouă IDLE→ARMED if tr.reason == "arm": direction = "BUY" if tr.next == State.ARMED_BUY else "SELL" - notifier.send(Alert( + _emit_arm_alert( + notifier, kind="arm", - title=f"{direction} armat ({color})", - body=f"@ {datetime.fromtimestamp(now).isoformat(timespec='seconds')}", - image_path=snap("arm", f"arm_{direction.lower()}"), direction=direction, - )) + now=now, + title=f"{direction} armat ({color})", + snap=snap, + snap_kind="arm", + snap_label=f"arm_{direction.lower()}", + ) + # OPPOSITE REARM: ciclul anterior abandonat de semnal opus + # (PRIMED_BUY→ARMED_SELL pe yellow, sau flip direct ARMED→ARMED opus). + # Setup nou în direcția opusă — alertă cu screenshot. + elif tr.reason == "opposite_rearm": + direction = "BUY" if tr.next == State.ARMED_BUY else "SELL" + _emit_arm_alert( + notifier, + kind="opposite_rearm", + direction=direction, + now=now, + title=f"{direction} re-armat ({color}) — ciclu opus", + snap=snap, + snap_kind="opposite_rearm", + snap_label=f"opposite_rearm_{direction.lower()}", + ) + # REARM: PRIMED_* → ARMED_* aceeași direcție (arm-color peste prime). + # Ciclul s-a resetat — prime_ts invalidat, armare proaspătă. + elif tr.reason == "rearm": + direction = "BUY" if tr.next == State.ARMED_BUY else "SELL" + _emit_arm_alert( + notifier, + kind="rearm", + direction=direction, + now=now, + title=f"{direction} re-armat ({color}) — ciclu reluat", + snap=snap, + snap_kind="rearm", + snap_label=f"rearm_{direction.lower()}", + ) # PRIME: dark_green (BUY) / dark_red (SELL) — doar la ARMED→PRIMED elif tr.reason == "prime": direction = "BUY" if tr.next == State.PRIMED_BUY else "SELL" @@ -1065,6 +1142,13 @@ async def _dispatch_command(ctx: RunContext, cmd) -> None: """Process a single Command. Exceptions bubble — caller wraps in try/except.""" cfg = ctx.cfg if cmd.action == "set_interval": + if getattr(ctx.canary, "is_paused", False): + ctx.notifier.send(Alert( + kind="warn", + title="⚠️ Canary drift activ — /set_interval ignorat", + body="Trimite /resume pentru a relua detecția.", + )) + return secs = cmd.value or cfg.telegram.auto_poll_interval_s ctx.scheduler.start(secs) ctx.audit.log({"ts": time.time(), "event": "scheduler_started", "reason": "set_interval", "interval_s": secs}) @@ -1123,7 +1207,8 @@ async def _dispatch_command(ctx: RunContext, cmd) -> None: _save_annotated_frame, frame_ss, ctx.cfg, ctx.fires_dir, "ss", now_ss, ctx.audit, ) ctx.audit.log({"ts": now_ss, "event": "screenshot_sent", "path": str(path_ss) if path_ss else None}) - ctx.notifier.send(Alert(kind="screenshot", title="Screenshot manual", body="", image_path=path_ss)) + warn = "⚠️ DETECȚIE OPRITĂ (drift) — trimite /resume" if getattr(ctx.canary, "is_paused", False) else "" + ctx.notifier.send(Alert(kind="screenshot", title="Screenshot manual", body=warn, image_path=path_ss)) elif cmd.action == "pause": # User manually stops monitoring. Canary drift state is untouched. if ctx.lifecycle is not None: @@ -1135,14 +1220,15 @@ async def _dispatch_command(ctx: RunContext, cmd) -> None: body="Folosește /resume pentru a relua.", )) elif cmd.action == "resume": - # R2: /resume clears only user_paused. Canary drift requires - # /resume force (value == 1) so the user acknowledges the risk. + # /resume clears BOTH user_paused AND canary drift in one shot. + # /resume force rămâne acceptat ca alias (legacy muscle memory); + # câmpul `force` e păstrat în audit pentru schema compat. was_drift = bool(getattr(ctx.canary, "is_paused", False)) was_user = bool(ctx.lifecycle.user_paused) if ctx.lifecycle is not None else False force = cmd.value == 1 if ctx.lifecycle is not None: ctx.lifecycle.user_paused = False - if force and was_drift: + if was_drift: ctx.canary.resume() ctx.audit.log({ "ts": time.time(), "event": "user_resumed", @@ -1153,33 +1239,25 @@ async def _dispatch_command(ctx: RunContext, cmd) -> None: ctx.audit.log({"ts": time.time(), "event": "window_focused", "command": "resume", "title": title}) await asyncio.sleep(0.15) # Adaptive response - if was_drift and not force: - title = "Pauză user eliminată — dar Canary drift activ" - body = ( - "Trimite /resume force pentru a anula drift-pause. " - "Recalibrează dacă driftul persistă." - ) - elif force and was_drift: - title = "Drift-pause anulat manual (force)" - body = "Dacă driftul persistă, Canary va repauza." + skip_now = None + if ctx.lifecycle is not None: + skip_now = _should_skip(time.time(), ctx.lifecycle, ctx.cfg, ctx.canary) + if skip_now and skip_now.startswith("out_of_window"): + title = "Pauză eliminată — piața e închisă acum" + body = "Monitorizarea va porni la următoarea fereastră." + elif was_drift: + title = "Monitorizare reluată — drift-pause anulat" + body = "Dacă driftul persistă, Canary va repauza la următoarea verificare." else: - skip_now = None - if ctx.lifecycle is not None: - skip_now = _should_skip(time.time(), ctx.lifecycle, ctx.cfg, ctx.canary) - if skip_now and skip_now.startswith("out_of_window"): - title = "Pauză eliminată — piața e închisă acum" - body = "Monitorizarea va porni la următoarea fereastră." - else: - title = "Monitorizare reluată" - body = "" + title = "Monitorizare reluată" + body = "" ctx.notifier.send(Alert(kind="status", title=title, body=body)) elif cmd.action == "help": body = ( "/status — stare FSM, uptime, ultima detecție\n" "/ss — screenshot acum\n" "/pause — oprește detecția (heartbeat continuă)\n" - "/resume — reia detecția (doar pauza user)\n" - "/resume force — reia + anulează drift-pause canary\n" + "/resume — reia detecția (șterge user-pause și drift-pause)\n" "/3 — screenshot automat la fiecare 3 min (sau orice număr)\n" "/stop — oprește screenshot-urile automate\n" "/h — acest mesaj" @@ -1356,8 +1434,13 @@ async def run_live_async(cfg, duration_s=None, capture_stub: bool = False) -> No heartbeat_due = time.monotonic() + cfg.heartbeat_min * 60 continue uptime_h = (time.monotonic() - start) / 3600 - body = f"{ctx.fsm.state.value} | semnale: {ctx.state.fire_count} | {uptime_h:.1f}h" - notifier.send(Alert(kind="heartbeat", title="activ", body=body)) + paused = bool(getattr(canary, "is_paused", False)) + notifier.send(_build_heartbeat_alert( + fsm_state=ctx.fsm.state.value, + fire_count=ctx.state.fire_count, + uptime_h=uptime_h, + canary_paused=paused, + )) heartbeat_due = time.monotonic() + cfg.heartbeat_min * 60 async def _detection_loop() -> None: diff --git a/tests/test_handle_tick.py b/tests/test_handle_tick.py index 943a5c9..d83231e 100644 --- a/tests/test_handle_tick.py +++ b/tests/test_handle_tick.py @@ -567,3 +567,169 @@ def test_state_machine_is_locked_and_record_fire_public_api(): assert fsm.is_locked("BUY", 50.0) is True # within 100s assert fsm.is_locked("BUY", 150.0) is False # past lockout assert fsm.is_locked("SELL", 50.0) is False # other direction unaffected + + +# --------------------------------------------------------------------------- +# opposite_rearm — bug observat 2026-04-21 17:45 +# PRIMED_BUY + yellow → ARMED_SELL; reason=opposite_rearm; must emit alert +# with screenshot attached. CRITICAL regression. +# --------------------------------------------------------------------------- + +def test_opposite_rearm_primed_buy_to_armed_sell_emits_alert(): + """REGRESSION (2026-04-21): PRIMED_BUY + yellow → ARMED_SELL silently.""" + from pathlib import Path + + fsm = StateMachine(lockout_s=60) + notif = FakeNotifier() + audit = FakeAudit() + + def snap(kind, label): + return Path(f"/tmp/{label}.png") + + # Drive to PRIMED_BUY + _handle_tick(fsm, "turquoise", 1.0, notif, audit, first_accepted=False, snapshot=snap) + _handle_tick(fsm, "dark_green", 2.0, notif, audit, first_accepted=False, snapshot=snap) + assert fsm.state == State.PRIMED_BUY + notif.alerts.clear() + + # Yellow → ARMED_SELL via opposite_rearm + tr = _handle_tick(fsm, "yellow", 3.0, notif, audit, first_accepted=False, snapshot=snap) + + assert tr is not None + assert tr.next == State.ARMED_SELL + assert tr.reason == "opposite_rearm" + assert len(notif.alerts) == 1 + a = notif.alerts[0] + assert a.kind == "opposite_rearm" + assert a.direction == "SELL" + assert "yellow" in a.title + assert "opus" in a.title.lower() + assert a.image_path == Path("/tmp/opposite_rearm_sell.png") + + +def test_opposite_rearm_primed_sell_to_armed_buy_emits_alert(): + """Mirror: PRIMED_SELL + turquoise → ARMED_BUY.""" + fsm = StateMachine(lockout_s=60) + notif = FakeNotifier() + audit = FakeAudit() + + _handle_tick(fsm, "yellow", 1.0, notif, audit, first_accepted=False) + _handle_tick(fsm, "dark_red", 2.0, notif, audit, first_accepted=False) + assert fsm.state == State.PRIMED_SELL + notif.alerts.clear() + + tr = _handle_tick(fsm, "turquoise", 3.0, notif, audit, first_accepted=False) + + assert tr is not None + assert tr.next == State.ARMED_BUY + assert tr.reason == "opposite_rearm" + assert len(notif.alerts) == 1 + assert notif.alerts[0].kind == "opposite_rearm" + assert notif.alerts[0].direction == "BUY" + + +def test_opposite_rearm_armed_buy_to_armed_sell_emits_alert(): + """Flip direct ARMED_BUY → ARMED_SELL (fără prime între).""" + fsm = StateMachine(lockout_s=60) + notif = FakeNotifier() + audit = FakeAudit() + + _handle_tick(fsm, "turquoise", 1.0, notif, audit, first_accepted=False) + assert fsm.state == State.ARMED_BUY + notif.alerts.clear() + + tr = _handle_tick(fsm, "yellow", 2.0, notif, audit, first_accepted=False) + + assert tr is not None + assert tr.next == State.ARMED_SELL + assert tr.reason == "opposite_rearm" + assert len(notif.alerts) == 1 + assert notif.alerts[0].kind == "opposite_rearm" + assert notif.alerts[0].direction == "SELL" + + +# --------------------------------------------------------------------------- +# rearm — PRIMED_* + arm-color aceeași direcție → ARMED_* (reset ciclu) +# --------------------------------------------------------------------------- + +def test_rearm_primed_buy_to_armed_buy_emits_alert(): + from pathlib import Path + + fsm = StateMachine(lockout_s=60) + notif = FakeNotifier() + audit = FakeAudit() + + def snap(kind, label): + return Path(f"/tmp/{label}.png") + + _handle_tick(fsm, "turquoise", 1.0, notif, audit, first_accepted=False, snapshot=snap) + _handle_tick(fsm, "dark_green", 2.0, notif, audit, first_accepted=False, snapshot=snap) + assert fsm.state == State.PRIMED_BUY + notif.alerts.clear() + + tr = _handle_tick(fsm, "turquoise", 3.0, notif, audit, first_accepted=False, snapshot=snap) + + assert tr is not None + assert tr.next == State.ARMED_BUY + assert tr.reason == "rearm" + assert len(notif.alerts) == 1 + a = notif.alerts[0] + assert a.kind == "rearm" + assert a.direction == "BUY" + assert "reluat" in a.title.lower() + assert a.image_path == Path("/tmp/rearm_buy.png") + + +def test_rearm_primed_sell_to_armed_sell_emits_alert(): + fsm = StateMachine(lockout_s=60) + notif = FakeNotifier() + audit = FakeAudit() + + _handle_tick(fsm, "yellow", 1.0, notif, audit, first_accepted=False) + _handle_tick(fsm, "dark_red", 2.0, notif, audit, first_accepted=False) + assert fsm.state == State.PRIMED_SELL + notif.alerts.clear() + + tr = _handle_tick(fsm, "yellow", 3.0, notif, audit, first_accepted=False) + + assert tr is not None + assert tr.next == State.ARMED_SELL + assert tr.reason == "rearm" + assert len(notif.alerts) == 1 + assert notif.alerts[0].kind == "rearm" + assert notif.alerts[0].direction == "SELL" + + +# --------------------------------------------------------------------------- +# _emit_arm_alert helper — unit test +# --------------------------------------------------------------------------- + +def test_emit_arm_alert_helper_builds_expected_alert(): + from pathlib import Path + from atm.main import _emit_arm_alert + + notif = FakeNotifier() + calls: list[tuple[str, str]] = [] + + def snap(kind, label): + calls.append((kind, label)) + return Path(f"/tmp/{label}.png") + + _emit_arm_alert( + notif, + kind="opposite_rearm", + direction="SELL", + now=1700000000.0, + title="SELL re-armat (yellow) — ciclu opus", + snap=snap, + snap_kind="opposite_rearm", + snap_label="opposite_rearm_sell", + ) + + assert len(notif.alerts) == 1 + a = notif.alerts[0] + assert a.kind == "opposite_rearm" + assert a.direction == "SELL" + assert a.title == "SELL re-armat (yellow) — ciclu opus" + assert a.image_path == Path("/tmp/opposite_rearm_sell.png") + assert calls == [("opposite_rearm", "opposite_rearm_sell")] diff --git a/tests/test_main.py b/tests/test_main.py index 9476451..84356a5 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -558,7 +558,7 @@ def _oh_cfg(enabled=True, weekdays=("MON", "TUE", "WED", "THU", "FRI"), stop_hhmm=stop, _tz_cache=_ZI(tz) if enabled else None, ) - return types.SimpleNamespace(operating_hours=oh) + return types.SimpleNamespace(operating_hours=oh, window_title=None) def _fake_canary(paused=False): @@ -841,8 +841,10 @@ async def test_resume_clears_user_paused_and_canary_when_forced(): @pytest.mark.asyncio -async def test_resume_during_drift_keeps_canary_paused_without_force(): - """R2 #21: plain /resume during drift clears user_paused but NOT canary.""" +async def test_resume_plain_also_clears_canary_drift(): + """2026-04-21 decision: /resume (no arg) now clears BOTH user_paused and + canary drift. /resume force remains accepted as legacy alias. Previous + behavior (force required for drift) was a UX trap — see plan doc.""" import atm.main as _main from atm.commands import Command @@ -859,15 +861,37 @@ async def test_resume_during_drift_keeps_canary_paused_without_force(): await _main._dispatch_command(ctx, Command(action="resume")) # no force assert ctx.lifecycle.user_paused is False - assert canary.is_paused is True # still drift-paused - # Message must mention drift + assert canary.is_paused is False # drift cleared without force + # Audit event still records was_drift + force=False for traceability + resumed = [e for e in ctx.audit.events if e.get("event") == "user_resumed"] + assert resumed and resumed[0]["was_drift"] is True and resumed[0]["force"] is False + # Message mentions drift-pause was cleared status = [a for a in ctx.notifier.alerts if a.kind == "status"] assert status and ("drift" in (status[0].title + status[0].body).lower()) - # Now force - ctx.notifier.alerts.clear() + +@pytest.mark.asyncio +async def test_resume_force_alias_still_works(): + """/resume force (value=1) remains accepted — legacy muscle memory.""" + import atm.main as _main + from atm.commands import Command + + class _Canary: + def __init__(self): self._p = True + @property + def is_paused(self): return self._p + def resume(self): self._p = False + canary = _Canary() + + ctx = _dispatch_ctx(canary=canary) + ctx.lifecycle.user_paused = True + await _main._dispatch_command(ctx, Command(action="resume", value=1)) + + assert ctx.lifecycle.user_paused is False assert canary.is_paused is False + resumed = [e for e in ctx.audit.events if e.get("event") == "user_resumed"] + assert resumed and resumed[0]["force"] is True @pytest.mark.asyncio @@ -923,6 +947,85 @@ async def test_status_command_reports_pause_reason(): assert "pauză manuală" in body or "pauza" in body.lower() +@pytest.mark.asyncio +async def test_set_interval_refused_while_canary_paused(): + """2026-04-21: /set_interval must not start scheduler while canary is drift-paused. + Previously started scheduler silently, misleading the user into thinking + detection was live. Now emits a warn and refuses.""" + import atm.main as _main + from atm.commands import Command + + canary = types.SimpleNamespace(is_paused=True, resume=lambda: None) + ctx = _dispatch_ctx(canary=canary) + + await _main._dispatch_command(ctx, Command(action="set_interval", value=60)) + + # Scheduler must NOT have started + assert ctx.scheduler.is_running is False + # No scheduler_started audit event + assert not any(e.get("event") == "scheduler_started" for e in ctx.audit.events) + # A warn alert must have been sent referencing /resume + warns = [a for a in ctx.notifier.alerts if a.kind == "warn"] + assert warns + combined = (warns[0].title + warns[0].body).lower() + assert "drift" in combined and "/resume" in combined + + +@pytest.mark.asyncio +async def test_ss_includes_warn_body_while_canary_paused(monkeypatch, tmp_path): + """2026-04-21: /ss still captures while canary is paused, but the alert body + must warn that detection is off.""" + import atm.main as _main + from atm.commands import Command + + canary = types.SimpleNamespace(is_paused=True, resume=lambda: None) + ctx = _dispatch_ctx(canary=canary) + + # Bypass window focus + use a simple non-None capture result + fake_frame = object() + ctx = types.SimpleNamespace(**{**ctx.__dict__}) # shallow copy RunContext fields + # Simpler: just override the capture and save functions used + async_capture_called = {"n": 0} + def _capture(): + async_capture_called["n"] += 1 + return fake_frame + ctx.capture = _capture + ctx.fires_dir = tmp_path + # window_title off so we skip focus branch + ctx.cfg.window_title = None + # stub _save_annotated_frame to return a dummy path + monkeypatch.setattr(_main, "_save_annotated_frame", + lambda *a, **kw: tmp_path / "fake_ss.png") + + await _main._dispatch_command(ctx, Command(action="ss")) + + screenshots = [a for a in ctx.notifier.alerts if a.kind == "screenshot"] + assert screenshots + assert "DETECȚIE OPRITĂ" in screenshots[0].body or "drift" in screenshots[0].body.lower() + assert "/resume" in screenshots[0].body + + +@pytest.mark.asyncio +async def test_ss_no_warn_when_canary_healthy(monkeypatch, tmp_path): + """/ss body must be empty when canary is not paused (no warn noise).""" + import atm.main as _main + from atm.commands import Command + + canary = types.SimpleNamespace(is_paused=False, resume=lambda: None) + ctx = _dispatch_ctx(canary=canary) + ctx.capture = lambda: object() + ctx.fires_dir = tmp_path + ctx.cfg.window_title = None + monkeypatch.setattr(_main, "_save_annotated_frame", + lambda *a, **kw: tmp_path / "fake_ss.png") + + await _main._dispatch_command(ctx, Command(action="ss")) + + screenshots = [a for a in ctx.notifier.alerts if a.kind == "screenshot"] + assert screenshots + assert screenshots[0].body == "" + + @pytest.mark.asyncio async def test_lifecycle_with_drift_then_resume_then_fire(monkeypatch, tmp_path): """E2E #16: drift paused → /resume force → dark_red/light_red produce FIRE alert. @@ -1040,6 +1143,32 @@ def test_heartbeat_suppressed_outside_hours(monkeypatch): assert sent == [] +def test_build_heartbeat_alert_active_when_not_paused(): + """Healthy state → title=activ, body shows fsm.state plainly.""" + import atm.main as _main + a = _main._build_heartbeat_alert( + fsm_state="ARMED_BUY", fire_count=2, uptime_h=1.5, canary_paused=False, + ) + assert a.kind == "heartbeat" + assert a.title == "activ" + assert "ARMED_BUY" in a.body + assert "[drift-pause]" not in a.body + assert "semnale: 2" in a.body + + +def test_build_heartbeat_alert_paused_when_canary_drift(): + """2026-04-21: heartbeat must reflect canary drift instead of lying with 'activ'.""" + import atm.main as _main + a = _main._build_heartbeat_alert( + fsm_state="ARMED_SELL", fire_count=0, uptime_h=3.2, canary_paused=True, + ) + assert a.kind == "heartbeat" + assert "pauzat" in a.title + assert "drift" in a.title.lower() + assert "ARMED_SELL" in a.body + assert "[drift-pause]" in a.body + + @pytest.mark.asyncio async def test_status_compact_active(): """/status produces compact 2-line format; 'Canary' absent."""