shak3008 commited on
Commit
f5a034b
·
1 Parent(s): 79b28f1

feat: auto-resume orphaned workflows on server restart + cancel button

Browse files

Durable 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 CHANGED
@@ -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."}
backend/app/main.py CHANGED
@@ -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
- # Load in background thread so server starts accepting requests immediately
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(
backend/app/workflow/nodes/knowledge.py CHANGED
@@ -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
- import logging
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
- tracker.record_llm_usage(
187
- "knowledge_extraction",
188
- input_tokens=getattr(um, "input_tokens", 0) or 0,
189
- output_tokens=getattr(um, "output_tokens", 0) or 0,
190
- )
191
  else:
192
- import logging
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
- import logging
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
 
frontend/src/pages/Settings.jsx CHANGED
@@ -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
frontend/src/pages/WorkflowDetail.jsx CHANGED
@@ -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={<WorkflowStatusBadge status={workflow.status} />}
 
 
 
 
 
 
 
 
 
 
 
 
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