Skip to content

Commit f30f140

Browse files
hp-8Harsh Patadia
andauthored
fix(roast): persist jobs + SSE in Redis to stop "job not found" 404s (#4)
Roast job state and the SSE event log lived in process RAM, so any worker restart (idle spin-down, OOM, redeploy on Render free tier) wiped the store and made /stream and poll return 404 mid-run. - New services/job_store.py: pluggable store. RedisJobStore (when REDIS_URL is set) persists job state, a replayable event log, and the cancel flag with a TTL; InMemoryJobStore keeps the original behaviour for dev/test. Factory falls back to in-memory if Redis is unreachable so boot never fails. - Pipeline checkpoints state at each stage + on completion; SSE now replays the full event log from the start so reconnecting/late clients catch up. - Staleness guard: a non-terminal job with a dead pipeline thread surfaces as failed instead of hanging forever. - Cancel uses a Redis flag and lets state expire via TTL (reconnect sees cancelled, not 404). - Add redis dep (requirements/-prod/pyproject/uv.lock), REDIS_URL + ROAST_JOB_TTL + ROAST_STALE_SECONDS config, render.yaml + .env.example. - Tests: backend/tests/test_job_store.py (both backends, factory fallback). - Bump transitive form-data 4.0.5 -> 4.0.6 (audit gate, GHSA-hmw2-7cc7-3qxx). Co-authored-by: Harsh Patadia <hpatadi07@gmail.com>
1 parent d73cc51 commit f30f140

11 files changed

Lines changed: 600 additions & 132 deletions

File tree

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ ROAST_AGENT_COUNT=100
6969
ROAST_CONCURRENCY=20
7070
ROAST_MAX_COST_USD=1.00
7171

72+
# Roast job store. Set to a Redis URL (e.g. Upstash rediss://...) so jobs + the
73+
# SSE event log survive worker restarts/redeploys/idle spin-down. Unset → jobs
74+
# live in-process and are lost on restart ("job not found" 404 on /stream).
75+
# REDIS_URL=rediss://default:<token>@<host>:6379
76+
ROAST_JOB_TTL=3600
77+
# A non-terminal job idle this many seconds (pipeline thread died) → failed.
78+
ROAST_STALE_SECONDS=180
79+
7280
# ============================================================
7381
# API hardening (public launch)
7482
# ============================================================

backend/app/api/roast.py

Lines changed: 87 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,27 @@
66
GET /api/roast/<job_id>/stream SSE feed of agent reactions as they land
77
DELETE /api/roast/<job_id> cancel + drop job (best-effort)
88
9-
Jobs live in-process (no DB). They are short-lived (60s typical).
10-
Production deployment will need a real job store (Redis) — out of scope today.
9+
Job state + the SSE event log live in the job store (see services/job_store):
10+
Redis when REDIS_URL is set (survives worker restart / redeploy / idle
11+
spin-down), in-process otherwise. The pipeline still runs in a daemon thread
12+
in this web worker — Redis is a state store, not a task queue.
1113
"""
1214

1315
from __future__ import annotations
1416

1517
import asyncio
1618
import json
1719
import logging
18-
import queue
1920
import threading
2021
import time
2122
import uuid
22-
from dataclasses import dataclass, field
2323
from typing import Any
2424

2525
from flask import Blueprint, Response, jsonify, request, stream_with_context
2626

2727
from ..config import Config
2828
from ..extensions import limiter
29+
from ..services.job_store import RoastJob, make_store
2930
from ..services.swarm import (
3031
AgentReaction,
3132
CHAT_SOFT_CAP,
@@ -46,100 +47,28 @@
4647
roast_bp = Blueprint("roast", __name__)
4748

4849

49-
# ---------- in-process job store ----------
50-
51-
@dataclass
52-
class RoastJob:
53-
job_id: str
54-
status: str = "pending" # pending | parsing | generating_archetypes | running_swarm | reporting | completed | failed | cancelled
55-
progress: float = 0.0 # 0..1
56-
pitch_text: str = ""
57-
swarm_type: str = DEFAULT_SWARM
58-
source: str = "text" # "text" | "deck"
59-
n_agents: int = 0
60-
error: str | None = None
61-
# transient: raw uploaded PDF bytes, discarded after parse (never serialized)
62-
deck_bytes: bytes | None = None
63-
deck_slides: list[dict] = field(default_factory=list)
64-
started_at: float = field(default_factory=time.time)
65-
finished_at: float | None = None
66-
67-
# accumulating outputs
68-
parsed_pitch: dict | None = None
69-
archetypes: list[dict] = field(default_factory=list)
70-
reactions: list[dict] = field(default_factory=list)
71-
report: dict | None = None
72-
usage: dict | None = None
73-
74-
# streaming
75-
event_queue: queue.Queue[dict] = field(default_factory=queue.Queue)
76-
cancelled: threading.Event = field(default_factory=threading.Event)
77-
78-
# per-agent chat history: agent_id -> list[{role, content}]
79-
chats: dict[str, list[dict[str, str]]] = field(default_factory=dict)
80-
81-
def to_dict(self, include_full: bool = False) -> dict[str, Any]:
82-
out: dict[str, Any] = {
83-
"job_id": self.job_id,
84-
"status": self.status,
85-
"progress": round(self.progress, 3),
86-
"swarm_type": self.swarm_type,
87-
"source": self.source,
88-
"n_agents": self.n_agents,
89-
"started_at": self.started_at,
90-
"finished_at": self.finished_at,
91-
"error": self.error,
92-
}
93-
if include_full or self.status == "completed":
94-
out["parsed_pitch"] = self.parsed_pitch
95-
out["archetypes"] = self.archetypes
96-
out["reactions"] = self.reactions
97-
out["report"] = self.report
98-
out["usage"] = self.usage
99-
out["deck_slides"] = self.deck_slides
100-
return out
101-
102-
103-
class _JobStore:
104-
def __init__(self):
105-
self._jobs: dict[str, RoastJob] = {}
106-
self._lock = threading.Lock()
107-
108-
def create(self, pitch_text: str, n_agents: int, swarm_type: str = DEFAULT_SWARM,
109-
source: str = "text", deck_bytes: bytes | None = None) -> RoastJob:
110-
job_id = f"roast_{uuid.uuid4().hex[:16]}"
111-
job = RoastJob(
112-
job_id=job_id, pitch_text=pitch_text, n_agents=n_agents,
113-
swarm_type=swarm_type, source=source, deck_bytes=deck_bytes,
114-
)
115-
with self._lock:
116-
self._jobs[job_id] = job
117-
return job
50+
# ---------- job store ----------
11851

119-
def get(self, job_id: str) -> RoastJob | None:
120-
with self._lock:
121-
return self._jobs.get(job_id)
52+
# Redis-backed when REDIS_URL is set (survives worker restart / redeploy /
53+
# idle spin-down — the cause of "job not found" 404s); in-process otherwise.
54+
_store = make_store(Config.REDIS_URL, Config.ROAST_JOB_TTL)
12255

123-
def drop(self, job_id: str) -> bool:
124-
with self._lock:
125-
return self._jobs.pop(job_id, None) is not None
12656

127-
def gc(self, max_age_seconds: int = 3600) -> int:
128-
"""Drop jobs older than max_age_seconds. Returns count dropped."""
129-
now = time.time()
130-
dropped = 0
131-
with self._lock:
132-
stale_ids = [
133-
jid for jid, j in self._jobs.items()
134-
if (j.finished_at or j.started_at) < now - max_age_seconds
135-
]
136-
for jid in stale_ids:
137-
del self._jobs[jid]
138-
dropped += 1
139-
return dropped
57+
def _new_job(pitch_text: str, n_agents: int, swarm_type: str = DEFAULT_SWARM,
58+
source: str = "text", deck_bytes: bytes | None = None) -> RoastJob:
59+
job_id = f"roast_{uuid.uuid4().hex[:16]}"
60+
return _store.create(RoastJob(
61+
job_id=job_id, pitch_text=pitch_text, n_agents=n_agents,
62+
swarm_type=swarm_type, source=source, deck_bytes=deck_bytes,
63+
))
14064

14165

142-
_store = _JobStore()
66+
def _is_stale(job: RoastJob) -> bool:
67+
"""A non-terminal job whose pipeline thread died (e.g. with the worker):
68+
no progress for ROAST_STALE_SECONDS. Surfaced as failed instead of stuck."""
69+
if job.status in ("completed", "failed", "cancelled"):
70+
return False
71+
return (time.time() - (job.finished_at or job.started_at)) > Config.ROAST_STALE_SECONDS
14372

14473

14574
# ---------- background pipeline ----------
@@ -167,11 +96,8 @@ def _public_error_message(exc: Exception) -> str:
16796

16897

16998
def _push_event(job: RoastJob, event_type: str, payload: Any) -> None:
170-
"""Append an SSE-shaped event to the job queue."""
171-
try:
172-
job.event_queue.put_nowait({"type": event_type, "data": payload})
173-
except queue.Full:
174-
pass # drop event if consumer is slow; status endpoint still works
99+
"""Append an SSE-shaped event to the job's replayable event log."""
100+
_store.append_event(job.job_id, {"type": event_type, "data": payload})
175101

176102

177103
def _run_pipeline(job: RoastJob) -> None:
@@ -184,6 +110,7 @@ def _run_pipeline(job: RoastJob) -> None:
184110
def _stage(name: str, fraction: float) -> float:
185111
job.status = name
186112
job.progress = fraction
113+
_store.persist(job) # checkpoint status before the (slow) stage work
187114
_push_event(job, "status", {"status": name, "progress": fraction})
188115
elapsed = time.time() - t0
189116
print(f"[{job.job_id}] STAGE='{name}' elapsed={elapsed:.1f}s", flush=True)
@@ -209,22 +136,24 @@ def _stage(name: str, fraction: float) -> float:
209136
parser = spec.parser_cls(tracker=tracker)
210137
pitch = parser.parse(job.pitch_text)
211138
job.parsed_pitch = pitch.to_dict()
139+
_store.persist(job)
212140
_push_event(job, "parsed_pitch", pitch.to_dict())
213141
logger.info(f"[{job.job_id}] parsing done in {time.time()-t:.1f}s")
214-
if job.cancelled.is_set():
142+
if _store.is_cancelled(job.job_id):
215143
raise RuntimeError("cancelled")
216144

217145
# Stage 2 — generate archetypes
218146
t = _stage("generating_archetypes", 0.15)
219147
archgen = spec.archgen_cls(tracker=tracker)
220148
archetypes = archgen.generate(pitch, n_archetypes=spec.n_archetypes)
221149
job.archetypes = [a.to_dict() for a in archetypes]
150+
_store.persist(job)
222151
_push_event(job, "archetypes", [a.to_dict() for a in archetypes])
223152
logger.info(
224153
f"[{job.job_id}] archetypes done in {time.time()-t:.1f}s "
225154
f"(got {len(archetypes)})"
226155
)
227-
if job.cancelled.is_set():
156+
if _store.is_cancelled(job.job_id):
228157
raise RuntimeError("cancelled")
229158

230159
# Stage 3 — run swarm
@@ -274,7 +203,7 @@ def _on_reaction(r: AgentReaction) -> None:
274203
f"(got {len(reactions)} reactions)"
275204
)
276205

277-
if job.cancelled.is_set():
206+
if _store.is_cancelled(job.job_id):
278207
raise RuntimeError("cancelled")
279208

280209
# Stage 3.5 — deck diagnosis (deck uploads only): pitch-intelligence EVALUATE
@@ -285,7 +214,7 @@ def _on_reaction(r: AgentReaction) -> None:
285214
deck_diagnosis = diagnosis.to_dict()
286215
_push_event(job, "deck_diagnosis", deck_diagnosis)
287216
logger.info(f"[{job.job_id}] deck diagnosis done in {time.time()-t:.1f}s")
288-
if job.cancelled.is_set():
217+
if _store.is_cancelled(job.job_id):
289218
raise RuntimeError("cancelled")
290219

291220
# Stage 4 — synthesize report
@@ -298,24 +227,26 @@ def _on_reaction(r: AgentReaction) -> None:
298227
_push_event(job, "report", report.to_dict())
299228
logger.info(f"[{job.job_id}] report done in {time.time()-t:.1f}s")
300229

301-
# Done
230+
# Done — persist the full result (all reactions/report) before signalling.
302231
job.status = "completed"
303232
job.progress = 1.0
304233
job.usage = tracker.summary()
305234
job.finished_at = time.time()
235+
_store.persist(job)
306236
_push_event(job, "status", {"status": job.status, "progress": 1.0})
307237
_push_event(job, "usage", job.usage)
308238
_push_event(job, "done", {"job_id": job.job_id})
309239

310240
except Exception as exc:
311-
if job.cancelled.is_set():
241+
if _store.is_cancelled(job.job_id):
312242
job.status = "cancelled"
313243
else:
314244
job.status = "failed"
315245
job.error = _public_error_message(exc)
316246
logger.exception("roast pipeline failed for %s", job.job_id)
317247
job.finished_at = time.time()
318248
job.usage = tracker.summary()
249+
_store.persist(job)
319250
_push_event(job, "status", {"status": job.status, "error": job.error})
320251

321252

@@ -367,7 +298,7 @@ def create_roast():
367298
n_agents = Config.ROAST_AGENT_COUNT
368299
n_agents = max(10, min(n_agents, 500))
369300

370-
job = _store.create(
301+
job = _new_job(
371302
pitch_text="", n_agents=n_agents, swarm_type=swarm_type,
372303
source="deck", deck_bytes=data,
373304
)
@@ -387,7 +318,7 @@ def create_roast():
387318
n_agents = int(body.get("n_agents") or Config.ROAST_AGENT_COUNT)
388319
n_agents = max(10, min(n_agents, 500)) # clamp 10..500
389320

390-
job = _store.create(pitch_text=pitch_text, n_agents=n_agents, swarm_type=swarm_type)
321+
job = _new_job(pitch_text=pitch_text, n_agents=n_agents, swarm_type=swarm_type)
391322

392323
print(f"[{job.job_id}] creating background thread (n_agents={n_agents}, source={job.source})", flush=True)
393324
thread = threading.Thread(target=_run_pipeline, args=(job,), daemon=True)
@@ -403,39 +334,67 @@ def get_roast(job_id: str):
403334
job = _store.get(job_id)
404335
if not job:
405336
return jsonify({"error": "job not found"}), 404
337+
if _is_stale(job):
338+
job.status = "failed"
339+
job.error = _STALE_ERROR
406340
return jsonify(job.to_dict()), 200
407341

408342

343+
# Stream tuning: poll the event log this often for new events (also the
344+
# keepalive cadence). Kept >=1s to stay within Redis free-tier command budgets.
345+
_STREAM_POLL_SECONDS = 1.0
346+
_STALE_ERROR = (
347+
"This run was interrupted before it finished (our server may have restarted). "
348+
"Please start a new roast."
349+
)
350+
351+
409352
@roast_bp.route("/<job_id>/stream", methods=["GET"])
410353
def stream_roast(job_id: str):
411354
"""Server-sent events feed of pipeline events.
412355
413-
Event types: status, parsed_pitch, archetypes, reaction, report, usage, done.
414-
Client should disconnect on `done` or on `status` with status == 'failed'/'cancelled'.
356+
Replays the full event log from the start (so a reconnecting or late client
357+
catches up on everything), then tails new events. Event types: status,
358+
parsed_pitch, archetypes, thinking, reaction, deck_slides, deck_diagnosis,
359+
report, usage, done. Client disconnects on `done` or a failed/cancelled
360+
`status`.
415361
"""
416362
job = _store.get(job_id)
417363
if not job:
418364
return jsonify({"error": "job not found"}), 404
419365

420366
@stream_with_context
421367
def generate():
422-
# Emit current snapshot so reconnecting clients catch up.
368+
# Initial snapshot so a client that connects before any event isn't blank.
423369
yield _sse({"type": "status", "data": {
424370
"status": job.status, "progress": job.progress,
425371
}})
372+
cursor = 0
426373
while True:
427-
try:
428-
event = job.event_queue.get(timeout=30)
429-
yield _sse(event)
430-
if event["type"] == "done" or (
431-
event["type"] == "status"
432-
and event["data"].get("status") in ("failed", "cancelled")
433-
):
434-
return
435-
except queue.Empty:
436-
yield ": keepalive\n\n"
437-
if job.status in ("completed", "failed", "cancelled"):
438-
return
374+
events = _store.get_events(job_id, cursor)
375+
if events:
376+
cursor += len(events)
377+
for event in events:
378+
yield _sse(event)
379+
if event["type"] == "done" or (
380+
event["type"] == "status"
381+
and event["data"].get("status") in ("failed", "cancelled")
382+
):
383+
return
384+
continue
385+
# No new events — keepalive + terminal/staleness checks.
386+
yield ": keepalive\n\n"
387+
snap = _store.get(job_id)
388+
if snap is None:
389+
return # job expired / dropped
390+
if snap.status in ("completed", "failed", "cancelled"):
391+
return
392+
if _is_stale(snap):
393+
yield _sse({"type": "status", "data": {
394+
"status": "failed", "error": _STALE_ERROR,
395+
}})
396+
return
397+
time.sleep(_STREAM_POLL_SECONDS)
439398

440399
return Response(generate(), mimetype="text/event-stream", headers={
441400
"Cache-Control": "no-cache",
@@ -445,12 +404,15 @@ def generate():
445404

446405
@roast_bp.route("/<job_id>", methods=["DELETE"])
447406
def cancel_roast(job_id: str):
448-
"""Cancel + drop a job (best-effort)."""
407+
"""Cancel a job (best-effort). Sets the cancel flag the pipeline polls; the
408+
running pipeline then marks the job cancelled. The job state is left to
409+
expire via TTL so a reconnecting client sees `cancelled`, not a 404."""
449410
job = _store.get(job_id)
450411
if not job:
451412
return jsonify({"error": "job not found"}), 404
452-
job.cancelled.set()
453-
_store.drop(job_id)
413+
_store.set_cancelled(job_id)
414+
if job.status in ("completed", "failed", "cancelled"):
415+
_store.drop(job_id) # nothing running; safe to reclaim immediately
454416
return jsonify({"ok": True}), 200
455417

456418

@@ -518,6 +480,7 @@ def chat_agent(job_id: str, agent_id: str):
518480
history.append({"role": "user", "content": message})
519481
history.append({"role": "assistant", "content": reply})
520482
user_turns += 1
483+
_store.persist(job) # persist chat history (no-op for in-memory store)
521484

522485
return jsonify({
523486
"reply": reply,

0 commit comments

Comments
 (0)