Skip to content

Commit 538b3da

Browse files
author
dev-pd
committed
Async runs via priority queue: user jobs HIGH, background learning LOW; single worker; UI polls
1 parent d4754ac commit 538b3da

14 files changed

Lines changed: 382 additions & 97 deletions

File tree

backend/app/api/run.py

Lines changed: 33 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,21 @@
1-
"""Live agent-run endpoint — audit a real URL with A and B.
1+
"""Async agent-run endpoints — audit a real URL with A and B via the job queue.
22
3-
Runs both agents live (real page fetch + live SERP + Anthropic) on any URL +
4-
keyword the user enters, and records the results so a later replay is free. There
5-
is no answer key for an arbitrary real page, so this returns recommendations and
6-
cost, not scores — the scored A-vs-B proof lives on the planted benchmark.
3+
`POST /run` enqueues a HIGH-priority job and returns a `job_id` immediately; the
4+
single worker runs both agents (B learning on), and the UI polls `GET /run/{job_id}`
5+
until it is done. Going through the queue means a user run jumps ahead of background
6+
learning, waiting at most for the one page already in flight.
77
"""
88

99
from __future__ import annotations
1010

11-
import httpx
11+
import uuid
12+
1213
from fastapi import APIRouter, HTTPException
1314
from pydantic import BaseModel
1415

15-
from app.agents.agent_a import run_agent_a
16-
from app.agents.agent_b import run_agent_b
17-
from app.core.exceptions import ReplayMissError
18-
from app.memory.janitor import run_janitor
19-
from app.models import AgentRunResult
20-
from app.repositories.diary import DiaryRepository
21-
from app.tools.fetch_page import FetchPageInput, FetchPageTool
22-
from app.tools.registry import build_default_registry
16+
from app.jobs.queue import HIGH, Job, get_queue
17+
from app.models import RunJobRecord
18+
from app.repositories.run_results import RunResultRepository
2319

2420
router = APIRouter(prefix="/run", tags=["run"])
2521

@@ -29,49 +25,29 @@ class RunRequest(BaseModel):
2925
keyword: str
3026

3127

32-
class RunResponse(BaseModel):
33-
a: AgentRunResult
34-
b: AgentRunResult
35-
note: str | None = (
36-
None # set when the page body couldn't be fetched (SERP-only run)
37-
)
28+
class RunAccepted(BaseModel):
29+
job_id: str
30+
3831

32+
@router.post("", response_model=RunAccepted, status_code=202)
33+
async def run(req: RunRequest) -> RunAccepted:
34+
"""Enqueue an A-vs-B audit; the UI polls GET /run/{job_id} for the result."""
35+
job_id = uuid.uuid4().hex
36+
await RunResultRepository().create(
37+
RunJobRecord(job_id=job_id, url=req.url, keyword=req.keyword)
38+
)
39+
get_queue().enqueue(
40+
Job(
41+
name="run", args={"job_id": job_id, "url": req.url, "keyword": req.keyword}
42+
),
43+
priority=HIGH,
44+
)
45+
return RunAccepted(job_id=job_id)
3946

40-
@router.post("", response_model=RunResponse)
41-
async def run(req: RunRequest) -> RunResponse:
42-
try:
43-
# Probe the page once. A bot-block no longer fails the run — fetch_page degrades
44-
# to a SERP-only audit and tells the user via `note`.
45-
probe = await FetchPageTool(replay=False).run(FetchPageInput(url=req.url))
46-
a = await run_agent_a(
47-
req.url,
48-
req.keyword,
49-
registry=build_default_registry(replay=False),
50-
replay=False,
51-
)
52-
# B learns on every live run: read the playbook, audit, jot one diary lesson…
53-
b = await run_agent_b(
54-
req.url,
55-
req.keyword,
56-
registry=build_default_registry(replay=False),
57-
replay=False,
58-
memory_on=True,
59-
learn=True,
60-
run_index=await DiaryRepository().next_run(),
61-
)
62-
# …then consolidate the diary into the playbook so the NEXT run sees it.
63-
await run_janitor()
64-
except httpx.HTTPError as exc:
65-
# A page bot-block degrades gracefully (see fetch_page); this only fires for
66-
# other live HTTP failures, e.g. the SERP call.
67-
raise HTTPException(
68-
status_code=502,
69-
detail=f"Live request failed: {exc.__class__.__name__}. Try again or a different page.",
70-
) from exc
71-
except ReplayMissError as exc:
72-
raise HTTPException(status_code=409, detail=str(exc)) from exc
73-
except RuntimeError as exc:
74-
# e.g. live SERP needs SERP_API_KEY
75-
raise HTTPException(status_code=502, detail=str(exc)) from exc
7647

77-
return RunResponse(a=a, b=b, note=None if probe.fetch_ok else probe.fetch_note)
48+
@router.get("/{job_id}", response_model=RunJobRecord)
49+
async def run_status(job_id: str) -> RunJobRecord:
50+
record = await RunResultRepository().get(job_id)
51+
if record is None:
52+
raise HTTPException(status_code=404, detail="No such run job.")
53+
return record

backend/app/jobs/handlers.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Job handlers — the work the single worker performs, by job name.
2+
3+
- `run` : a user's A-vs-B audit (HIGH priority). Fills a RunJobRecord the UI polls.
4+
- `seed_page` : learn from one discovered page (LOW priority, background).
5+
- `janitor` : consolidate the diary into the playbook (LOW priority).
6+
7+
All are one page / one consolidation each, so a user `run` only ever waits for the
8+
single background page in flight — never a whole pass.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import logging
14+
from collections.abc import Awaitable, Callable
15+
from typing import Any
16+
17+
from app.agents.agent_a import run_agent_a
18+
from app.agents.agent_b import run_agent_b
19+
from app.memory.janitor import run_janitor
20+
from app.models import JobStatus
21+
from app.repositories.diary import DiaryRepository
22+
from app.repositories.run_results import RunResultRepository
23+
from app.tools.fetch_page import FetchPageInput, FetchPageTool
24+
from app.tools.registry import build_default_registry
25+
26+
logger = logging.getLogger("worker")
27+
28+
29+
async def handle_run(args: dict[str, Any]) -> None:
30+
"""Run both agents on the user's page; B learns; consolidate; record the result."""
31+
job_id, url, keyword = args["job_id"], args["url"], args["keyword"]
32+
results = RunResultRepository()
33+
await results.update(job_id, status=JobStatus.RUNNING)
34+
try:
35+
probe = await FetchPageTool(replay=False).run(FetchPageInput(url=url))
36+
a = await run_agent_a(
37+
url, keyword, registry=build_default_registry(replay=False), replay=False
38+
)
39+
b = await run_agent_b(
40+
url,
41+
keyword,
42+
registry=build_default_registry(replay=False),
43+
replay=False,
44+
memory_on=True,
45+
learn=True,
46+
run_index=await DiaryRepository().next_run(),
47+
)
48+
await run_janitor()
49+
await results.update(
50+
job_id,
51+
status=JobStatus.DONE,
52+
a=a.model_dump(),
53+
b=b.model_dump(),
54+
note=None if probe.fetch_ok else probe.fetch_note,
55+
)
56+
except Exception as exc: # surface a clean message to the polling UI
57+
logger.exception("run job %s failed", job_id)
58+
await results.update(job_id, status=JobStatus.ERROR, error=str(exc))
59+
60+
61+
async def handle_seed_page(args: dict[str, Any]) -> None:
62+
"""Learn from one discovered page (no janitor here — that is its own job)."""
63+
await run_agent_b(
64+
args["url"],
65+
args["keyword"],
66+
registry=build_default_registry(replay=False),
67+
replay=False,
68+
memory_on=True,
69+
learn=True,
70+
run_index=await DiaryRepository().next_run(),
71+
)
72+
73+
74+
async def handle_janitor(args: dict[str, Any]) -> None:
75+
await run_janitor()
76+
77+
78+
HANDLERS: dict[str, Callable[[dict[str, Any]], Awaitable[None]]] = {
79+
"run": handle_run,
80+
"seed_page": handle_seed_page,
81+
"janitor": handle_janitor,
82+
}

backend/app/jobs/queue.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,18 @@ def pop(self) -> Job | None:
112112
job = Job.model_validate_json(raw)
113113
self._backend.clear_pending(job.dedup_key()) # no longer pending
114114
return job
115+
116+
117+
_queue: JobQueue | None = None
118+
119+
120+
def get_queue() -> JobQueue:
121+
"""The shared Redis-backed queue (built once per process)."""
122+
global _queue
123+
if _queue is None:
124+
import redis
125+
126+
from app.core.config import settings
127+
128+
_queue = JobQueue(RedisBackend(redis.from_url(settings.redis_url)))
129+
return _queue

backend/app/jobs/scheduler.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,39 @@
11
"""Scheduler entrypoint.
22
3-
When `SEED_IDLE_ENABLED` is set, this cycles the curated seed jobs on a timer to
4-
grow Agent B's memory between user jobs (no web roaming — it only works the
5-
hand-picked seed list). Off by default, so it idles and spends nothing until you
6-
opt in.
3+
On a timer, enqueues a batch of background learning jobs: B discovers fetchable
4+
pages for the given seed topics and queues them (LOW priority) for the worker, plus
5+
a trailing janitor. The worker drains them one page at a time, always yielding to
6+
user runs (HIGH). On by default, but only runs when live keys are present, so a
7+
keyless clone still boots clean and silent.
78
"""
89

