Spaces:
Running
Running
Commit ·
52cebc9
1
Parent(s): ac7b398
Step 4A-6-C-R3.2 Executor Ownership Correction and UI Rollback (#10)
Browse files- Step 4A-6-C-R3.2 Executor Ownership Correction and UI Rollback (3fd0f77d70e84a8faaf668c4f5fb0e9e9915cb59)
- app.py +53 -76
- task_executor.py +45 -10
app.py
CHANGED
|
@@ -7,7 +7,6 @@ import os
|
|
| 7 |
import re
|
| 8 |
import secrets
|
| 9 |
import shutil
|
| 10 |
-
import signal
|
| 11 |
import subprocess
|
| 12 |
import sys
|
| 13 |
import time
|
|
@@ -396,12 +395,13 @@ def _pid_is_task_executor(pid: int, task_id: str, task_dir: Path) -> bool:
|
|
| 396 |
|
| 397 |
|
| 398 |
def _launch_executor(task_id: str, *, force: bool = False) -> int:
|
| 399 |
-
"""
|
| 400 |
|
| 401 |
-
``subprocess.Popen``
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
|
|
|
| 405 |
"""
|
| 406 |
task_dir = _task_dir(task_id)
|
| 407 |
request_path = task_dir / "request.json"
|
|
@@ -419,25 +419,15 @@ def _launch_executor(task_id: str, *, force: bool = False) -> int:
|
|
| 419 |
try:
|
| 420 |
marker = json.loads(launch_marker_path.read_text(encoding="utf-8"))
|
| 421 |
pid = int(marker.get("pid") or 0)
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
if claim_marker_path.is_file():
|
| 426 |
-
try:
|
| 427 |
-
claim_marker = json.loads(claim_marker_path.read_text(encoding="utf-8"))
|
| 428 |
-
except Exception:
|
| 429 |
-
claim_marker = {}
|
| 430 |
-
if int(claim_marker.get("pid") or 0) == pid and int(claim_marker.get("execution_epoch") or 0) > 0:
|
| 431 |
-
return pid
|
| 432 |
-
if verified_executor and force:
|
| 433 |
-
try:
|
| 434 |
-
os.kill(pid, signal.SIGTERM)
|
| 435 |
-
except OSError:
|
| 436 |
-
pass
|
| 437 |
except Exception:
|
| 438 |
pass
|
| 439 |
|
| 440 |
-
# A stale claim
|
|
|
|
|
|
|
| 441 |
try:
|
| 442 |
claim_marker_path.unlink(missing_ok=True)
|
| 443 |
except TypeError:
|
|
@@ -465,42 +455,17 @@ def _launch_executor(task_id: str, *, force: bool = False) -> int:
|
|
| 465 |
start_new_session=True,
|
| 466 |
env=executor_env,
|
| 467 |
)
|
| 468 |
-
_atomic_json(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
logging.info("[SP_UI] executor_spawned task_id=%s pid=%s", task_id, process.pid)
|
| 470 |
-
|
| 471 |
-
timeout_seconds = float(APP_CONFIG.get("executor_bootstrap_timeout_seconds", 20))
|
| 472 |
-
poll_seconds = max(0.05, float(APP_CONFIG.get("executor_bootstrap_poll_ms", 100)) / 1000.0)
|
| 473 |
-
deadline = time.monotonic() + timeout_seconds
|
| 474 |
-
while time.monotonic() < deadline:
|
| 475 |
-
if claim_marker_path.is_file():
|
| 476 |
-
try:
|
| 477 |
-
claim_marker = json.loads(claim_marker_path.read_text(encoding="utf-8"))
|
| 478 |
-
except Exception:
|
| 479 |
-
claim_marker = {}
|
| 480 |
-
if (
|
| 481 |
-
int(claim_marker.get("pid") or 0) == int(process.pid)
|
| 482 |
-
and str(claim_marker.get("task_id") or "") == str(task_id)
|
| 483 |
-
and str(claim_marker.get("request_hash") or "") == request_hash
|
| 484 |
-
and int(claim_marker.get("execution_epoch") or 0) > 0
|
| 485 |
-
and str(claim_marker.get("executor_instance_id") or "").strip()
|
| 486 |
-
):
|
| 487 |
-
logging.info(
|
| 488 |
-
"[SP_UI] executor_claim_confirmed task_id=%s pid=%s epoch=%s",
|
| 489 |
-
task_id, process.pid, claim_marker.get("execution_epoch"),
|
| 490 |
-
)
|
| 491 |
-
return int(process.pid)
|
| 492 |
-
return_code = process.poll()
|
| 493 |
-
if return_code is not None:
|
| 494 |
-
logging.error("[SP_UI] executor_bootstrap_exit task_id=%s pid=%s rc=%s", task_id, process.pid, return_code)
|
| 495 |
-
raise RuntimeError(f"executor_bootstrap_failed_exit_{return_code}")
|
| 496 |
-
time.sleep(poll_seconds)
|
| 497 |
-
|
| 498 |
-
try:
|
| 499 |
-
process.terminate()
|
| 500 |
-
except Exception:
|
| 501 |
-
pass
|
| 502 |
-
logging.error("[SP_UI] executor_claim_timeout task_id=%s pid=%s", task_id, process.pid)
|
| 503 |
-
raise RuntimeError("executor_claim_timeout")
|
| 504 |
|
| 505 |
|
| 506 |
def _process_pending_cancel() -> None:
|
|
@@ -529,6 +494,7 @@ def _process_pending_resume() -> None:
|
|
| 529 |
st.rerun()
|
| 530 |
_launch_executor(task_id, force=True)
|
| 531 |
st.session_state["sp_pending_resume_task_id"] = ""
|
|
|
|
| 532 |
st.rerun()
|
| 533 |
|
| 534 |
|
|
@@ -575,6 +541,7 @@ def _process_pending_launch(lang_code: str) -> None:
|
|
| 575 |
st.rerun()
|
| 576 |
st.session_state["sp_pending_launch"] = None
|
| 577 |
st.session_state["sp_payment_required"] = False
|
|
|
|
| 578 |
st.rerun()
|
| 579 |
|
| 580 |
|
|
@@ -597,7 +564,6 @@ 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 "")
|
|
@@ -655,21 +621,27 @@ def _render_task_status_once(lang_code: str) -> None:
|
|
| 655 |
st.rerun()
|
| 656 |
|
| 657 |
|
| 658 |
-
def
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
if not task_id:
|
| 662 |
return
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 673 |
|
| 674 |
def render_workspace_controls(user):
|
| 675 |
top_toolbar = st.container()
|
|
@@ -695,10 +667,6 @@ def render_workspace_controls(user):
|
|
| 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"))
|
|
@@ -771,4 +739,13 @@ _process_pending_launch(lang_code)
|
|
| 771 |
if st.session_state.get("sp_payment_required"):
|
| 772 |
render_payment_purchase(lang_code)
|
| 773 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 774 |
|
|
|
|
| 7 |
import re
|
| 8 |
import secrets
|
| 9 |
import shutil
|
|
|
|
| 10 |
import subprocess
|
| 11 |
import sys
|
| 12 |
import time
|
|
|
|
| 395 |
|
| 396 |
|
| 397 |
def _launch_executor(task_id: str, *, force: bool = False) -> int:
|
| 398 |
+
"""Start a detached executor without making Streamlit its lifecycle owner.
|
| 399 |
|
| 400 |
+
``subprocess.Popen`` only proves that the OS accepted the child process.
|
| 401 |
+
R3.2 therefore records the PID and returns immediately. The executor owns
|
| 402 |
+
claim/heartbeat/checkpoint/finalization; Worker/D1 owns task authority and
|
| 403 |
+
startup-lease expiry. Streamlit never waits for claim and never terminates
|
| 404 |
+
an executor because a UI-side timer expired.
|
| 405 |
"""
|
| 406 |
task_dir = _task_dir(task_id)
|
| 407 |
request_path = task_dir / "request.json"
|
|
|
|
| 419 |
try:
|
| 420 |
marker = json.loads(launch_marker_path.read_text(encoding="utf-8"))
|
| 421 |
pid = int(marker.get("pid") or 0)
|
| 422 |
+
if _pid_is_task_executor(pid, task_id, task_dir) and not force:
|
| 423 |
+
logging.info("[SP_UI] executor_already_spawned task_id=%s pid=%s", task_id, pid)
|
| 424 |
+
return pid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
except Exception:
|
| 426 |
pass
|
| 427 |
|
| 428 |
+
# A stale local claim marker can never establish ownership for a new
|
| 429 |
+
# launch/resume. Epoch fencing in Worker/D1, not process termination here,
|
| 430 |
+
# prevents an older executor from overwriting a newer epoch.
|
| 431 |
try:
|
| 432 |
claim_marker_path.unlink(missing_ok=True)
|
| 433 |
except TypeError:
|
|
|
|
| 455 |
start_new_session=True,
|
| 456 |
env=executor_env,
|
| 457 |
)
|
| 458 |
+
_atomic_json(
|
| 459 |
+
launch_marker_path,
|
| 460 |
+
{
|
| 461 |
+
"pid": process.pid,
|
| 462 |
+
"task_id": str(task_id),
|
| 463 |
+
"request_hash": request_hash,
|
| 464 |
+
"launched_at": datetime.now(timezone.utc).isoformat(),
|
| 465 |
+
},
|
| 466 |
+
)
|
| 467 |
logging.info("[SP_UI] executor_spawned task_id=%s pid=%s", task_id, process.pid)
|
| 468 |
+
return int(process.pid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
|
| 470 |
|
| 471 |
def _process_pending_cancel() -> None:
|
|
|
|
| 494 |
st.rerun()
|
| 495 |
_launch_executor(task_id, force=True)
|
| 496 |
st.session_state["sp_pending_resume_task_id"] = ""
|
| 497 |
+
st.session_state["sp_scroll_to_task_monitor"] = True
|
| 498 |
st.rerun()
|
| 499 |
|
| 500 |
|
|
|
|
| 541 |
st.rerun()
|
| 542 |
st.session_state["sp_pending_launch"] = None
|
| 543 |
st.session_state["sp_payment_required"] = False
|
| 544 |
+
st.session_state["sp_scroll_to_task_monitor"] = True
|
| 545 |
st.rerun()
|
| 546 |
|
| 547 |
|
|
|
|
| 564 |
|
| 565 |
|
| 566 |
ACTIVE_TASK_STATUSES = {"created", "running", "resumable", "cancelling"}
|
|
|
|
| 567 |
|
| 568 |
def _render_monitor_box(task: dict, lang_code: str) -> None:
|
| 569 |
status = str(task.get("status") or "")
|
|
|
|
| 621 |
st.rerun()
|
| 622 |
|
| 623 |
|
| 624 |
+
def _scroll_to_task_monitor_once() -> None:
|
| 625 |
+
"""One-time UX correction after launch/resume; never polls or reruns."""
|
| 626 |
+
if not bool(st.session_state.get("sp_scroll_to_task_monitor")):
|
|
|
|
| 627 |
return
|
| 628 |
+
st.session_state["sp_scroll_to_task_monitor"] = False
|
| 629 |
+
components.html(
|
| 630 |
+
"""
|
| 631 |
+
<script>
|
| 632 |
+
(() => {
|
| 633 |
+
const parentDocument = window.parent.document;
|
| 634 |
+
window.parent.setTimeout(() => {
|
| 635 |
+
const target = parentDocument.getElementById('sp-task-monitor-anchor');
|
| 636 |
+
if (target) target.scrollIntoView({behavior: 'auto', block: 'start'});
|
| 637 |
+
}, 120);
|
| 638 |
+
})();
|
| 639 |
+
</script>
|
| 640 |
+
""",
|
| 641 |
+
height=0,
|
| 642 |
+
width=0,
|
| 643 |
+
)
|
| 644 |
+
|
| 645 |
|
| 646 |
def render_workspace_controls(user):
|
| 647 |
top_toolbar = st.container()
|
|
|
|
| 667 |
|
| 668 |
st.markdown(f"<div class='main-title'><h2>{tr(lang_code, 'title')}</h2></div>", unsafe_allow_html=True)
|
| 669 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
|
| 671 |
if task_status == "resumable":
|
| 672 |
st.warning(tr(lang_code, "checkpoint_warning"))
|
|
|
|
| 739 |
if st.session_state.get("sp_payment_required"):
|
| 740 |
render_payment_purchase(lang_code)
|
| 741 |
|
| 742 |
+
# R3.2 UI rollback: keep the task monitor at its pre-R3.1.1 location.
|
| 743 |
+
# It refreshes only on a normal Streamlit rerun (user action/manual page refresh);
|
| 744 |
+
# there is no periodic fragment and therefore no 5-second page flicker.
|
| 745 |
+
_task_for_monitor = st.session_state.get("sp_generation_task") or {}
|
| 746 |
+
if str(_task_for_monitor.get("task_id") or "").strip():
|
| 747 |
+
st.markdown("<div id='sp-task-monitor-anchor'></div>", unsafe_allow_html=True)
|
| 748 |
+
_render_task_status_once(lang_code)
|
| 749 |
+
_scroll_to_task_monitor_once()
|
| 750 |
+
|
| 751 |
|
task_executor.py
CHANGED
|
@@ -43,9 +43,31 @@ logging.basicConfig(
|
|
| 43 |
|
| 44 |
|
| 45 |
CLAIM_HANDSHAKE_FILENAME = "executor_claim.json"
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
def _new_executor_instance_id() -> str:
|
|
@@ -151,27 +173,40 @@ class ExecutorApi:
|
|
| 151 |
"request_hash": request_hash,
|
| 152 |
"executor_instance_id": executor_instance_id,
|
| 153 |
}
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
try:
|
| 156 |
return self.post(
|
| 157 |
"/executor/task/claim",
|
| 158 |
payload,
|
| 159 |
-
timeout=
|
| 160 |
)
|
| 161 |
except (
|
| 162 |
requests.exceptions.SSLError,
|
| 163 |
requests.exceptions.Timeout,
|
| 164 |
requests.exceptions.ConnectionError,
|
| 165 |
) as exc:
|
| 166 |
-
|
|
|
|
| 167 |
raise
|
| 168 |
-
delay =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
logging.warning(
|
| 170 |
-
"[SP_EXECUTOR] claim_transport_retry task_id=%s attempt=%s
|
| 171 |
-
task_id, attempt,
|
| 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)
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
CLAIM_HANDSHAKE_FILENAME = "executor_claim.json"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _bounded_env_float(name: str, default: float, minimum: float, maximum: float) -> float:
|
| 49 |
+
try:
|
| 50 |
+
value = float(os.environ.get(name, str(default)))
|
| 51 |
+
except (TypeError, ValueError):
|
| 52 |
+
value = float(default)
|
| 53 |
+
return max(float(minimum), min(float(maximum), value))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# The current Worker startup lease is 90 seconds. R3.2 deliberately keeps the
|
| 57 |
+
# default executor claim window below that lease while allowing materially
|
| 58 |
+
# slower HF->Cloudflare TLS handshakes than R3.1.1's 4-second timeout.
|
| 59 |
+
EXECUTOR_CLAIM_WINDOW_SECONDS = _bounded_env_float(
|
| 60 |
+
"SMARTPAPER_EXECUTOR_CLAIM_WINDOW_SECONDS", 70.0, 20.0, 80.0
|
| 61 |
+
)
|
| 62 |
+
EXECUTOR_CLAIM_REQUEST_TIMEOUT_SECONDS = _bounded_env_float(
|
| 63 |
+
"SMARTPAPER_EXECUTOR_CLAIM_REQUEST_TIMEOUT_SECONDS", 18.0, 5.0, 30.0
|
| 64 |
+
)
|
| 65 |
+
EXECUTOR_CLAIM_BACKOFF_INITIAL_SECONDS = _bounded_env_float(
|
| 66 |
+
"SMARTPAPER_EXECUTOR_CLAIM_BACKOFF_INITIAL_SECONDS", 1.0, 0.2, 5.0
|
| 67 |
+
)
|
| 68 |
+
EXECUTOR_CLAIM_BACKOFF_MAX_SECONDS = _bounded_env_float(
|
| 69 |
+
"SMARTPAPER_EXECUTOR_CLAIM_BACKOFF_MAX_SECONDS", 4.0, 1.0, 10.0
|
| 70 |
+
)
|
| 71 |
|
| 72 |
|
| 73 |
def _new_executor_instance_id() -> str:
|
|
|
|
| 173 |
"request_hash": request_hash,
|
| 174 |
"executor_instance_id": executor_instance_id,
|
| 175 |
}
|
| 176 |
+
deadline = time.monotonic() + EXECUTOR_CLAIM_WINDOW_SECONDS
|
| 177 |
+
attempt = 0
|
| 178 |
+
while True:
|
| 179 |
+
remaining = deadline - time.monotonic()
|
| 180 |
+
if remaining <= 0:
|
| 181 |
+
raise RuntimeError("executor_claim_deadline_exhausted")
|
| 182 |
+
attempt += 1
|
| 183 |
+
request_timeout = max(1.0, min(EXECUTOR_CLAIM_REQUEST_TIMEOUT_SECONDS, remaining))
|
| 184 |
try:
|
| 185 |
return self.post(
|
| 186 |
"/executor/task/claim",
|
| 187 |
payload,
|
| 188 |
+
timeout=request_timeout,
|
| 189 |
)
|
| 190 |
except (
|
| 191 |
requests.exceptions.SSLError,
|
| 192 |
requests.exceptions.Timeout,
|
| 193 |
requests.exceptions.ConnectionError,
|
| 194 |
) as exc:
|
| 195 |
+
remaining = deadline - time.monotonic()
|
| 196 |
+
if remaining <= 0:
|
| 197 |
raise
|
| 198 |
+
delay = min(
|
| 199 |
+
EXECUTOR_CLAIM_BACKOFF_MAX_SECONDS,
|
| 200 |
+
EXECUTOR_CLAIM_BACKOFF_INITIAL_SECONDS * (2 ** max(0, attempt - 1)),
|
| 201 |
+
max(0.0, remaining - 0.25),
|
| 202 |
+
)
|
| 203 |
logging.warning(
|
| 204 |
+
"[SP_EXECUTOR] claim_transport_retry task_id=%s attempt=%s error=%s delay=%.1fs remaining=%.1fs",
|
| 205 |
+
task_id, attempt, type(exc).__name__, delay, remaining,
|
| 206 |
)
|
| 207 |
+
if delay <= 0:
|
| 208 |
+
raise
|
| 209 |
time.sleep(delay)
|
|
|
|
| 210 |
|
| 211 |
def heartbeat(self, payload: dict) -> dict:
|
| 212 |
return self.post("/executor/task/heartbeat", payload)
|