feat(scouts): deliver_pending drains queue and sends scout_proposal frames
Add ScoutEngine.deliver_pending(user_id, ws) that queries status='queued' rows, fetches metadata via the registered connector, sends scout_proposal WS frames, and flips status to 'delivered'. Add ack_proposal(proposal_id) that flips 'delivered' -> 'acked' (idempotent). Wire both into device_ws.py: deliver_pending fires as a background task after device_hello + register; scout_proposal_ack frames dispatch to ack_proposal in the message loop. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ from sqlalchemy import update
|
|||||||
|
|
||||||
from app.api.routes.scout_setup import handle_journey_message, handle_journey_start
|
from app.api.routes.scout_setup import handle_journey_message, handle_journey_start
|
||||||
from app.config.settings import settings
|
from app.config.settings import settings
|
||||||
|
from app.scouts.engine import ScoutEngine
|
||||||
from app.core.scout_runner import trigger_pending_runs
|
from app.core.scout_runner import trigger_pending_runs
|
||||||
from app.core.scout_session_buffer import session_buffer
|
from app.core.scout_session_buffer import session_buffer
|
||||||
from app.core.brief_agent import run_home_brief, run_project_brief
|
from app.core.brief_agent import run_home_brief, run_project_brief
|
||||||
@@ -118,6 +119,16 @@ async def device_ws(websocket: WebSocket) -> None:
|
|||||||
# Trigger any overdue agent runs now that the device is connected.
|
# Trigger any overdue agent runs now that the device is connected.
|
||||||
asyncio.create_task(trigger_pending_runs(user_id, device_id, device_manager))
|
asyncio.create_task(trigger_pending_runs(user_id, device_id, device_manager))
|
||||||
|
|
||||||
|
# Drain any queued scout proposals and deliver to the client (non-blocking).
|
||||||
|
async def _deliver_pending_safe() -> None:
|
||||||
|
import uuid as _uuid # noqa: PLC0415
|
||||||
|
try:
|
||||||
|
await ScoutEngine().deliver_pending(_uuid.UUID(user_id), websocket)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("scout deliver_pending failed for user %s", user_id)
|
||||||
|
|
||||||
|
asyncio.create_task(_deliver_pending_safe())
|
||||||
|
|
||||||
# ── 4. Concurrent message loop + heartbeat ────────────────────────
|
# ── 4. Concurrent message loop + heartbeat ────────────────────────
|
||||||
try:
|
try:
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
@@ -204,6 +215,14 @@ async def _message_loop(websocket: WebSocket, user_id: str) -> None:
|
|||||||
_handle_contextual_scope_update(websocket, user_id, frame)
|
_handle_contextual_scope_update(websocket, user_id, frame)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
elif frame_type == "scout_proposal_ack":
|
||||||
|
proposal_id = frame.get("proposal_id")
|
||||||
|
if proposal_id:
|
||||||
|
try:
|
||||||
|
await ScoutEngine().ack_proposal(proposal_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("scout ack_proposal failed for %s", proposal_id)
|
||||||
|
|
||||||
elif frame_type == "pong":
|
elif frame_type == "pong":
|
||||||
# Heartbeat ack — nothing to do, connection is alive.
|
# Heartbeat ack — nothing to do, connection is alive.
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -132,6 +132,61 @@ class ScoutEngine:
|
|||||||
ref.source_msg_ref,
|
ref.source_msg_ref,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def deliver_pending(self, user_id: uuid.UUID, ws) -> None:
|
||||||
|
"""Drain status='queued' rows for user, send scout_proposal WS frames, flip to 'delivered'."""
|
||||||
|
from app.scouts.connectors.base import ItemRef # noqa: PLC0415
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(ScoutTriageQueue).where(
|
||||||
|
ScoutTriageQueue.user_id == str(user_id),
|
||||||
|
ScoutTriageQueue.status == "queued",
|
||||||
|
)
|
||||||
|
)).scalars().all()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
connector = get_connector(row.source_type)
|
||||||
|
except KeyError:
|
||||||
|
logger.warning("deliver_pending: no connector for %s", row.source_type)
|
||||||
|
continue
|
||||||
|
scout = await session.get(CloudScoutConfig, row.scout_id)
|
||||||
|
if scout is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
meta = await connector.fetch_metadata(scout, ItemRef(source_msg_ref=row.source_msg_ref))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("deliver_pending: fetch_metadata failed")
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"type": "scout_proposal",
|
||||||
|
"proposal": {
|
||||||
|
"id": row.id,
|
||||||
|
"scout_id": row.scout_id,
|
||||||
|
"source_type": row.source_type,
|
||||||
|
"source_msg_ref": row.source_msg_ref,
|
||||||
|
"raw_subject": meta.subject,
|
||||||
|
"raw_snippet": meta.snippet,
|
||||||
|
"category": "unprocessed",
|
||||||
|
"payload": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await ws.send_json(payload)
|
||||||
|
row.status = "delivered"
|
||||||
|
row.delivered_at = datetime.now(tz=timezone.utc)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def ack_proposal(self, proposal_id: str) -> None:
|
||||||
|
"""Flip a delivered proposal to acked. Idempotent — no-op if already acked."""
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
row = await session.get(ScoutTriageQueue, proposal_id)
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
row.status = "acked"
|
||||||
|
row.acked_at = datetime.now(tz=timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
async def _triage_llm(self, scout: CloudScoutConfig, content: ItemContent) -> TriageVerdict:
|
async def _triage_llm(self, scout: CloudScoutConfig, content: ItemContent) -> TriageVerdict:
|
||||||
"""Stub — real implementation in Task 24."""
|
"""Stub — real implementation in Task 24."""
|
||||||
raise NotImplementedError("Real triage LLM call lands in Task 24")
|
raise NotImplementedError("Real triage LLM call lands in Task 24")
|
||||||
|
|||||||
@@ -170,3 +170,48 @@ async def test_idempotent_replay(monkeypatch):
|
|||||||
async with _TestSessionLocal() as session:
|
async with _TestSessionLocal() as session:
|
||||||
rows = (await session.execute(select(ScoutTriageQueue))).scalars().all()
|
rows = (await session.execute(select(ScoutTriageQueue))).scalars().all()
|
||||||
assert len(rows) == 1, "Replay must not create duplicate queue rows"
|
assert len(rows) == 1, "Replay must not create duplicate queue rows"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deliver_pending_sends_one_frame_per_queued_row(monkeypatch):
|
||||||
|
user_id = "00000000-0000-0000-0000-000000000003"
|
||||||
|
scout_id = str(uuid.uuid4())
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
|
||||||
|
async with _TestSessionLocal() as session:
|
||||||
|
session.add(CloudScoutConfig(
|
||||||
|
id=scout_id, user_id=user_id, provider="gmail", name="Test",
|
||||||
|
data_types=[], prompt_template="", schedule_cron="0 * * * *",
|
||||||
|
enabled=True, auto_trash_spam=False, device_inactivity_pause_days=14,
|
||||||
|
))
|
||||||
|
for i in range(3):
|
||||||
|
session.add(ScoutTriageQueue(
|
||||||
|
id=str(uuid.uuid4()), user_id=user_id, scout_id=scout_id,
|
||||||
|
source_type="gmail", source_msg_ref=f"msg-{i}",
|
||||||
|
triage_verdict="relevant", status="queued",
|
||||||
|
triaged_at=now, expires_at=now + timedelta(days=30),
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
connector = AsyncMock()
|
||||||
|
connector.source_type = "gmail"
|
||||||
|
connector.fetch_metadata = AsyncMock(side_effect=lambda scout, ref: ItemMetadata(
|
||||||
|
subject=f"sub-{ref.source_msg_ref}", snippet=f"snip-{ref.source_msg_ref}",
|
||||||
|
))
|
||||||
|
register_connector(connector)
|
||||||
|
|
||||||
|
sent = []
|
||||||
|
ws = AsyncMock()
|
||||||
|
ws.send_json = AsyncMock(side_effect=lambda payload: sent.append(payload))
|
||||||
|
|
||||||
|
engine = ScoutEngine(session_factory=_TestSessionLocal)
|
||||||
|
await engine.deliver_pending(uuid.UUID(user_id), ws)
|
||||||
|
|
||||||
|
assert len(sent) == 3
|
||||||
|
assert all(s["type"] == "scout_proposal" for s in sent)
|
||||||
|
subjects = {s["proposal"]["raw_subject"] for s in sent}
|
||||||
|
assert subjects == {"sub-msg-0", "sub-msg-1", "sub-msg-2"}
|
||||||
|
async with _TestSessionLocal() as session:
|
||||||
|
rows = (await session.execute(select(ScoutTriageQueue))).scalars().all()
|
||||||
|
assert all(r.status == "delivered" for r in rows)
|
||||||
|
assert all(r.delivered_at is not None for r in rows)
|
||||||
|
|||||||
Reference in New Issue
Block a user