66GET /api/roast/<job_id>/stream SSE feed of agent reactions as they land
77DELETE /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
1315from __future__ import annotations
1416
1517import asyncio
1618import json
1719import logging
18- import queue
1920import threading
2021import time
2122import uuid
22- from dataclasses import dataclass , field
2323from typing import Any
2424
2525from flask import Blueprint , Response , jsonify , request , stream_with_context
2626
2727from ..config import Config
2828from ..extensions import limiter
29+ from ..services .job_store import RoastJob , make_store
2930from ..services .swarm import (
3031 AgentReaction ,
3132 CHAT_SOFT_CAP ,
4647roast_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
16998def _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
177103def _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" ])
410353def 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" ])
447406def 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