mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-14 01:47:55 +00:00
fix(email): preserve urgency transaction lifecycle
This commit is contained in:
parent
2abc062280
commit
49daa73106
2 changed files with 720 additions and 71 deletions
|
|
@ -20,41 +20,87 @@ from src.interactive_gate import wait_for_interactive_quiet
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _run_email_urgency_state_transaction(state_path, lock_db_path, operation):
|
||||
"""Serialize one owner urgency checkpoint across worker processes.
|
||||
|
||||
``operation`` receives the latest JSON state and returns
|
||||
``(result, next_state)``. The scheduled-email database is used only as a
|
||||
durable cross-process write lock; the UI-facing state remains the existing
|
||||
JSON file. Running the operation in a worker thread keeps the async event
|
||||
loop free while reminder delivery is in flight.
|
||||
"""
|
||||
def _acquire_email_urgency_state_lock(
|
||||
state_path,
|
||||
lock_db_path,
|
||||
cancel_event,
|
||||
timeout_seconds=120,
|
||||
):
|
||||
"""Acquire the cross-process urgency lock without blocking the app loop."""
|
||||
import sqlite3
|
||||
import uuid
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
state_path = Path(state_path)
|
||||
state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(lock_db_path), timeout=120)
|
||||
temp_path = state_path.with_name(
|
||||
f".{state_path.name}.{uuid.uuid4().hex}.tmp"
|
||||
)
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
|
||||
while not cancel_event.is_set():
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise sqlite3.OperationalError("timed out waiting for urgency state lock")
|
||||
conn = sqlite3.connect(
|
||||
str(lock_db_path),
|
||||
timeout=min(0.25, max(0.01, remaining)),
|
||||
check_same_thread=False,
|
||||
)
|
||||
try:
|
||||
prior = json.loads(state_path.read_text(encoding="utf-8")) \
|
||||
if state_path.exists() else {}
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
except sqlite3.OperationalError as exc:
|
||||
conn.close()
|
||||
if "locked" not in str(exc).lower():
|
||||
raise
|
||||
cancel_event.wait(min(0.05, max(0.0, remaining)))
|
||||
continue
|
||||
except BaseException:
|
||||
conn.close()
|
||||
raise
|
||||
|
||||
if cancel_event.is_set():
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return None, None
|
||||
try:
|
||||
prior = (
|
||||
json.loads(state_path.read_text(encoding="utf-8"))
|
||||
if state_path.exists()
|
||||
else {}
|
||||
)
|
||||
if not isinstance(prior, dict):
|
||||
prior = {}
|
||||
except Exception:
|
||||
prior = {}
|
||||
return conn, prior
|
||||
|
||||
result, next_state = operation(prior)
|
||||
return None, None
|
||||
|
||||
|
||||
def _close_email_urgency_state_lock(conn):
|
||||
if conn is None:
|
||||
return
|
||||
try:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _commit_email_urgency_state(conn, state_path, next_state):
|
||||
"""Atomically publish JSON before releasing the SQLite write lock."""
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
state_path = Path(state_path)
|
||||
temp_path = state_path.with_name(
|
||||
f".{state_path.name}.{uuid.uuid4().hex}.tmp"
|
||||
)
|
||||
try:
|
||||
temp_path.write_text(json.dumps(next_state), encoding="utf-8")
|
||||
temp_path.replace(state_path)
|
||||
conn.commit()
|
||||
return result
|
||||
except Exception:
|
||||
except BaseException:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
|
|
@ -62,6 +108,130 @@ def _run_email_urgency_state_transaction(state_path, lock_db_path, operation):
|
|||
conn.close()
|
||||
|
||||
|
||||
async def _run_email_urgency_state_transaction(
|
||||
state_path,
|
||||
lock_db_path,
|
||||
operation,
|
||||
):
|
||||
"""Serialize one urgency decision while keeping async work on this loop.
|
||||
|
||||
Only lock acquisition waits in a worker thread. ``operation`` is awaited
|
||||
on the caller's long-lived event loop, where shared async clients, locks,
|
||||
and the browser-notification queue belong. Cancellation rolls back the
|
||||
SQLite transaction and never publishes a checkpoint.
|
||||
"""
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
cancel_event = threading.Event()
|
||||
acquire_future = loop.run_in_executor(
|
||||
None,
|
||||
_acquire_email_urgency_state_lock,
|
||||
state_path,
|
||||
lock_db_path,
|
||||
cancel_event,
|
||||
)
|
||||
try:
|
||||
conn, prior = await asyncio.shield(acquire_future)
|
||||
except asyncio.CancelledError as cancelled:
|
||||
cancel_event.set()
|
||||
# The acquisition worker owns any connection until it returns. Wait
|
||||
# for its short busy-poll to observe cancellation, then close a lock it
|
||||
# may have won concurrently with the cancellation request.
|
||||
while True:
|
||||
try:
|
||||
conn, _prior = await asyncio.shield(acquire_future)
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
continue
|
||||
except Exception:
|
||||
conn = None
|
||||
break
|
||||
_close_email_urgency_state_lock(conn)
|
||||
raise cancelled
|
||||
|
||||
if conn is None:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
try:
|
||||
result, next_state = await operation(prior)
|
||||
# Keep this small atomic publish synchronous. There is no await between
|
||||
# the successful operation and commit, so cancellation cannot be
|
||||
# observed and then followed by a checkpoint.
|
||||
try:
|
||||
_commit_email_urgency_state(conn, state_path, next_state)
|
||||
finally:
|
||||
conn = None
|
||||
return result
|
||||
except BaseException:
|
||||
_close_email_urgency_state_lock(conn)
|
||||
raise
|
||||
|
||||
|
||||
def _email_urgency_account_key(message_key):
|
||||
return str(message_key).split(":", 1)[0]
|
||||
|
||||
|
||||
def _merge_email_urgency_state(
|
||||
prior,
|
||||
*,
|
||||
owner,
|
||||
per_uid_scores,
|
||||
notified_uids,
|
||||
all_unread_keys,
|
||||
fully_scanned_account_ids,
|
||||
timestamp,
|
||||
):
|
||||
"""Merge current scan knowledge without deleting unscanned accounts."""
|
||||
prior_per_uid = prior.get("per_uid", {})
|
||||
if not isinstance(prior_per_uid, dict):
|
||||
prior_per_uid = {}
|
||||
complete = {str(account_id) for account_id in fully_scanned_account_ids}
|
||||
|
||||
merged_per_uid = dict(prior_per_uid)
|
||||
for key in list(merged_per_uid):
|
||||
if _email_urgency_account_key(key) in complete:
|
||||
merged_per_uid.pop(key, None)
|
||||
# Partial scans may add or refresh facts, but absence from a partial scan
|
||||
# is not evidence that another checkpoint or UI row is stale.
|
||||
merged_per_uid.update(per_uid_scores)
|
||||
|
||||
merged_notified = set(notified_uids)
|
||||
for key in list(merged_notified):
|
||||
if (
|
||||
_email_urgency_account_key(key) in complete
|
||||
and key not in all_unread_keys
|
||||
):
|
||||
merged_notified.discard(key)
|
||||
|
||||
total_unread = 0
|
||||
total_urgent = 0
|
||||
max_score = 0
|
||||
for value in merged_per_uid.values():
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
try:
|
||||
score = max(0, min(3, int(value.get("score", 0))))
|
||||
except (TypeError, ValueError):
|
||||
score = 0
|
||||
max_score = max(max_score, score)
|
||||
if value.get("unread"):
|
||||
total_unread += 1
|
||||
if score >= 2:
|
||||
total_urgent += 1
|
||||
|
||||
return {
|
||||
"ts": timestamp,
|
||||
"owner": owner or "",
|
||||
"total_unread": total_unread,
|
||||
"total_urgent": total_urgent,
|
||||
"max_score": max_score,
|
||||
"per_uid": merged_per_uid,
|
||||
"notified_uids": sorted(merged_notified),
|
||||
}
|
||||
|
||||
|
||||
class TaskNoop(BaseException):
|
||||
"""Raised by an action when it determined there's nothing to do.
|
||||
|
||||
|
|
@ -1972,6 +2142,7 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||
failed_classifications = []
|
||||
tag_write_details = []
|
||||
scanned = 0
|
||||
fully_scanned_account_ids = set()
|
||||
|
||||
def _heuristic_email_verdict(item: dict) -> dict:
|
||||
blob = (
|
||||
|
|
@ -2067,16 +2238,27 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||
def _scan_one(account=acc, cache_uids=cache.get("uids", {})):
|
||||
"""Sync IMAP work runs in a thread."""
|
||||
results = []
|
||||
scan_complete = True
|
||||
conn = _imap_connect(account.id)
|
||||
try:
|
||||
conn.select("INBOX", readonly=True)
|
||||
select_status, _select_data = conn.select("INBOX", readonly=True)
|
||||
if select_status != "OK":
|
||||
return results, False
|
||||
# Tag recent inbox mail, not only unread mail. Urgency
|
||||
# reminders below still only notify for unread messages.
|
||||
since_str = AGE_CUTOFF.strftime("%d-%b-%Y")
|
||||
status, data = conn.uid("SEARCH", None, f'(SINCE {since_str})')
|
||||
if status != "OK" or not data or not data[0]:
|
||||
return results
|
||||
uids = data[0].split()[-30:]
|
||||
if status != "OK":
|
||||
return results, False
|
||||
if not data or not data[0]:
|
||||
return results, True
|
||||
matching_uids = data[0].split()
|
||||
if len(matching_uids) > 30:
|
||||
# The scale guard deliberately processes only the most
|
||||
# recent 30. That is a partial account snapshot, so it
|
||||
# cannot justify pruning older checkpoint facts.
|
||||
scan_complete = False
|
||||
uids = matching_uids[-30:]
|
||||
for uid_b in uids:
|
||||
uid = uid_b.decode() if isinstance(uid_b, bytes) else str(uid_b)
|
||||
key = f"{account.id}:{uid}"
|
||||
|
|
@ -2084,12 +2266,41 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||
cached_ok = isinstance(cached, dict) and cached.get("triage_version") == TRIAGE_VERSION
|
||||
results.append({"key": key, "uid": uid, "cached": cached if cached_ok else None})
|
||||
if cached_ok:
|
||||
# Already classified — skip the fetch.
|
||||
# Cached verdicts still need a lightweight FLAGS
|
||||
# refresh. Without it a cached unread message looks
|
||||
# read and its successful notification checkpoint
|
||||
# is pruned on the next pass.
|
||||
try:
|
||||
st, flag_data = conn.uid("FETCH", uid_b, "(UID FLAGS)")
|
||||
if st != "OK" or not flag_data:
|
||||
scan_complete = False
|
||||
results.pop()
|
||||
continue
|
||||
flag_parts = []
|
||||
for part in flag_data:
|
||||
if isinstance(part, (bytes, bytearray)):
|
||||
flag_parts.append(bytes(part))
|
||||
elif (
|
||||
isinstance(part, tuple)
|
||||
and part
|
||||
and isinstance(part[0], (bytes, bytearray))
|
||||
):
|
||||
flag_parts.append(bytes(part[0]))
|
||||
flags_blob = b" ".join(flag_parts)
|
||||
results[-1]["unread"] = b"\\Seen" not in flags_blob
|
||||
except Exception as _fe:
|
||||
scan_complete = False
|
||||
results.pop()
|
||||
logger.debug(
|
||||
f"urgency: flag fetch for uid {uid} failed: {_fe}"
|
||||
)
|
||||
continue
|
||||
# Pull headers + first ~800 chars of plaintext body.
|
||||
try:
|
||||
st, msg_data = conn.uid("FETCH", uid_b, "(UID FLAGS RFC822.HEADER BODY.PEEK[TEXT]<0.800>)")
|
||||
if st != "OK" or not msg_data:
|
||||
scan_complete = False
|
||||
results.pop()
|
||||
continue
|
||||
flags_blob = b" ".join(
|
||||
part[0] for part in msg_data
|
||||
|
|
@ -2103,6 +2314,8 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||
if isinstance(part, tuple) and part[1]:
|
||||
raw += part[1] + b"\n\n"
|
||||
if not raw:
|
||||
scan_complete = False
|
||||
results.pop()
|
||||
continue
|
||||
msg = _email_mod.message_from_bytes(raw)
|
||||
# Skip Odysseus-generated reminders so the scanner
|
||||
|
|
@ -2158,17 +2371,21 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||
"unread": is_unread,
|
||||
})
|
||||
except Exception as _fe:
|
||||
scan_complete = False
|
||||
results.pop()
|
||||
logger.debug(f"urgency: header fetch for uid {uid} failed: {_fe}")
|
||||
finally:
|
||||
try: conn.logout()
|
||||
except Exception: pass
|
||||
return results
|
||||
return results, scan_complete
|
||||
|
||||
try:
|
||||
items = await _aio.to_thread(_scan_one)
|
||||
items, scan_complete = await _aio.to_thread(_scan_one)
|
||||
except Exception as e:
|
||||
logger.warning(f"urgency: IMAP scan failed for account {acc.id}: {e}")
|
||||
continue
|
||||
if scan_complete:
|
||||
fully_scanned_account_ids.add(str(acc.id))
|
||||
|
||||
for item in items:
|
||||
scanned += 1
|
||||
|
|
@ -2305,13 +2522,13 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||
logger.debug(f"urgency: LLM classify failed for {key}: {e}")
|
||||
continue
|
||||
|
||||
# ── Prune cache entries for UIDs that are no longer in the recent
|
||||
# scan window. Read messages remain cached because tags are useful
|
||||
# on read mail too; unread state is refreshed per scan above.
|
||||
seen_uids = {it["uid"] for it in items}
|
||||
cache_uids = cache.get("uids", {})
|
||||
for stale in [u for u in cache_uids if u not in seen_uids]:
|
||||
cache_uids.pop(stale, None)
|
||||
if scan_complete:
|
||||
# Only a complete account scan proves a cached UID left the
|
||||
# recent window. Partial/failing scans preserve prior facts.
|
||||
seen_uids = {it["uid"] for it in items}
|
||||
cache_uids = cache.get("uids", {})
|
||||
for stale in [u for u in cache_uids if u not in seen_uids]:
|
||||
cache_uids.pop(stale, None)
|
||||
|
||||
try:
|
||||
cache_file.write_text(_json.dumps(cache), encoding="utf-8")
|
||||
|
|
@ -2415,7 +2632,6 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||
|
||||
# ── 4. Aggregate state. urgent = score ≥ 2.
|
||||
urgent_keys = [k for k, v in per_uid_scores.items() if v.get("score", 0) >= 2 and v.get("unread")]
|
||||
max_score = max((v.get("score", 0) for v in per_uid_scores.values()), default=0)
|
||||
total_urgent = len(urgent_keys)
|
||||
|
||||
# ── 5. Fire a reminder only when a previously-unnotified UID scores
|
||||
|
|
@ -2464,12 +2680,12 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||
owner=owner or "",
|
||||
)
|
||||
|
||||
def _dispatch_and_checkpoint(prior):
|
||||
async def _dispatch_and_checkpoint(prior):
|
||||
notified_uids = set(prior.get("notified_uids", []))
|
||||
new_urgent = [k for k in urgent_keys if k not in notified_uids]
|
||||
if new_urgent:
|
||||
try:
|
||||
dispatch_result = _aio.run(_dispatch_urgency_reminder())
|
||||
dispatch_result = await _dispatch_urgency_reminder()
|
||||
channel = (settings.get("reminder_channel") or "browser").strip().lower()
|
||||
delivered = bool(dispatch_result.get("browser_sent"))
|
||||
if channel == "email":
|
||||
|
|
@ -2491,23 +2707,19 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||
logger.warning(f"urgency: reminder dispatch failed: {e}")
|
||||
notify_failed.update(new_urgent)
|
||||
|
||||
# Prune UIDs no longer unread, while retaining every successful
|
||||
# checkpoint observed after this worker acquired the durable lock.
|
||||
notified_uids.intersection_update(all_unread_keys)
|
||||
next_state = {
|
||||
"ts": _time.time(),
|
||||
"owner": owner or "",
|
||||
"total_unread": len(all_unread_keys),
|
||||
"total_urgent": total_urgent,
|
||||
"max_score": max_score,
|
||||
"per_uid": per_uid_scores,
|
||||
"notified_uids": sorted(notified_uids),
|
||||
}
|
||||
next_state = _merge_email_urgency_state(
|
||||
prior,
|
||||
owner=owner,
|
||||
per_uid_scores=per_uid_scores,
|
||||
notified_uids=notified_uids,
|
||||
all_unread_keys=all_unread_keys,
|
||||
fully_scanned_account_ids=fully_scanned_account_ids,
|
||||
timestamp=_time.time(),
|
||||
)
|
||||
return notified_uids, next_state
|
||||
|
||||
try:
|
||||
await _aio.to_thread(
|
||||
_run_email_urgency_state_transaction,
|
||||
await _run_email_urgency_state_transaction(
|
||||
STATE_PATH,
|
||||
STATE_LOCK_DB,
|
||||
_dispatch_and_checkpoint,
|
||||
|
|
|
|||
|
|
@ -1,24 +1,164 @@
|
|||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_urgency_state_transaction_serializes_decision_and_checkpoint(tmp_path):
|
||||
class _Column:
|
||||
def __eq__(self, _other):
|
||||
return True
|
||||
|
||||
def __ne__(self, _other):
|
||||
return True
|
||||
|
||||
|
||||
class _Query:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def filter(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return list(self._rows)
|
||||
|
||||
|
||||
class _Db:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def query(self, _model):
|
||||
return _Query(self._rows())
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class _EmailAccount:
|
||||
enabled = _Column()
|
||||
owner = _Column()
|
||||
imap_user = _Column()
|
||||
from_address = _Column()
|
||||
id = _Column()
|
||||
|
||||
|
||||
class _FakeImap:
|
||||
def __init__(self, account_id, failures, seen_accounts):
|
||||
self.account_id = account_id
|
||||
self.failures = failures
|
||||
self.seen_accounts = seen_accounts
|
||||
|
||||
def select(self, *_args, **_kwargs):
|
||||
if self.account_id in self.failures:
|
||||
raise RuntimeError(f"{self.account_id} unavailable")
|
||||
return "OK", []
|
||||
|
||||
def uid(self, command, *_args):
|
||||
if self.account_id in self.failures:
|
||||
raise RuntimeError(f"{self.account_id} unavailable")
|
||||
if command == "SEARCH":
|
||||
return "OK", [b"1"]
|
||||
query = str(_args[-1]) if _args else ""
|
||||
seen = "\\Seen" if self.account_id in self.seen_accounts else ""
|
||||
flags = f"1 (UID 1 FLAGS ({seen}))".encode()
|
||||
if query == "(UID FLAGS)":
|
||||
return "OK", [flags]
|
||||
raw = (
|
||||
f"From: Sender {self.account_id} <sender-{self.account_id}@example.com>\r\n"
|
||||
f"Subject: Urgent request for {self.account_id}\r\n"
|
||||
f"Message-ID: <{self.account_id}-1@example.com>\r\n"
|
||||
"\r\n"
|
||||
"Please reply immediately."
|
||||
).encode()
|
||||
return "OK", [(flags, raw)]
|
||||
|
||||
def logout(self):
|
||||
return None
|
||||
|
||||
|
||||
def _account(account_id):
|
||||
return SimpleNamespace(
|
||||
id=account_id,
|
||||
enabled=True,
|
||||
owner="alice",
|
||||
imap_user="alice",
|
||||
from_address="alice",
|
||||
)
|
||||
|
||||
|
||||
def _configure_action(monkeypatch, tmp_path, account_ids):
|
||||
from core import database
|
||||
from routes import email_helpers
|
||||
from src import builtin_actions, llm_core, settings, task_endpoint
|
||||
|
||||
runtime = {
|
||||
"accounts": list(account_ids),
|
||||
"failures": set(),
|
||||
"seen_accounts": set(),
|
||||
"settings": {
|
||||
"reminder_channel": "browser",
|
||||
"reminder_llm_synthesis": False,
|
||||
"app_public_url": "",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(builtin_actions, "DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(
|
||||
builtin_actions,
|
||||
"EMAIL_URGENCY_CACHE_DIR",
|
||||
str(tmp_path / "urgency-cache"),
|
||||
)
|
||||
monkeypatch.setattr(database, "EmailAccount", _EmailAccount)
|
||||
monkeypatch.setattr(
|
||||
database,
|
||||
"SessionLocal",
|
||||
lambda: _Db(lambda: [_account(value) for value in runtime["accounts"]]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
task_endpoint,
|
||||
"resolve_task_candidates",
|
||||
lambda *args, **kwargs: [("http://llm", "model", {})],
|
||||
)
|
||||
|
||||
async def fake_fallback(*_args, **_kwargs):
|
||||
return '{"score": 3, "reason": "urgent"}'
|
||||
|
||||
monkeypatch.setattr(llm_core, "llm_call_async_with_fallback", fake_fallback)
|
||||
monkeypatch.setattr(settings, "load_settings", lambda: dict(runtime["settings"]))
|
||||
monkeypatch.setattr(
|
||||
email_helpers,
|
||||
"SCHEDULED_DB",
|
||||
tmp_path / "scheduled-emails.db",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
email_helpers,
|
||||
"_imap_connect",
|
||||
lambda account_id=None, **_kwargs: _FakeImap(
|
||||
str(account_id), runtime["failures"], runtime["seen_accounts"]
|
||||
),
|
||||
)
|
||||
return builtin_actions, runtime
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_urgency_state_transaction_serializes_decision_and_checkpoint(tmp_path):
|
||||
"""A later worker must observe the first worker's delivered UID."""
|
||||
from src.builtin_actions import _run_email_urgency_state_transaction
|
||||
|
||||
state_path = tmp_path / "email_urgency_state_alice.json"
|
||||
lock_db = tmp_path / "scheduled-emails.db"
|
||||
first_entered = threading.Event()
|
||||
allow_first_to_finish = threading.Event()
|
||||
second_entered = threading.Event()
|
||||
lock_db = tmp_path / "urgency.lock.sqlite3"
|
||||
first_entered = asyncio.Event()
|
||||
allow_first_to_finish = asyncio.Event()
|
||||
second_entered = asyncio.Event()
|
||||
deliveries = []
|
||||
|
||||
def operation(name):
|
||||
def update(prior):
|
||||
async def operation(name):
|
||||
async def update(prior):
|
||||
if name == "first":
|
||||
first_entered.set()
|
||||
assert allow_first_to_finish.wait(timeout=2)
|
||||
await allow_first_to_finish.wait()
|
||||
else:
|
||||
second_entered.set()
|
||||
|
||||
|
|
@ -32,25 +172,322 @@ def test_urgency_state_transaction_serializes_decision_and_checkpoint(tmp_path):
|
|||
"notified_uids": sorted(notified),
|
||||
}
|
||||
|
||||
return _run_email_urgency_state_transaction(
|
||||
return await _run_email_urgency_state_transaction(
|
||||
state_path,
|
||||
lock_db,
|
||||
update,
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
first = executor.submit(operation, "first")
|
||||
assert first_entered.wait(timeout=2)
|
||||
second = executor.submit(operation, "second")
|
||||
assert not second_entered.wait(timeout=0.2)
|
||||
allow_first_to_finish.set()
|
||||
|
||||
assert first.result(timeout=2) is True
|
||||
assert second.result(timeout=2) is False
|
||||
first = asyncio.create_task(operation("first"))
|
||||
await asyncio.wait_for(first_entered.wait(), timeout=2)
|
||||
second = asyncio.create_task(operation("second"))
|
||||
await asyncio.sleep(0.1)
|
||||
assert not second_entered.is_set()
|
||||
allow_first_to_finish.set()
|
||||
|
||||
assert await asyncio.wait_for(first, timeout=2) is True
|
||||
assert await asyncio.wait_for(second, timeout=2) is False
|
||||
assert deliveries == ["first"]
|
||||
assert json.loads(state_path.read_text(encoding="utf-8")) == {
|
||||
"owner": "alice",
|
||||
"notified_uids": ["acct:42"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_waiting_transaction_cancellation_does_not_leak_lock(tmp_path):
|
||||
from src.builtin_actions import _run_email_urgency_state_transaction
|
||||
|
||||
state_path = tmp_path / "email_urgency_state_alice.json"
|
||||
lock_db = tmp_path / "urgency.lock.sqlite3"
|
||||
first_entered = asyncio.Event()
|
||||
release_first = asyncio.Event()
|
||||
|
||||
async def first_operation(_prior):
|
||||
first_entered.set()
|
||||
await release_first.wait()
|
||||
return None, {"notified_uids": ["acct:1"]}
|
||||
|
||||
async def later_operation(prior):
|
||||
return None, prior
|
||||
|
||||
first = asyncio.create_task(
|
||||
_run_email_urgency_state_transaction(
|
||||
state_path, lock_db, first_operation
|
||||
)
|
||||
)
|
||||
await asyncio.wait_for(first_entered.wait(), timeout=2)
|
||||
waiting = asyncio.create_task(
|
||||
_run_email_urgency_state_transaction(
|
||||
state_path, lock_db, later_operation
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
waiting.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await asyncio.wait_for(waiting, timeout=2)
|
||||
|
||||
release_first.set()
|
||||
await asyncio.wait_for(first, timeout=2)
|
||||
await asyncio.wait_for(
|
||||
_run_email_urgency_state_transaction(
|
||||
state_path, lock_db, later_operation
|
||||
),
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_dispatch_stays_on_app_loop_and_queues_browser_notification(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
import routes.note_routes as note_routes
|
||||
from src import endpoint_resolver, llm_core
|
||||
from src.task_scheduler import TaskScheduler
|
||||
|
||||
builtin_actions, runtime = _configure_action(
|
||||
monkeypatch, tmp_path, ["acct-a"]
|
||||
)
|
||||
runtime["settings"]["reminder_llm_synthesis"] = True
|
||||
monkeypatch.setattr(note_routes, "DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(
|
||||
endpoint_resolver,
|
||||
"resolve_endpoint",
|
||||
lambda *_args, **_kwargs: (
|
||||
"https://api.openai.com/v1",
|
||||
"utility-model",
|
||||
{},
|
||||
),
|
||||
)
|
||||
|
||||
expected_loop = asyncio.get_running_loop()
|
||||
expected_thread = threading.get_ident()
|
||||
synthesis_loops = []
|
||||
shared_client = SimpleNamespace(is_closed=False)
|
||||
monkeypatch.setattr(llm_core, "_http_client", shared_client)
|
||||
monkeypatch.setattr(llm_core, "_response_cache", {})
|
||||
|
||||
class _Response:
|
||||
is_success = True
|
||||
status_code = 200
|
||||
text = "ok"
|
||||
|
||||
@staticmethod
|
||||
def json():
|
||||
return {
|
||||
"choices": [
|
||||
{"message": {"content": "Synthesized urgency reminder."}}
|
||||
]
|
||||
}
|
||||
|
||||
async def fake_http_post(client, *_args, **_kwargs):
|
||||
synthesis_loops.append(asyncio.get_running_loop())
|
||||
assert client is shared_client
|
||||
return _Response()
|
||||
|
||||
monkeypatch.setattr(
|
||||
llm_core,
|
||||
"httpx_post_kimi_aware_async",
|
||||
fake_http_post,
|
||||
)
|
||||
|
||||
scheduler = TaskScheduler(None)
|
||||
notification_threads = []
|
||||
original_add = scheduler.add_notification
|
||||
|
||||
def checked_add(*args, **kwargs):
|
||||
notification_threads.append(threading.get_ident())
|
||||
return original_add(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(scheduler, "add_notification", checked_add)
|
||||
monkeypatch.setattr(note_routes, "_scheduler_ref", scheduler)
|
||||
|
||||
message, ok = await builtin_actions.action_check_email_urgency("alice")
|
||||
|
||||
assert ok is True
|
||||
assert "notified 1" in message
|
||||
assert synthesis_loops == [expected_loop]
|
||||
assert notification_threads == [expected_thread]
|
||||
notifications = scheduler.pop_notifications(owner="alice")
|
||||
assert len(notifications) == 1
|
||||
assert notifications[0]["body"] == "Synthesized urgency reminder."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_cancellation_rolls_back_without_checkpoint(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
import routes.note_routes as note_routes
|
||||
|
||||
builtin_actions, _runtime = _configure_action(
|
||||
monkeypatch, tmp_path, ["acct-a"]
|
||||
)
|
||||
state_path = tmp_path / "email_urgency_state_alice.json"
|
||||
state_path.write_text(
|
||||
json.dumps({"owner": "alice", "per_uid": {}, "notified_uids": []}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
dispatch_cancelled = threading.Event()
|
||||
|
||||
async def blocked_dispatch(**_kwargs):
|
||||
entered.set()
|
||||
try:
|
||||
while not release.is_set():
|
||||
await asyncio.sleep(0.01)
|
||||
except asyncio.CancelledError:
|
||||
dispatch_cancelled.set()
|
||||
raise
|
||||
return {
|
||||
"browser_sent": True,
|
||||
"email_sent": False,
|
||||
"ntfy_sent": False,
|
||||
"webhook_sent": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(note_routes, "dispatch_reminder", blocked_dispatch)
|
||||
task = asyncio.create_task(
|
||||
builtin_actions.action_check_email_urgency("alice")
|
||||
)
|
||||
for _ in range(200):
|
||||
if entered.is_set():
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert entered.is_set()
|
||||
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
release.set()
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert dispatch_cancelled.is_set()
|
||||
assert json.loads(state_path.read_text(encoding="utf-8")) == {
|
||||
"owner": "alice",
|
||||
"per_uid": {},
|
||||
"notified_uids": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_account_scoped_actions_merge_disjoint_checkpoints(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
import routes.note_routes as note_routes
|
||||
|
||||
builtin_actions, runtime = _configure_action(
|
||||
monkeypatch, tmp_path, ["acct-a"]
|
||||
)
|
||||
deliveries = []
|
||||
|
||||
async def delivered(**kwargs):
|
||||
deliveries.append(kwargs["note_body"])
|
||||
return {
|
||||
"browser_sent": True,
|
||||
"email_sent": False,
|
||||
"ntfy_sent": False,
|
||||
"webhook_sent": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(note_routes, "dispatch_reminder", delivered)
|
||||
|
||||
await builtin_actions.action_check_email_urgency(
|
||||
"alice", prompt='{"account_id":"acct-a"}'
|
||||
)
|
||||
runtime["accounts"] = ["acct-b"]
|
||||
await builtin_actions.action_check_email_urgency(
|
||||
"alice", prompt='{"account_id":"acct-b"}'
|
||||
)
|
||||
runtime["accounts"] = ["acct-a"]
|
||||
await builtin_actions.action_check_email_urgency(
|
||||
"alice", prompt='{"account_id":"acct-a"}'
|
||||
)
|
||||
|
||||
state = json.loads(
|
||||
(tmp_path / "email_urgency_state_alice.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert len(deliveries) == 2
|
||||
assert "Urgent request for acct-a" in deliveries[0]
|
||||
assert "Urgent request for acct-b" in deliveries[1]
|
||||
assert state["notified_uids"] == ["acct-a:1", "acct-b:1"]
|
||||
assert set(state["per_uid"]) == {"acct-a:1", "acct-b:1"}
|
||||
assert state["total_unread"] == 2
|
||||
assert state["total_urgent"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_account_scan_preserves_checkpoint_until_recovery(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
import routes.note_routes as note_routes
|
||||
|
||||
builtin_actions, runtime = _configure_action(
|
||||
monkeypatch, tmp_path, ["acct-a", "acct-b"]
|
||||
)
|
||||
deliveries = []
|
||||
|
||||
async def delivered(**kwargs):
|
||||
deliveries.append(kwargs["note_body"])
|
||||
return {
|
||||
"browser_sent": True,
|
||||
"email_sent": False,
|
||||
"ntfy_sent": False,
|
||||
"webhook_sent": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(note_routes, "dispatch_reminder", delivered)
|
||||
|
||||
await builtin_actions.action_check_email_urgency("alice")
|
||||
runtime["failures"] = {"acct-b"}
|
||||
await builtin_actions.action_check_email_urgency("alice")
|
||||
|
||||
failed_state = json.loads(
|
||||
(tmp_path / "email_urgency_state_alice.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert failed_state["notified_uids"] == ["acct-a:1", "acct-b:1"]
|
||||
assert set(failed_state["per_uid"]) == {"acct-a:1", "acct-b:1"}
|
||||
|
||||
runtime["failures"] = set()
|
||||
runtime["accounts"] = ["acct-b"]
|
||||
await builtin_actions.action_check_email_urgency(
|
||||
"alice", prompt='{"account_id":"acct-b"}'
|
||||
)
|
||||
|
||||
assert len(deliveries) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cached_flags_refresh_prunes_checkpoint_after_message_is_read(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
import routes.note_routes as note_routes
|
||||
|
||||
builtin_actions, runtime = _configure_action(
|
||||
monkeypatch, tmp_path, ["acct-a"]
|
||||
)
|
||||
|
||||
async def delivered(**_kwargs):
|
||||
return {
|
||||
"browser_sent": True,
|
||||
"email_sent": False,
|
||||
"ntfy_sent": False,
|
||||
"webhook_sent": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(note_routes, "dispatch_reminder", delivered)
|
||||
await builtin_actions.action_check_email_urgency("alice")
|
||||
runtime["seen_accounts"] = {"acct-a"}
|
||||
await builtin_actions.action_check_email_urgency("alice")
|
||||
|
||||
state = json.loads(
|
||||
(tmp_path / "email_urgency_state_alice.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert state["notified_uids"] == []
|
||||
assert state["per_uid"]["acct-a:1"]["unread"] is False
|
||||
assert state["total_unread"] == 0
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue