Spaces:
Running
Running
Step 4A-6-C-R3.1.1 Executor Claim Transport Idempotency and UI Monitor Hotfix
#9
by GuXiaopeng - opened
- app.py +81 -64
- 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
|
| 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 |
-
|
| 140 |
-
"
|
| 141 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 280 |
-
if epoch <= 0 or not
|
| 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:
|