From 4882c9206a6071b7194920f1c7d21edb40fd4524 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Sun, 13 Sep 2026 16:31:32 +0200 Subject: [PATCH] Prevent finished projects from bouncing progress to report. Gate /progress SSE and auto-redirect on a live queued/running status so historical pipeline_complete events no longer flash Processing then open the report. Co-authored-by: Cursor --- backend/routers/pipeline.py | 46 +++++++++++---- frontend/content/changelog.md | 7 +++ frontend/src/app/(app)/project/[id]/page.tsx | 4 +- .../app/(app)/project/[id]/progress/page.tsx | 56 +++++++++++++++---- 4 files changed, 90 insertions(+), 23 deletions(-) diff --git a/backend/routers/pipeline.py b/backend/routers/pipeline.py index 4bef76d..d5632d1 100644 --- a/backend/routers/pipeline.py +++ b/backend/routers/pipeline.py @@ -385,6 +385,12 @@ async def regen(project_id: str, req: RegenRequest, request: Request): _EXEC_TERMINAL = frozenset({"succeeded", "failed", "cancelled"}) +_ANALYSIS_SSE_TERMINAL = frozenset({ + "pipeline_complete", + "pipeline_error", + "pipeline_cancelled", + "pipeline_paused", +}) @router.get("/pipeline/{project_id}/events") @@ -392,20 +398,28 @@ async def events(project_id: str, request: Request): """SSE stream of pipeline progress events. Tails the GCS-backed event log written by the worker. Stops on - terminal events as today, but also has two hard-crash escape - hatches: the project's status reaching a terminal value, and the - Cloud Run execution reaching a terminal state. Either of those - triggers a synthetic ``pipeline_error`` so the SSE doesn't hang - forever when the worker dies without writing its terminal event. + analysis terminal events, but also has two hard-crash escape + hatches: the project's status reaching a terminal value *after* + having been active, and the Cloud Run execution reaching a + terminal state. Either of those triggers a synthetic + ``pipeline_error`` so the SSE doesn't hang forever when the worker + dies without writing its terminal event. """ owner_user_id, meta = await resolve_or_404(request, project_id) storage = get_storage(request) async def event_generator(): execution_name = meta.execution_name - # Drive the GCS tail and the escape-hatch poll concurrently. The - # tail yields events; the escape hatch flips a flag. crash_detected: dict[str, str | None] = {"reason": None} + # Only treat a terminal status as a crash if we observed the + # project as queued/running first — otherwise a finished project + # reconnecting to /events would immediately synthesize an error + # (or race with a historical pipeline_complete replay). + active_state = { + "saw": meta.status in ( + proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING, + ), + } async def watch_status() -> None: poll_interval = 2.0 @@ -417,13 +431,18 @@ async def events(project_id: str, request: Request): continue if cur is None: continue - if cur.status in proj_svc.TERMINAL_STATUSES: + if cur.status in (proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING): + active_state["saw"] = True + elif active_state["saw"] and cur.status in proj_svc.TERMINAL_STATUSES: crash_detected["reason"] = ( f"project status={cur.status} (terminal)" ) return # Cloud Run hard-crash detection - if execution_name: + if execution_name and ( + active_state["saw"] + or cur.status in (proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING) + ): try: state = job_runner.get_execution_state(execution_name) except Exception: @@ -438,14 +457,19 @@ async def events(project_id: str, request: Request): try: async for msg in event_bridge.tail_events( storage, owner_user_id, project_id, + terminal_events=_ANALYSIS_SSE_TERMINAL, ): if crash_detected["reason"] is not None: break + ev = msg["event"] + # Skip placement events in the shared log. + if ev.startswith("placement_"): + continue yield { - "event": msg["event"], + "event": ev, "data": json.dumps(msg.get("data", {})), } - if msg["event"] in event_bridge.TERMINAL_EVENTS: + if ev in _ANALYSIS_SSE_TERMINAL: return # tail_events exited without a terminal event — escape hatch diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 44a6da1..d7cb2aa 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,13 @@ What's new in Pinscope. +## 2.28.3 — 2026-09-13 — Stop progress→report bounce on finished projects + +Opening a finished project no longer flashes Processing and dumps you on the report. Progress only auto-opens the report after a live run on that visit. + +- [Fixed] `/progress` checks project status before SSE; finished projects go to the hub. +- [Fixed] Analysis SSE ignores placement events and does not treat already-terminal status as a crash. + ## 2.28.2 — 2026-09-13 — Fix project sidebar navigation Opening a finished project no longer dumps you on the report with a stuck left menu. Hub first; Report stays in the sidebar. Leaving `/report` for BOM/tabs uses a full navigation so the page actually changes. diff --git a/frontend/src/app/(app)/project/[id]/page.tsx b/frontend/src/app/(app)/project/[id]/page.tsx index 871a781..4f1f12e 100644 --- a/frontend/src/app/(app)/project/[id]/page.tsx +++ b/frontend/src/app/(app)/project/[id]/page.tsx @@ -101,9 +101,9 @@ export default function ProjectDetailPage({ reload(); }, [reload]); - // Redirect to progress page if pipeline is running + // Redirect to progress page if pipeline is running or queued useEffect(() => { - if (project?.status === "running") { + if (project?.status === "running" || project?.status === "queued") { router.replace(`/project/${id}/progress`); } }, [project?.status, id, router]); diff --git a/frontend/src/app/(app)/project/[id]/progress/page.tsx b/frontend/src/app/(app)/project/[id]/progress/page.tsx index c4e94ba..25e185e 100644 --- a/frontend/src/app/(app)/project/[id]/progress/page.tsx +++ b/frontend/src/app/(app)/project/[id]/progress/page.tsx @@ -34,6 +34,8 @@ import { RotateCcw, } from "lucide-react"; +type ProgressGate = "loading" | "live" | "paused" | "idle"; + export default function ProgressPage({ params, }: { @@ -41,8 +43,12 @@ export default function ProgressPage({ }) { const { id } = use(params); const router = useRouter(); + // Don't attach SSE until we know this is a live (or paused) run — + // otherwise a historical ``pipeline_complete`` in the event log + // immediately sets done and bounces the user to the report. + const [gate, setGate] = useState("loading"); const { steps, done, cancelled, error, summary, autoTopupFailure, credits, started, paused } = - usePipelineProgress(id); + usePipelineProgress(gate === "live" ? id : null); const [topupDismissed, setTopupDismissed] = useState(false); const [projectName, setProjectName] = useState(""); @@ -65,25 +71,43 @@ export default function ProgressPage({ } } - // Fetch project name + initial paused state. The SSE stream only reports - // `pipeline_paused` if the page is open when it fires; landing on the - // progress page later, we need to read the persisted project status. + // Decide whether this visit is a live run before opening SSE / auto-redirect. useEffect(() => { + let cancelledFetch = false; fetchProject(id) .then((p) => { + if (cancelledFetch) return; setProjectName(p.name); - if (p.status === "paused_insufficient_credits") { + if ( + p.status === "paused_insufficient_credits" || + p.status === "paused_by_user" + ) { setProjectPaused(true); setProjectCheckpoint(p.pauseCheckpoint ?? null); + setGate("paused"); + return; } + if (p.status === "running" || p.status === "queued") { + setGate("live"); + return; + } + // Finished / draft — progress is the wrong page; go to the project hub. + setGate("idle"); + router.replace(`/project/${id}`); }) - .catch(() => {}); - }, [id]); + .catch(() => { + if (!cancelledFetch) setGate("live"); + }); + return () => { + cancelledFetch = true; + }; + }, [id, router]); // Live `pipeline_paused` event also flips the paused state. useEffect(() => { if (paused) { setProjectPaused(true); + setGate("paused"); setProjectCheckpoint({ paused_at: paused.unit_id, paused_stage: paused.stage, @@ -107,8 +131,9 @@ export default function ProgressPage({ } }; - // Auto-navigate to report when pipeline completes and the file exists + // Auto-navigate to report only after a live run completes on this visit. useEffect(() => { + if (gate !== "live") return; if (!done || error || cancelled || projectPaused) return; let stopped = false; (async () => { @@ -121,11 +146,13 @@ export default function ProgressPage({ await new Promise((r) => setTimeout(r, 500)); } } + // Report missing — still leave the progress spinner for the hub. + if (!stopped) router.replace(`/project/${id}`); })(); return () => { stopped = true; }; - }, [done, error, cancelled, projectPaused, id, router]); + }, [gate, done, error, cancelled, projectPaused, id, router]); // Auto-navigate to dashboard when pipeline is cancelled useEffect(() => { @@ -150,9 +177,18 @@ export default function ProgressPage({ } }; - const isRunning = !done && !projectPaused; + const isRunning = gate === "live" && !done && !projectPaused; const isQueued = isRunning && !started; + if (gate === "loading" || gate === "idle") { + return ( +
+ + {gate === "idle" ? "Opening project…" : "Checking pipeline status…"} +
+ ); + } + return (