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:
+35
-11
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user