Step 4A-6-C-R3.1.1 Executor Claim Transport Idempotency and UI Monitor Hotfix

#9
Files changed (2) hide show
  1. app.py +81 -64
  2. task_executor.py +48 -8
app.py CHANGED
@@ -596,6 +596,81 @@ _user_workspace = os.path.join(WORKSPACE_DIR, "users", _safe_key(authenticated_u
596
  os.makedirs(_user_workspace, exist_ok=True)
597
 
598
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
  def render_workspace_controls(user):
600
  top_toolbar = st.container()
601
  with top_toolbar:
@@ -615,11 +690,16 @@ def render_workspace_controls(user):
615
 
616
  task = st.session_state.get("sp_generation_task") or {}
617
  task_status = str(task.get("status") or "")
618
- active = task_status in {"created", "running", "resumable", "cancelling"}
619
  st.session_state["is_running"] = active
620
 
621
  st.markdown(f"<div class='main-title'><h2>{tr(lang_code, 'title')}</h2></div>", unsafe_allow_html=True)
622
 
 
 
 
 
 
623
  if task_status == "resumable":
624
  st.warning(tr(lang_code, "checkpoint_warning"))
625
  colA, colB = st.columns([1, 1])
@@ -692,66 +772,3 @@ if st.session_state.get("sp_payment_required"):
692
  render_payment_purchase(lang_code)
693
 
694
 
695
- def _render_monitor_box(task: dict) -> None:
696
- status = str(task.get("status") or "")
697
- node = str(task.get("current_node") or status or "")
698
- progress = max(0, min(100, int(task.get("progress_pct") or 0)))
699
- if status in {"created", "running", "resumable", "cancelling"}:
700
- st.progress(progress, text=f"{progress}% — {node}")
701
- st.markdown(f"""
702
- <div class='log-box'>
703
- <div><b>{tr(lang_code, 'monitor_pipeline')}:</b> {status}</div><div><b>{tr(lang_code, 'monitor_node')}:</b> {node}</div><div><b>{tr(lang_code, 'monitor_log')}:</b> checkpoint {int(task.get('checkpoint_seq') or 0)} · {str(task.get('checkpoint_stage') or '')}</div><hr>
704
- <div><b>{tr(lang_code, 'monitor_compute')}:</b></div>
705
- <div style='display: flex; gap: 8px;'>
706
- <span style='background: #fef3c7; padding: 4px 8px; border-radius: 4px; color: black;'>🧠 DeepSeek: <b>{int(task.get('ds_calls') or 0)}</b></span>
707
- <span style='background: #dbeafe; padding: 4px 8px; border-radius: 4px; color: black;'>📚 S2: <b>{int(task.get('s2_calls') or 0)}</b></span>
708
- <span style='background: #d1fae5; padding: 4px 8px; border-radius: 4px; color: black;'>🌐 OpenAlex: <b>{int(task.get('oa_calls') or 0)}</b></span>
709
- </div>
710
- </div>
711
- """, unsafe_allow_html=True)
712
-
713
-
714
- def _render_task_status_once() -> None:
715
- task = st.session_state.get("sp_generation_task") or {}
716
- task_id = str(task.get("task_id") or "").strip()
717
- if not task_id:
718
- return
719
- previous_status = str(st.session_state.get("sp_last_ui_task_status") or str(task.get("status") or ""))
720
- status = str(task.get("status") or "")
721
- if status in {"created", "running", "resumable", "cancelling"}:
722
- try:
723
- result = generation_task_status(task_id)
724
- task = dict(result.get("task") or task)
725
- st.session_state["sp_generation_task"] = task
726
- status = str(task.get("status") or "")
727
- except Exception as exc:
728
- logging.warning("[SP_UI] status_poll_error task_id=%s error=%s", task_id, type(exc).__name__)
729
- st.session_state["sp_last_ui_task_status"] = status
730
- _render_monitor_box(task)
731
- if status == "completed":
732
- output_path = _task_output_path(task)
733
- if output_path and output_path.is_file():
734
- st.success(tr(lang_code, "success"))
735
- st.download_button(label=tr(lang_code, "download"), data=output_path.read_bytes(), file_name=output_path.name, mime="application/zip")
736
- else:
737
- st.warning("The task completed, but the local output file is unavailable after runtime replacement.")
738
- elif status == "failed":
739
- st.error(str(task.get("safe_error_detail") or task.get("safe_error_code") or tr(lang_code, "node_error")))
740
- elif status == "cancelled":
741
- detail = str(task.get("safe_error_detail") or "")
742
- if detail:
743
- st.warning(detail)
744
-
745
- # Only boundary transitions need one full app rerun so static controls pick
746
- # up resume/terminal state. Running progress never reruns the whole page.
747
- if previous_status and status != previous_status and status in {"resumable", "completed", "failed", "cancelled"}:
748
- st.rerun()
749
-
750
-
751
- if getattr(st, "fragment", None) is not None:
752
- @st.fragment(run_every=5)
753
- def _task_monitor_fragment():
754
- _render_task_status_once()
755
- _task_monitor_fragment()
756
- else:
757
- _render_task_status_once()
 
596
  os.makedirs(_user_workspace, exist_ok=True)
597
 
598
 
599
+ ACTIVE_TASK_STATUSES = {"created", "running", "resumable", "cancelling"}
600
+ AUTO_MONITOR_TASK_STATUSES = {"created", "running", "cancelling"}
601
+
602
+ def _render_monitor_box(task: dict, lang_code: str) -> None:
603
+ status = str(task.get("status") or "")
604
+ node = str(task.get("current_node") or status or "")
605
+ progress = max(0, min(100, int(task.get("progress_pct") or 0)))
606
+ if status in {"created", "running", "resumable", "cancelling"}:
607
+ st.progress(progress, text=f"{progress}% — {node}")
608
+ st.markdown(f"""
609
+ <div class='log-box'>
610
+ <div><b>{tr(lang_code, 'monitor_pipeline')}:</b> {status}</div><div><b>{tr(lang_code, 'monitor_node')}:</b> {node}</div><div><b>{tr(lang_code, 'monitor_log')}:</b> checkpoint {int(task.get('checkpoint_seq') or 0)} · {str(task.get('checkpoint_stage') or '')}</div><hr>
611
+ <div><b>{tr(lang_code, 'monitor_compute')}:</b></div>
612
+ <div style='display: flex; gap: 8px;'>
613
+ <span style='background: #fef3c7; padding: 4px 8px; border-radius: 4px; color: black;'>🧠 DeepSeek: <b>{int(task.get('ds_calls') or 0)}</b></span>
614
+ <span style='background: #dbeafe; padding: 4px 8px; border-radius: 4px; color: black;'>📚 S2: <b>{int(task.get('s2_calls') or 0)}</b></span>
615
+ <span style='background: #d1fae5; padding: 4px 8px; border-radius: 4px; color: black;'>🌐 OpenAlex: <b>{int(task.get('oa_calls') or 0)}</b></span>
616
+ </div>
617
+ </div>
618
+ """, unsafe_allow_html=True)
619
+
620
+
621
+ def _render_task_status_once(lang_code: str) -> None:
622
+ task = st.session_state.get("sp_generation_task") or {}
623
+ task_id = str(task.get("task_id") or "").strip()
624
+ if not task_id:
625
+ return
626
+ previous_status = str(st.session_state.get("sp_last_ui_task_status") or str(task.get("status") or ""))
627
+ status = str(task.get("status") or "")
628
+ if status in {"created", "running", "resumable", "cancelling"}:
629
+ try:
630
+ result = generation_task_status(task_id)
631
+ task = dict(result.get("task") or task)
632
+ st.session_state["sp_generation_task"] = task
633
+ status = str(task.get("status") or "")
634
+ except Exception as exc:
635
+ logging.warning("[SP_UI] status_poll_error task_id=%s error=%s", task_id, type(exc).__name__)
636
+ st.session_state["sp_last_ui_task_status"] = status
637
+ _render_monitor_box(task, lang_code)
638
+ if status == "completed":
639
+ output_path = _task_output_path(task)
640
+ if output_path and output_path.is_file():
641
+ st.success(tr(lang_code, "success"))
642
+ st.download_button(label=tr(lang_code, "download"), data=output_path.read_bytes(), file_name=output_path.name, mime="application/zip")
643
+ else:
644
+ st.warning("The task completed, but the local output file is unavailable after runtime replacement.")
645
+ elif status == "failed":
646
+ st.error(str(task.get("safe_error_detail") or task.get("safe_error_code") or tr(lang_code, "node_error")))
647
+ elif status == "cancelled":
648
+ detail = str(task.get("safe_error_detail") or "")
649
+ if detail:
650
+ st.warning(detail)
651
+
652
+ # Only boundary transitions need one full app rerun so static controls pick
653
+ # up resume/terminal state. Running progress never reruns the whole page.
654
+ if previous_status and status != previous_status and status in {"resumable", "completed", "failed", "cancelled"}:
655
+ st.rerun()
656
+
657
+
658
+ def _render_task_monitor(lang_code: str) -> None:
659
+ task = st.session_state.get("sp_generation_task") or {}
660
+ task_id = str(task.get("task_id") or "").strip()
661
+ if not task_id:
662
+ return
663
+ task_status = str(task.get("status") or "")
664
+ monitor_run_every = 5 if task_status in AUTO_MONITOR_TASK_STATUSES else None
665
+ fragment = getattr(st, "fragment", None)
666
+ if fragment is not None:
667
+ @fragment(run_every=monitor_run_every)
668
+ def _task_monitor_fragment():
669
+ _render_task_status_once(lang_code)
670
+ _task_monitor_fragment()
671
+ else:
672
+ _render_task_status_once(lang_code)
673
+
674
  def render_workspace_controls(user):
675
  top_toolbar = st.container()
676
  with top_toolbar:
 
690
 
691
  task = st.session_state.get("sp_generation_task") or {}
692
  task_status = str(task.get("status") or "")
693
+ active = task_status in ACTIVE_TASK_STATUSES
694
  st.session_state["is_running"] = active
695
 
696
  st.markdown(f"<div class='main-title'><h2>{tr(lang_code, 'title')}</h2></div>", unsafe_allow_html=True)
697
 
698
+ # R3.1.1: the live task monitor sits directly below the title so a full
699
+ # rerun after pressing Generate lands on the progress area, not above a
700
+ # long static form. Only this small monitor auto-refreshes while running.
701
+ _render_task_monitor(lang_code)
702
+
703
  if task_status == "resumable":
704
  st.warning(tr(lang_code, "checkpoint_warning"))
705
  colA, colB = st.columns([1, 1])
 
772
  render_payment_purchase(lang_code)
773
 
774
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
task_executor.py CHANGED
@@ -15,6 +15,7 @@ import json
15
  import logging
16
  import os
17
  import re
 
18
  import sys
19
  import threading
20
  import time
@@ -42,6 +43,14 @@ logging.basicConfig(
42
 
43
 
44
  CLAIM_HANDSHAKE_FILENAME = "executor_claim.json"
 
 
 
 
 
 
 
 
45
 
46
 
47
  def _safe(value, limit=500):
@@ -135,11 +144,34 @@ class ExecutorApi:
135
  raise ExecutorApiError(error, response.status_code, data)
136
  return data
137
 
138
- def claim(self, *, task_id: str, user_id: int, request_hash: str) -> dict:
139
- return self.post(
140
- "/executor/task/claim",
141
- {"task_id": task_id, "user_id": user_id, "request_hash": request_hash},
142
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
 
144
  def heartbeat(self, payload: dict) -> dict:
145
  return self.post("/executor/task/heartbeat", payload)
@@ -272,13 +304,21 @@ def _bootstrap_claim(*, task_dir: Path, task_id: str, user_id: int, request_data
272
  request_hash = str(request_data.get("request_hash") or "").strip()
273
  if not request_hash:
274
  raise RuntimeError("request_hash_missing")
275
- claim = api.claim(task_id=task_id, user_id=user_id, request_hash=request_hash)
 
 
 
 
 
 
276
  task = dict(claim.get("task") or {})
277
  execution = dict(claim.get("execution") or {})
278
  epoch = int(execution.get("execution_epoch") or task.get("execution_epoch") or 0)
279
- instance_id = str(execution.get("executor_instance_id") or "")
280
- if epoch <= 0 or not instance_id:
281
  raise RuntimeError("executor_claim_identity_missing")
 
 
282
  if int(task.get("target_words") or 0) != int(request_data.get("target_words") or 0):
283
  raise RuntimeError("target_words_claim_mismatch")
284
  try:
 
15
  import logging
16
  import os
17
  import re
18
+ import secrets
19
  import sys
20
  import threading
21
  import time
 
43
 
44
 
45
  CLAIM_HANDSHAKE_FILENAME = "executor_claim.json"
46
+ EXECUTOR_CLAIM_ATTEMPTS = 3
47
+ EXECUTOR_CLAIM_TIMEOUT_SECONDS = 4
48
+ EXECUTOR_CLAIM_BACKOFF_SECONDS = 0.6
49
+
50
+
51
+ def _new_executor_instance_id() -> str:
52
+ """Create one stable identity for this executor claim attempt sequence."""
53
+ return f"exec_{secrets.token_urlsafe(18)}"
54
 
55
 
56
  def _safe(value, limit=500):
 
144
  raise ExecutorApiError(error, response.status_code, data)
145
  return data
146
 
147
+ def claim(self, *, task_id: str, user_id: int, request_hash: str, executor_instance_id: str) -> dict:
148
+ payload = {
149
+ "task_id": task_id,
150
+ "user_id": user_id,
151
+ "request_hash": request_hash,
152
+ "executor_instance_id": executor_instance_id,
153
+ }
154
+ for attempt in range(1, EXECUTOR_CLAIM_ATTEMPTS + 1):
155
+ try:
156
+ return self.post(
157
+ "/executor/task/claim",
158
+ payload,
159
+ timeout=EXECUTOR_CLAIM_TIMEOUT_SECONDS,
160
+ )
161
+ except (
162
+ requests.exceptions.SSLError,
163
+ requests.exceptions.Timeout,
164
+ requests.exceptions.ConnectionError,
165
+ ) as exc:
166
+ if attempt >= EXECUTOR_CLAIM_ATTEMPTS:
167
+ raise
168
+ delay = EXECUTOR_CLAIM_BACKOFF_SECONDS * (2 ** (attempt - 1))
169
+ logging.warning(
170
+ "[SP_EXECUTOR] claim_transport_retry task_id=%s attempt=%s/%s error=%s delay=%.1fs",
171
+ task_id, attempt, EXECUTOR_CLAIM_ATTEMPTS, type(exc).__name__, delay,
172
+ )
173
+ time.sleep(delay)
174
+ raise RuntimeError("executor_claim_retry_exhausted")
175
 
176
  def heartbeat(self, payload: dict) -> dict:
177
  return self.post("/executor/task/heartbeat", payload)
 
304
  request_hash = str(request_data.get("request_hash") or "").strip()
305
  if not request_hash:
306
  raise RuntimeError("request_hash_missing")
307
+ instance_id = _new_executor_instance_id()
308
+ claim = api.claim(
309
+ task_id=task_id,
310
+ user_id=user_id,
311
+ request_hash=request_hash,
312
+ executor_instance_id=instance_id,
313
+ )
314
  task = dict(claim.get("task") or {})
315
  execution = dict(claim.get("execution") or {})
316
  epoch = int(execution.get("execution_epoch") or task.get("execution_epoch") or 0)
317
+ claimed_instance_id = str(execution.get("executor_instance_id") or "")
318
+ if epoch <= 0 or not claimed_instance_id:
319
  raise RuntimeError("executor_claim_identity_missing")
320
+ if claimed_instance_id != instance_id:
321
+ raise RuntimeError("executor_claim_identity_mismatch")
322
  if int(task.get("target_words") or 0) != int(request_data.get("target_words") or 0):
323
  raise RuntimeError("target_words_claim_mismatch")
324
  try: