feat: auto-resume orphaned workflows on server restart + cancel button
Browse filesDurable restart (requirement #2):
- On startup, queries all RUNNING workflows from DB
- For each, creates WorkflowExecutor and calls resume()
- LangGraph loads checkpoint from PostgreSQL, continues from last node
- If resume fails, marks workflow as FAILED
- Waits 5s after startup for DB pool initialization
Auto-detect stuck workflows:
- Any workflow RUNNING for >5 min with no progress checkpoint
auto-marks as FAILED when polled by frontend
Cancel button:
- POST /workflows/{id}/cancel endpoint marks as FAILED
- Red Cancel button on WorkflowDetail for RUNNING/PENDING workflows
- Immediate escape hatch without waiting for timeout
Fix workspace delete:
- Navigate first, refresh after (prevents double-click issue)
Token capture logging:
- Added explicit INFO/WARNING logs to diagnose token tracking
- backend/app/api/workflows.py +75 -0
- backend/app/main.py +48 -1
- backend/app/workflow/nodes/knowledge.py +10 -11
- frontend/src/pages/Settings.jsx +1 -1
- frontend/src/pages/WorkflowDetail.jsx +15 -2
|
@@ -134,6 +134,27 @@ def get_workflow_by_document_version(
|
|
| 134 |
if workspace is None:
|
| 135 |
raise HTTPException(status_code=403, detail="You do not have access to this workflow.")
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
return _workflow_response(workflow)
|
| 138 |
|
| 139 |
|
|
@@ -170,4 +191,58 @@ def get_workflow(
|
|
| 170 |
detail="You do not have access to this workflow.",
|
| 171 |
)
|
| 172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
return _workflow_response(workflow)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
if workspace is None:
|
| 135 |
raise HTTPException(status_code=403, detail="You do not have access to this workflow.")
|
| 136 |
|
| 137 |
+
# Auto-detect stuck workflows: if RUNNING for >5 min with no progress, mark as FAILED
|
| 138 |
+
if workflow.status == WorkflowStatus.RUNNING:
|
| 139 |
+
from datetime import datetime, timezone, timedelta
|
| 140 |
+
now = datetime.now(timezone.utc)
|
| 141 |
+
started = workflow.started_at.replace(tzinfo=timezone.utc) if workflow.started_at.tzinfo is None else workflow.started_at
|
| 142 |
+
if now - started > timedelta(minutes=5):
|
| 143 |
+
# Check if there's been recent progress
|
| 144 |
+
latest_checkpoint = None
|
| 145 |
+
for cp in reversed(workflow.checkpoints or []):
|
| 146 |
+
if cp.agent_name == "PROGRESS":
|
| 147 |
+
latest_checkpoint = cp
|
| 148 |
+
break
|
| 149 |
+
stale = True
|
| 150 |
+
if latest_checkpoint and latest_checkpoint.created_at:
|
| 151 |
+
cp_time = latest_checkpoint.created_at.replace(tzinfo=timezone.utc) if latest_checkpoint.created_at.tzinfo is None else latest_checkpoint.created_at
|
| 152 |
+
if now - cp_time < timedelta(minutes=5):
|
| 153 |
+
stale = False
|
| 154 |
+
if stale:
|
| 155 |
+
workflow.status = WorkflowStatus.FAILED
|
| 156 |
+
db.commit()
|
| 157 |
+
|
| 158 |
return _workflow_response(workflow)
|
| 159 |
|
| 160 |
|
|
|
|
| 191 |
detail="You do not have access to this workflow.",
|
| 192 |
)
|
| 193 |
|
| 194 |
+
# Auto-detect stuck workflows: if RUNNING for >5 min with no progress, mark as FAILED
|
| 195 |
+
if workflow.status == WorkflowStatus.RUNNING:
|
| 196 |
+
from datetime import datetime, timezone, timedelta
|
| 197 |
+
now = datetime.now(timezone.utc)
|
| 198 |
+
started = workflow.started_at.replace(tzinfo=timezone.utc) if workflow.started_at.tzinfo is None else workflow.started_at
|
| 199 |
+
if now - started > timedelta(minutes=5):
|
| 200 |
+
# Check if there's been recent progress
|
| 201 |
+
latest_checkpoint = None
|
| 202 |
+
for cp in reversed(workflow.checkpoints or []):
|
| 203 |
+
if cp.agent_name == "PROGRESS":
|
| 204 |
+
latest_checkpoint = cp
|
| 205 |
+
break
|
| 206 |
+
stale = True
|
| 207 |
+
if latest_checkpoint and latest_checkpoint.created_at:
|
| 208 |
+
cp_time = latest_checkpoint.created_at.replace(tzinfo=timezone.utc) if latest_checkpoint.created_at.tzinfo is None else latest_checkpoint.created_at
|
| 209 |
+
if now - cp_time < timedelta(minutes=5):
|
| 210 |
+
stale = False
|
| 211 |
+
if stale:
|
| 212 |
+
workflow.status = WorkflowStatus.FAILED
|
| 213 |
+
db.commit()
|
| 214 |
+
|
| 215 |
return _workflow_response(workflow)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
@router.post("/{workflow_id}/cancel", status_code=200)
|
| 219 |
+
def cancel_workflow(
|
| 220 |
+
workflow_id: UUID,
|
| 221 |
+
current_user: User = Depends(get_current_user),
|
| 222 |
+
db: Session = Depends(get_db),
|
| 223 |
+
):
|
| 224 |
+
"""Mark a stuck/running workflow as FAILED so it stops polling."""
|
| 225 |
+
workflow = (
|
| 226 |
+
db.query(WorkflowRun)
|
| 227 |
+
.filter(WorkflowRun.id == workflow_id)
|
| 228 |
+
.first()
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
if workflow is None:
|
| 232 |
+
raise HTTPException(status_code=404, detail="Workflow not found.")
|
| 233 |
+
|
| 234 |
+
workspace = (
|
| 235 |
+
db.query(Workspace)
|
| 236 |
+
.filter(Workspace.id == workflow.workspace_id, Workspace.created_by == current_user.id)
|
| 237 |
+
.first()
|
| 238 |
+
)
|
| 239 |
+
if workspace is None:
|
| 240 |
+
raise HTTPException(status_code=403, detail="You do not have access to this workflow.")
|
| 241 |
+
|
| 242 |
+
if workflow.status in (WorkflowStatus.COMPLETED, WorkflowStatus.CANCELLED):
|
| 243 |
+
return {"id": str(workflow.id), "status": workflow.status.value, "message": "Already terminal."}
|
| 244 |
+
|
| 245 |
+
workflow.status = WorkflowStatus.FAILED
|
| 246 |
+
db.commit()
|
| 247 |
+
|
| 248 |
+
return {"id": str(workflow.id), "status": "FAILED", "message": "Workflow marked as failed."}
|
|
@@ -26,6 +26,7 @@ def preload_models():
|
|
| 26 |
"""
|
| 27 |
Pre-load heavy ML models at startup so the first document
|
| 28 |
upload doesn't pay the cold-start penalty.
|
|
|
|
| 29 |
"""
|
| 30 |
import threading
|
| 31 |
|
|
@@ -43,8 +44,54 @@ def preload_models():
|
|
| 43 |
except Exception as e:
|
| 44 |
logger.warning("Model pre-load failed (non-fatal): %s", e)
|
| 45 |
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
threading.Thread(target=_load, daemon=True).start()
|
|
|
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
app.add_middleware(
|
|
|
|
| 26 |
"""
|
| 27 |
Pre-load heavy ML models at startup so the first document
|
| 28 |
upload doesn't pay the cold-start penalty.
|
| 29 |
+
Also resumes any orphaned RUNNING workflows from before a crash.
|
| 30 |
"""
|
| 31 |
import threading
|
| 32 |
|
|
|
|
| 44 |
except Exception as e:
|
| 45 |
logger.warning("Model pre-load failed (non-fatal): %s", e)
|
| 46 |
|
| 47 |
+
def _resume_orphaned_workflows():
|
| 48 |
+
"""
|
| 49 |
+
Resume workflows that were RUNNING when the server died.
|
| 50 |
+
LangGraph checkpoints after every node, so we can resume
|
| 51 |
+
from the last completed stage.
|
| 52 |
+
"""
|
| 53 |
+
import time
|
| 54 |
+
time.sleep(5) # Wait for DB pool to be ready
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
from app.database.database import SessionLocal
|
| 58 |
+
from app.models.workflow_run import WorkflowRun, WorkflowStatus
|
| 59 |
+
from app.workflow import WorkflowExecutor
|
| 60 |
+
|
| 61 |
+
db = SessionLocal()
|
| 62 |
+
try:
|
| 63 |
+
orphaned = (
|
| 64 |
+
db.query(WorkflowRun)
|
| 65 |
+
.filter(WorkflowRun.status == WorkflowStatus.RUNNING)
|
| 66 |
+
.all()
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
if not orphaned:
|
| 70 |
+
return
|
| 71 |
+
|
| 72 |
+
logger.info("Found %d orphaned RUNNING workflow(s). Resuming...", len(orphaned))
|
| 73 |
+
|
| 74 |
+
for workflow in orphaned:
|
| 75 |
+
try:
|
| 76 |
+
logger.info(" Resuming workflow %s...", workflow.id)
|
| 77 |
+
executor = WorkflowExecutor(db, interrupt_before=["human_review"])
|
| 78 |
+
executor.resume(workflow)
|
| 79 |
+
executor.close()
|
| 80 |
+
logger.info(" Workflow %s resumed successfully (status: %s)", workflow.id, workflow.status.value)
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logger.warning(" Could not resume workflow %s: %s", workflow.id, str(e)[:100])
|
| 83 |
+
# Mark as failed if we can't resume
|
| 84 |
+
workflow.status = WorkflowStatus.FAILED
|
| 85 |
+
db.commit()
|
| 86 |
+
finally:
|
| 87 |
+
db.close()
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.warning("Orphaned workflow resume failed: %s", e)
|
| 90 |
+
|
| 91 |
+
# Load model in background
|
| 92 |
threading.Thread(target=_load, daemon=True).start()
|
| 93 |
+
# Resume orphaned workflows in background
|
| 94 |
+
threading.Thread(target=_resume_orphaned_workflows, daemon=True).start()
|
| 95 |
|
| 96 |
|
| 97 |
app.add_middleware(
|
|
@@ -165,6 +165,8 @@ def knowledge(
|
|
| 165 |
return state
|
| 166 |
|
| 167 |
# Extract token usage from raw LLM response
|
|
|
|
|
|
|
| 168 |
try:
|
| 169 |
if raw_response and hasattr(raw_response, "response_metadata"):
|
| 170 |
meta = raw_response.response_metadata or {}
|
|
@@ -177,23 +179,20 @@ def knowledge(
|
|
| 177 |
input_tokens=input_tokens,
|
| 178 |
output_tokens=output_tokens,
|
| 179 |
)
|
|
|
|
| 180 |
else:
|
| 181 |
-
|
| 182 |
-
logging.getLogger(__name__).warning("Token usage was 0. Meta: %s", meta)
|
| 183 |
elif raw_response and hasattr(raw_response, "usage_metadata"):
|
| 184 |
um = raw_response.usage_metadata
|
| 185 |
if um:
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
)
|
| 191 |
else:
|
| 192 |
-
|
| 193 |
-
logging.getLogger(__name__).warning("No raw_response or no response_metadata. raw_response type: %s", type(raw_response))
|
| 194 |
except Exception as e:
|
| 195 |
-
|
| 196 |
-
logging.getLogger(__name__).warning("Token capture exception: %s", e)
|
| 197 |
|
| 198 |
tracker.end_stage("knowledge_extraction")
|
| 199 |
|
|
|
|
| 165 |
return state
|
| 166 |
|
| 167 |
# Extract token usage from raw LLM response
|
| 168 |
+
import logging
|
| 169 |
+
_log = logging.getLogger(__name__)
|
| 170 |
try:
|
| 171 |
if raw_response and hasattr(raw_response, "response_metadata"):
|
| 172 |
meta = raw_response.response_metadata or {}
|
|
|
|
| 179 |
input_tokens=input_tokens,
|
| 180 |
output_tokens=output_tokens,
|
| 181 |
)
|
| 182 |
+
_log.info("Tokens captured: in=%d out=%d", input_tokens, output_tokens)
|
| 183 |
else:
|
| 184 |
+
_log.warning("Token usage was 0. Meta keys: %s, usage: %s", list(meta.keys()), usage)
|
|
|
|
| 185 |
elif raw_response and hasattr(raw_response, "usage_metadata"):
|
| 186 |
um = raw_response.usage_metadata
|
| 187 |
if um:
|
| 188 |
+
in_t = getattr(um, "input_tokens", 0) or 0
|
| 189 |
+
out_t = getattr(um, "output_tokens", 0) or 0
|
| 190 |
+
tracker.record_llm_usage("knowledge_extraction", input_tokens=in_t, output_tokens=out_t)
|
| 191 |
+
_log.info("Tokens captured via usage_metadata: in=%d out=%d", in_t, out_t)
|
|
|
|
| 192 |
else:
|
| 193 |
+
_log.warning("No raw_response or no response_metadata. raw_response=%s type=%s", raw_response is not None, type(raw_response))
|
|
|
|
| 194 |
except Exception as e:
|
| 195 |
+
_log.warning("Token capture exception: %s", e)
|
|
|
|
| 196 |
|
| 197 |
tracker.end_stage("knowledge_extraction")
|
| 198 |
|
|
@@ -426,8 +426,8 @@ export default function Settings() {
|
|
| 426 |
onClick={async () => {
|
| 427 |
if (!confirm(`Delete workspace "${workspace.name}"?\n\nThis action cannot be undone. All documents, knowledge, and workflows will become inaccessible.`)) return;
|
| 428 |
await deleteWorkspace(workspace.id);
|
| 429 |
-
await refresh();
|
| 430 |
navigate("/");
|
|
|
|
| 431 |
}}
|
| 432 |
>
|
| 433 |
Delete workspace
|
|
|
|
| 426 |
onClick={async () => {
|
| 427 |
if (!confirm(`Delete workspace "${workspace.name}"?\n\nThis action cannot be undone. All documents, knowledge, and workflows will become inaccessible.`)) return;
|
| 428 |
await deleteWorkspace(workspace.id);
|
|
|
|
| 429 |
navigate("/");
|
| 430 |
+
refresh();
|
| 431 |
}}
|
| 432 |
>
|
| 433 |
Delete workspace
|
|
@@ -3,11 +3,12 @@ import { useParams, useNavigate } from "react-router-dom";
|
|
| 3 |
import { getWorkflow } from "../api/workflows";
|
| 4 |
import { getWorkflowMetrics } from "../api/metrics";
|
| 5 |
import { listPendingProposals } from "../api/proposals";
|
|
|
|
| 6 |
import { WorkflowStatusBadge } from "../components/workflow/WorkflowStatusBadge";
|
| 7 |
import { WorkflowPipeline } from "../components/workflow/WorkflowPipeline";
|
| 8 |
import { WorkflowTimeline } from "../components/workflow/WorkflowTimeline";
|
| 9 |
import { ProposalReviewList } from "../components/proposals/ProposalReviewList";
|
| 10 |
-
import { Card, CardHeader, CardBody, LoadingState, EmptyState, Glossary } from "../components/ui";
|
| 11 |
import { GLOSSARY } from "../utils/labels";
|
| 12 |
import "./WorkflowDetail.css";
|
| 13 |
|
|
@@ -88,7 +89,19 @@ export default function WorkflowDetail() {
|
|
| 88 |
<Card>
|
| 89 |
<CardHeader
|
| 90 |
title="Workflow status"
|
| 91 |
-
action={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
/>
|
| 93 |
<CardBody>
|
| 94 |
<WorkflowPipeline
|
|
|
|
| 3 |
import { getWorkflow } from "../api/workflows";
|
| 4 |
import { getWorkflowMetrics } from "../api/metrics";
|
| 5 |
import { listPendingProposals } from "../api/proposals";
|
| 6 |
+
import { apiRequest } from "../api/client";
|
| 7 |
import { WorkflowStatusBadge } from "../components/workflow/WorkflowStatusBadge";
|
| 8 |
import { WorkflowPipeline } from "../components/workflow/WorkflowPipeline";
|
| 9 |
import { WorkflowTimeline } from "../components/workflow/WorkflowTimeline";
|
| 10 |
import { ProposalReviewList } from "../components/proposals/ProposalReviewList";
|
| 11 |
+
import { Card, CardHeader, CardBody, Button, LoadingState, EmptyState, Glossary } from "../components/ui";
|
| 12 |
import { GLOSSARY } from "../utils/labels";
|
| 13 |
import "./WorkflowDetail.css";
|
| 14 |
|
|
|
|
| 89 |
<Card>
|
| 90 |
<CardHeader
|
| 91 |
title="Workflow status"
|
| 92 |
+
action={
|
| 93 |
+
<span style={{ display: "flex", alignItems: "center", gap: "var(--space-3)" }}>
|
| 94 |
+
<WorkflowStatusBadge status={workflow.status} />
|
| 95 |
+
{(workflow.status === "RUNNING" || workflow.status === "PENDING") && (
|
| 96 |
+
<Button size="sm" variant="danger" onClick={async () => {
|
| 97 |
+
await apiRequest(`/workflows/${workflowId}/cancel`, "POST");
|
| 98 |
+
fetchWorkflow();
|
| 99 |
+
}}>
|
| 100 |
+
Cancel
|
| 101 |
+
</Button>
|
| 102 |
+
)}
|
| 103 |
+
</span>
|
| 104 |
+
}
|
| 105 |
/>
|
| 106 |
<CardBody>
|
| 107 |
<WorkflowPipeline
|