910
import asyncio
1011
import logging
1112
import time
1213

1314
from app.core.config import settings
14-
from app.jobs.seed_runner import run_idle_loop
15+
from app.jobs.seed_runner import run_enqueue_loop
1516

1617
logging.basicConfig(level=logging.INFO)
1718
logger = logging.getLogger("scheduler")
1819

1920

2021
def main() -> None:
21-
# Auto-learn in the background, but only when we can actually run live (key present).
22-
if settings.seed_idle_enabled and settings.anthropic_api_key:
22+
# Enqueue background learning, but only when we can actually run live.
23+
if (
24+
settings.seed_idle_enabled
25+
and settings.anthropic_api_key
26+
and settings.serp_api_key
27+
):
2328
logger.info(
24-
"seed idle loop on; cycling curated jobs every %ds",
29+
"seed scheduler on; enqueuing a learning batch every %ds",
2530
settings.seed_idle_interval_seconds,
2631
)
27-
asyncio.run(run_idle_loop(interval_seconds=settings.seed_idle_interval_seconds))
32+
asyncio.run(
33+
run_enqueue_loop(interval_seconds=settings.seed_idle_interval_seconds)
34+
)
2835
return
29-
logger.info("scheduler idle (no Anthropic key, or seeding disabled).")
36+
logger.info("scheduler idle (missing live keys, or seeding disabled).")
3037
while True:
3138
time.sleep(3600)
3239

backend/app/jobs/seed_runner.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from app.agents.agent_b import run_agent_b
2222
from app.jobs.discovery import discover_pages, page_is_fetchable
23+
from app.jobs.queue import LOW, Job, JobQueue, get_queue
2324
from app.jobs.seed_topics import SEED_TOPICS
2425
from app.memory.janitor import run_janitor
2526
from app.models import AgentRunResult
@@ -102,11 +103,45 @@ async def run_seed_pass(
102103
)
103104

104105

