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 <cursoragent@cursor.com>
This commit is contained in:
2026-09-13 16:31:32 +02:00
co-authored by Cursor
parent d947128951
commit 4882c9206a
4 changed files with 90 additions and 23 deletions
+35 -11
View File
@@ -385,6 +385,12 @@ async def regen(project_id: str, req: RegenRequest, request: Request):
_EXEC_TERMINAL = frozenset({"succeeded", "failed", "cancelled"}) _EXEC_TERMINAL = frozenset({"succeeded", "failed", "cancelled"})
_ANALYSIS_SSE_TERMINAL = frozenset({
"pipeline_complete",
"pipeline_error",
"pipeline_cancelled",
"pipeline_paused",
})
@router.get("/pipeline/{project_id}/events") @router.get("/pipeline/{project_id}/events")
@@ -392,20 +398,28 @@ async def events(project_id: str, request: Request):
"""SSE stream of pipeline progress events. """SSE stream of pipeline progress events.
Tails the GCS-backed event log written by the worker. Stops on Tails the GCS-backed event log written by the worker. Stops on
terminal events as today, but also has two hard-crash escape analysis terminal events, but also has two hard-crash escape
hatches: the project's status reaching a terminal value, and the hatches: the project's status reaching a terminal value *after*
Cloud Run execution reaching a terminal state. Either of those having been active, and the Cloud Run execution reaching a
triggers a synthetic ``pipeline_error`` so the SSE doesn't hang terminal state. Either of those triggers a synthetic
forever when the worker dies without writing its terminal event. ``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) owner_user_id, meta = await resolve_or_404(request, project_id)
storage = get_storage(request) storage = get_storage(request)
async def event_generator(): async def event_generator():
execution_name = meta.execution_name 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} 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: async def watch_status() -> None:
poll_interval = 2.0 poll_interval = 2.0
@@ -417,13 +431,18 @@ async def events(project_id: str, request: Request):
continue continue
if cur is None: if cur is None:
continue 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"] = ( crash_detected["reason"] = (
f"project status={cur.status} (terminal)" f"project status={cur.status} (terminal)"
) )
return return
# Cloud Run hard-crash detection # 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: try:
state = job_runner.get_execution_state(execution_name) state = job_runner.get_execution_state(execution_name)
except Exception: except Exception:
@@ -438,14 +457,19 @@ async def events(project_id: str, request: Request):
try: try:
async for msg in event_bridge.tail_events( async for msg in event_bridge.tail_events(
storage, owner_user_id, project_id, storage, owner_user_id, project_id,
terminal_events=_ANALYSIS_SSE_TERMINAL,
): ):
if crash_detected["reason"] is not None: if crash_detected["reason"] is not None:
break break
ev = msg["event"]
# Skip placement events in the shared log.
if ev.startswith("placement_"):
continue
yield { yield {
"event": msg["event"], "event": ev,
"data": json.dumps(msg.get("data", {})), "data": json.dumps(msg.get("data", {})),
} }
if msg["event"] in event_bridge.TERMINAL_EVENTS: if ev in _ANALYSIS_SSE_TERMINAL:
return return
# tail_events exited without a terminal event — escape hatch # tail_events exited without a terminal event — escape hatch
+7
View File
@@ -2,6 +2,13 @@
What's new in Pinscope. 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 ## 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. 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.
+2 -2
View File
@@ -101,9 +101,9 @@ export default function ProjectDetailPage({
reload(); reload();
}, [reload]); }, [reload]);
// Redirect to progress page if pipeline is running // Redirect to progress page if pipeline is running or queued
useEffect(() => { useEffect(() => {
if (project?.status === "running") { if (project?.status === "running" || project?.status === "queued") {
router.replace(`/project/${id}/progress`); router.replace(`/project/${id}/progress`);
} }
}, [project?.status, id, router]); }, [project?.status, id, router]);
@@ -34,6 +34,8 @@ import {
RotateCcw, RotateCcw,
} from "lucide-react"; } from "lucide-react";
type ProgressGate = "loading" | "live" | "paused" | "idle";
export default function ProgressPage({ export default function ProgressPage({
params, params,
}: { }: {
@@ -41,8 +43,12 @@ export default function ProgressPage({
}) { }) {
const { id } = use(params); const { id } = use(params);
const router = useRouter(); 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<ProgressGate>("loading");
const { steps, done, cancelled, error, summary, autoTopupFailure, credits, started, paused } = const { steps, done, cancelled, error, summary, autoTopupFailure, credits, started, paused } =
usePipelineProgress(id); usePipelineProgress(gate === "live" ? id : null);
const [topupDismissed, setTopupDismissed] = useState(false); const [topupDismissed, setTopupDismissed] = useState(false);
const [projectName, setProjectName] = useState<string>(""); const [projectName, setProjectName] = useState<string>("");
@@ -65,25 +71,43 @@ export default function ProgressPage({
} }
} }
// Fetch project name + initial paused state. The SSE stream only reports // Decide whether this visit is a live run before opening SSE / auto-redirect.
// `pipeline_paused` if the page is open when it fires; landing on the
// progress page later, we need to read the persisted project status.
useEffect(() => { useEffect(() => {
let cancelledFetch = false;
fetchProject(id) fetchProject(id)
.then((p) => { .then((p) => {
if (cancelledFetch) return;
setProjectName(p.name); setProjectName(p.name);
if (p.status === "paused_insufficient_credits") { if (
p.status === "paused_insufficient_credits" ||
p.status === "paused_by_user"
) {
setProjectPaused(true); setProjectPaused(true);
setProjectCheckpoint(p.pauseCheckpoint ?? null); 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(() => {}); .catch(() => {
}, [id]); if (!cancelledFetch) setGate("live");
});
return () => {
cancelledFetch = true;
};
}, [id, router]);
// Live `pipeline_paused` event also flips the paused state. // Live `pipeline_paused` event also flips the paused state.
useEffect(() => { useEffect(() => {
if (paused) { if (paused) {
setProjectPaused(true); setProjectPaused(true);
setGate("paused");
setProjectCheckpoint({ setProjectCheckpoint({
paused_at: paused.unit_id, paused_at: paused.unit_id,
paused_stage: paused.stage, 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(() => { useEffect(() => {
if (gate !== "live") return;
if (!done || error || cancelled || projectPaused) return; if (!done || error || cancelled || projectPaused) return;
let stopped = false; let stopped = false;
(async () => { (async () => {
@@ -121,11 +146,13 @@ export default function ProgressPage({
await new Promise((r) => setTimeout(r, 500)); await new Promise((r) => setTimeout(r, 500));
} }
} }
// Report missing — still leave the progress spinner for the hub.
if (!stopped) router.replace(`/project/${id}`);
})(); })();
return () => { return () => {
stopped = true; stopped = true;
}; };
}, [done, error, cancelled, projectPaused, id, router]); }, [gate, done, error, cancelled, projectPaused, id, router]);
// Auto-navigate to dashboard when pipeline is cancelled // Auto-navigate to dashboard when pipeline is cancelled
useEffect(() => { useEffect(() => {
@@ -150,9 +177,18 @@ export default function ProgressPage({
} }
}; };
const isRunning = !done && !projectPaused; const isRunning = gate === "live" && !done && !projectPaused;
const isQueued = isRunning && !started; const isQueued = isRunning && !started;
if (gate === "loading" || gate === "idle") {
return (
<div className="flex-1 p-6 max-w-3xl mx-auto w-full flex items-center gap-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{gate === "idle" ? "Opening project…" : "Checking pipeline status…"}
</div>
);
}
return ( return (
<div className="flex-1 p-6 max-w-3xl mx-auto w-full space-y-6"> <div className="flex-1 p-6 max-w-3xl mx-auto w-full space-y-6">
<div> <div>