GuXiaopeng commited on
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)

Files changed (2) hide show
  1. app.py +53 -76
  2. 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
- """Launch only after a PID-bound claim handshake is observed.
400
 
401
- ``subprocess.Popen`` proves only that the OS accepted a child process. R3.1
402
- waits until that exact child has successfully claimed the D1 task and
403
- written ``executor_claim.json``. A child that exits/import-fails before
404
- claim is therefore never reported as a running executor.
 
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
- verified_executor = _pid_is_task_executor(pid, task_id, task_dir)
423
- if verified_executor and not force:
424
- claim_marker = {}
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 file must never satisfy a new launch/resume handshake.
 
 
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(launch_marker_path, {"pid": process.pid, "launched_at": datetime.now(timezone.utc).isoformat()})
 
 
 
 
 
 
 
 
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 _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()
@@ -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
- 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:
@@ -151,27 +173,40 @@ class ExecutorApi:
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)
 
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)