105-
async def run_idle_loop(*, interval_seconds: int, passes: int = 1) -> None:
106-
"""Forever: run one seed pass, then sleep. Gated by config; off without a key."""
106+
async def enqueue_seed_pass(
107+
queue: JobQueue | None = None,
108+
*,
109+
topics: list[str] | None = None,
110+
pages_per_topic: int = 4,
111+
replay: bool = False,
112+
discover: DiscoverFn | None = None,
113+
probe: ProbeFn | None = None,
114+
) -> int:
115+
"""Discover fetchable pages per topic and enqueue them as LOW-priority jobs.
116+
117+
The worker does the actual auditing one page at a time, so user runs (HIGH) keep
118+
jumping ahead. A trailing janitor job consolidates after the batch.
119+
"""
120+
queue = queue or get_queue()
121+
topics = topics if topics is not None else SEED_TOPICS
122+
discover = discover or (lambda kw, n: discover_pages(kw, n, replay=replay))
123+
probe = probe or (lambda url: page_is_fetchable(url, replay=replay))
124+
125+
enqueued = 0
126+
for topic in topics:
127+
for url in await discover(topic, pages_per_topic):
128+
if not await probe(url):
129+
continue
130+
job = Job(
131+
name="seed_page", args={"url": url, "keyword": topic}, key=f"seed:{url}"
132+
)
133+
if queue.enqueue(job, priority=LOW):
134+
enqueued += 1
135+
queue.enqueue(Job(name="janitor", key="janitor"), priority=LOW)
136+
logger.info("enqueued %d seed pages + janitor", enqueued)
137+
return enqueued
138+
139+
140+
async def run_enqueue_loop(*, interval_seconds: int, pages_per_topic: int = 4) -> None:
141+
"""Forever: enqueue a background learning batch, then sleep. Off without keys."""
107142
while True:
108143
try:
109-
await run_seed_pass(passes=passes)
110-
except Exception: # never let one bad pass kill the idle loop
111-
logger.exception("seed pass failed; will retry next tick")
144+
await enqueue_seed_pass(pages_per_topic=pages_per_topic)
145+
except Exception: # never let one bad tick kill the loop
146+
logger.exception("seed enqueue failed; will retry next tick")
112147
await asyncio.sleep(interval_seconds)

backend/app/jobs/worker.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,45 @@
1-
"""Queue worker entrypoint (stub).
1+
"""Queue worker — the single drain loop.
22
3-
The real Redis-backed worker arrives in Phase 4. Until then this only exists so
4-
`python -m app.jobs.worker` is a valid entrypoint; Compose runs it as an idle
5-
stub. Run directly, it logs once and idles.
3+
Pops one job at a time, high priority first, and runs its handler. Because there is
4+
exactly one worker and it never preempts a running job, a user `run` (HIGH) jumps
5+
ahead of all pending background `seed_page`/`janitor` work (LOW) — waiting at most
6+
for the one background page already in flight.
67
"""
78

9+
from __future__ import annotations
10+
11+
import asyncio
812
import logging
9-
import time
13+
14+
from app.jobs.handlers import HANDLERS
15+
from app.jobs.queue import get_queue
1016

1117
logging.basicConfig(level=logging.INFO)
1218
logger = logging.getLogger("worker")
1319

20+
IDLE_SLEEP_SECONDS = 1.0
1421

15-
def main() -> None:
16-
logger.info("worker stub started; queue is wired in Phase 4. Idling.")
22+
23+
async def drain() -> None:
24+
queue = get_queue()
25+
logger.info("worker started; draining HIGH then LOW.")
1726
while True:
18-
time.sleep(3600)
27+
job = queue.pop()
28+
if job is None:
29+
await asyncio.sleep(IDLE_SLEEP_SECONDS)
30+
continue
31+
handler = HANDLERS.get(job.name)
32+
if handler is None:
33+
logger.warning("no handler for job %r; dropping", job.name)
34+
continue
35+
try:
36+
await handler(job.args)
37+
except Exception: # one bad job must not kill the worker
38+
logger.exception("job %r failed", job.name)
39+
40+
41+
def main() -> None:
42+
asyncio.run(drain())
1943

2044

2145
if __name__ == "__main__":

backend/app/models/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Domain models. Import from here: ``from app.models import Page, Recommendation``."""
22

3+
from app.models.job import JobStatus, RunJobRecord
34
from app.models.page import Heading, Page
45
from app.models.proactive import BlindSpot, MonitorAlert
56
from app.models.recommendation import Recommendation, RecType, Severity, Stage
@@ -12,10 +13,12 @@
1213
"BlindSpot",
1314
"CheckResult",
1415
"Heading",
16+
"JobStatus",
1517
"MonitorAlert",
1618
"Page",
1719
"RecType",
1820
"Recommendation",
21+
"RunJobRecord",
1922
"SerpEntry",
2023
"SerpResult",
2124
"Severity",

0 commit comments

Comments
 (0)