Deploy SmartPaper v1.1 R2.1Fix

#4
app.py CHANGED
@@ -20,6 +20,8 @@ warnings.filterwarnings("ignore", message="coroutine 'expire_cache' was never aw
20
  import os
21
  import streamlit.components.v1 as components
22
  import time
 
 
23
  import re
24
  import math
25
  import json
@@ -43,6 +45,11 @@ from i18n import LANGUAGE_OPTIONS, get_amj_csv_template, get_asq_txt_template, g
43
  os.environ["STREAMLIT_SERVER_ENABLE_CORS"] = "false"
44
  os.environ["STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION"] = "false"
45
 
 
 
 
 
 
46
  WORKSPACE_DIR = APP_CONFIG.get("workspace_dir", ".workspace")
47
  os.makedirs(WORKSPACE_DIR, exist_ok=True)
48
  LEGACY_CHECKPOINT_FILE = os.path.join(WORKSPACE_DIR, "snapshot.json")
@@ -52,7 +59,9 @@ META_SECTIONS_BLACKLIST = set(APP_CONFIG["blacklists"]["meta_sections"])
52
 
53
 
54
  def update_heartbeat():
55
- """Refresh the authoritative Cloudflare/D1 task lease only."""
 
 
56
  now = time.time()
57
  task = st.session_state.get("sp_generation_task") or {}
58
  task_id = str(task.get("task_id") or "").strip()
@@ -66,10 +75,12 @@ def update_heartbeat():
66
  current_node=str(st.session_state.get("sp_current_node") or "")[:240],
67
  )
68
  st.session_state["sp_last_task_heartbeat"] = now
69
- except Exception:
70
- # The hidden browser bridge continues heartbeats independently. A single
71
- # transient UI heartbeat must not interrupt model generation.
72
- pass
 
 
73
 
74
 
75
  def is_system_busy():
@@ -407,6 +418,12 @@ def _begin_managed_generation_task(*, is_resume: bool, loaded_state: dict | None
407
  st.session_state["sp_generation_idempotency_key"] = idempotency_key
408
  st.session_state["sp_generation_task"] = task
409
  st.session_state["sp_last_task_heartbeat"] = time.time()
 
 
 
 
 
 
410
  return task, idempotency_key
411
 
412
 
@@ -530,6 +547,12 @@ def _process_pending_task_finalization():
530
  if not package_bytes:
531
  raise RuntimeError("validated_package_file_empty")
532
 
 
 
 
 
 
 
533
  response = finish_generation_task(
534
  task_id,
535
  status=status,
@@ -538,6 +561,13 @@ def _process_pending_task_finalization():
538
  current_node=str(pending.get("current_node") or "")[:240],
539
  )
540
 
 
 
 
 
 
 
 
541
  if status == "completed":
542
  filename = str(package.get("filename") or "SmartPaper.zip")
543
  pending_path = str(package.get("pending_path") or "")
@@ -863,7 +893,12 @@ progress_bar = st.empty()
863
  monitor_ui = st.empty()
864
 
865
  def render_monitor(agent, phase, current_sec="", details=""):
866
- st.session_state["sp_current_node"] = str(current_sec or phase or "")[:240]
 
 
 
 
 
867
  monitor_ui.markdown(f"""
868
  <div class='log-box'>
869
  <div><b>{tr(lang_code, 'monitor_pipeline')}:</b> {phase}</div><div><b>{tr(lang_code, 'monitor_node')}:</b> {current_sec}</div><div><b>{tr(lang_code, 'monitor_log')}:</b> {details}</div><hr>
@@ -922,6 +957,7 @@ if st.session_state.start_pipeline or st.session_state.resume_pipeline:
922
 
923
  st.session_state.is_running = True
924
  agent = SSciPipelineAgent(global_ds_key, global_s2_key)
 
925
  SAFE_CHUNK_WORDS = agent.chunk_words_default
926
  qual_engine = get_qualitative_engine(agent)
927
  quant_engine = get_quantitative_engine()
@@ -1355,6 +1391,13 @@ Output only final academic prose. Do not report the word count."""
1355
  render_monitor(agent, tr(lang_code, "complete"), "✅", tr(lang_code, "document_generated"))
1356
 
1357
  except Exception as e:
 
 
 
 
 
 
 
1358
  if not completion_finalization_attempted:
1359
  try:
1360
  _finish_managed_generation_task(
@@ -1368,6 +1411,12 @@ Output only final academic prose. Do not report the word count."""
1368
  pass
1369
  st.error(tr(lang_code, "startup_error", error=e))
1370
  finally:
 
 
 
 
 
 
1371
  clear_heartbeat()
1372
  st.session_state.is_running = False
1373
  st.session_state.start_pipeline = False
 
20
  import os
21
  import streamlit.components.v1 as components
22
  import time
23
+ import logging
24
+ import threading
25
  import re
26
  import math
27
  import json
 
45
  os.environ["STREAMLIT_SERVER_ENABLE_CORS"] = "false"
46
  os.environ["STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION"] = "false"
47
 
48
+ # Streamlit executes the app on its ScriptRunner thread rather than Python's
49
+ # process main thread. Capture that thread so background worker threads cannot
50
+ # emit browser/D1 heartbeats on behalf of work they do not own.
51
+ STREAMLIT_SCRIPT_THREAD_ID = threading.get_ident()
52
+
53
  WORKSPACE_DIR = APP_CONFIG.get("workspace_dir", ".workspace")
54
  os.makedirs(WORKSPACE_DIR, exist_ok=True)
55
  LEGACY_CHECKPOINT_FILE = os.path.join(WORKSPACE_DIR, "snapshot.json")
 
59
 
60
 
61
  def update_heartbeat():
62
+ """Refresh D1 only while the Python generation process is actually active."""
63
+ if threading.get_ident() != STREAMLIT_SCRIPT_THREAD_ID:
64
+ return
65
  now = time.time()
66
  task = st.session_state.get("sp_generation_task") or {}
67
  task_id = str(task.get("task_id") or "").strip()
 
75
  current_node=str(st.session_state.get("sp_current_node") or "")[:240],
76
  )
77
  st.session_state["sp_last_task_heartbeat"] = now
78
+ except Exception as exc:
79
+ logging.warning(
80
+ "[SP_TASK] heartbeat_enqueue_failed task_id=%s error=%s",
81
+ task_id,
82
+ type(exc).__name__,
83
+ )
84
 
85
 
86
  def is_system_busy():
 
418
  st.session_state["sp_generation_idempotency_key"] = idempotency_key
419
  st.session_state["sp_generation_task"] = task
420
  st.session_state["sp_last_task_heartbeat"] = time.time()
421
+ logging.info(
422
+ "[SP_TASK] task_started task_id=%s mode=%s language=%s",
423
+ str(task.get("task_id") or ""),
424
+ mode,
425
+ language,
426
+ )
427
  return task, idempotency_key
428
 
429
 
 
547
  if not package_bytes:
548
  raise RuntimeError("validated_package_file_empty")
549
 
550
+ logging.info(
551
+ "[SP_TASK] task_finalization_request task_id=%s status=%s node=%s",
552
+ task_id,
553
+ status,
554
+ str(pending.get("current_node") or "")[:120],
555
+ )
556
  response = finish_generation_task(
557
  task_id,
558
  status=status,
 
561
  current_node=str(pending.get("current_node") or "")[:240],
562
  )
563
 
564
+ logging.info(
565
+ "[SP_TASK] task_finalization_confirmed task_id=%s status=%s reservation=%s",
566
+ task_id,
567
+ status,
568
+ str((response.get("reservation") or {}).get("status") or ""),
569
+ )
570
+
571
  if status == "completed":
572
  filename = str(package.get("filename") or "SmartPaper.zip")
573
  pending_path = str(package.get("pending_path") or "")
 
893
  monitor_ui = st.empty()
894
 
895
  def render_monitor(agent, phase, current_sec="", details=""):
896
+ node = str(current_sec or phase or "")[:240]
897
+ st.session_state["sp_current_node"] = node
898
+ if node and node != st.session_state.get("sp_last_logged_node"):
899
+ task_id = str((st.session_state.get("sp_generation_task") or {}).get("task_id") or "")
900
+ logging.info("[SP_TASK] node_enter task_id=%s node=%s", task_id, node)
901
+ st.session_state["sp_last_logged_node"] = node
902
  monitor_ui.markdown(f"""
903
  <div class='log-box'>
904
  <div><b>{tr(lang_code, 'monitor_pipeline')}:</b> {phase}</div><div><b>{tr(lang_code, 'monitor_node')}:</b> {current_sec}</div><div><b>{tr(lang_code, 'monitor_log')}:</b> {details}</div><hr>
 
957
 
958
  st.session_state.is_running = True
959
  agent = SSciPipelineAgent(global_ds_key, global_s2_key)
960
+ agent.set_execution_progress_hook(update_heartbeat)
961
  SAFE_CHUNK_WORDS = agent.chunk_words_default
962
  qual_engine = get_qualitative_engine(agent)
963
  quant_engine = get_quantitative_engine()
 
1391
  render_monitor(agent, tr(lang_code, "complete"), "✅", tr(lang_code, "document_generated"))
1392
 
1393
  except Exception as e:
1394
+ task_id = str((st.session_state.get("sp_generation_task") or {}).get("task_id") or "")
1395
+ logging.exception(
1396
+ "[SP_TASK] generation_exception task_id=%s node=%s error_type=%s",
1397
+ task_id,
1398
+ str(st.session_state.get("sp_current_node") or "generation")[:120],
1399
+ type(e).__name__,
1400
+ )
1401
  if not completion_finalization_attempted:
1402
  try:
1403
  _finish_managed_generation_task(
 
1411
  pass
1412
  st.error(tr(lang_code, "startup_error", error=e))
1413
  finally:
1414
+ task_id = str((st.session_state.get("sp_generation_task") or {}).get("task_id") or "")
1415
+ logging.info(
1416
+ "[SP_TASK] pipeline_exit task_id=%s node=%s",
1417
+ task_id,
1418
+ str(st.session_state.get("sp_current_node") or "")[:120],
1419
+ )
1420
  clear_heartbeat()
1421
  st.session_state.is_running = False
1422
  st.session_state.start_pipeline = False
config.py CHANGED
@@ -21,11 +21,18 @@ def load_config():
21
  "rag_chunk_size": int(os.environ.get("RAG_CHUNK_SIZE", "3000")),
22
  "rag_top_k": int(os.environ.get("RAG_TOP_K", "3")),
23
  "max_extract_chars": int(os.environ.get("MAX_EXTRACT_CHARS", "150000")),
 
 
 
24
  "api_timeouts": {
25
- "standard": (15, 180),
26
- "stream": (15, 300),
27
  "search": 15
28
  },
 
 
 
 
29
 
30
  # --- API 节点配置 ---
31
  "deepseek_api_url": os.environ.get(
 
21
  "rag_chunk_size": int(os.environ.get("RAG_CHUNK_SIZE", "3000")),
22
  "rag_top_k": int(os.environ.get("RAG_TOP_K", "3")),
23
  "max_extract_chars": int(os.environ.get("MAX_EXTRACT_CHARS", "150000")),
24
+ # A DeepSeek request must fail before the 90-second Worker generation
25
+ # lease can expire without real execution progress. Retries renew the
26
+ # lease through the execution heartbeat hook.
27
  "api_timeouts": {
28
+ "standard": (15, 70),
29
+ "stream": (15, 70),
30
  "search": 15
31
  },
32
+ "deepseek_stream_idle_seconds": max(
33
+ 20,
34
+ min(65, int(os.environ.get("DEEPSEEK_STREAM_IDLE_SECONDS", "60"))),
35
+ ),
36
 
37
  # --- API 节点配置 ---
38
  "deepseek_api_url": os.environ.get(
core/pipeline_agent.py CHANGED
@@ -96,6 +96,7 @@ class SSciPipelineAgent:
96
  for source in ("s2", "oa")
97
  }
98
  self._cancel_checker = None
 
99
  self.s2_fused = False
100
  self.oa_fused = False
101
  self.citation_map = {}
@@ -147,6 +148,10 @@ class SSciPipelineAgent:
147
  self.openalex_api_url = APP_CONFIG["openalex_api_url"]
148
  self.s2_api_url = APP_CONFIG["s2_api_url"]
149
  self.timeouts = APP_CONFIG["api_timeouts"]
 
 
 
 
150
 
151
  self.global_memory = {
152
  "hypotheses": "", "variables": "", "model_summary": "",
@@ -164,6 +169,23 @@ class SSciPipelineAgent:
164
  def set_cancel_checker(self, checker):
165
  self._cancel_checker = checker
166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  def _check_cancelled(self):
168
  if self._cancel_checker is not None:
169
  result = self._cancel_checker()
@@ -708,6 +730,13 @@ class SSciPipelineAgent:
708
 
709
  try:
710
  self._check_cancelled()
 
 
 
 
 
 
 
711
  response = requests.post(
712
  self.deepseek_api_url,
713
  headers=self._deepseek_headers(self.ds_key),
@@ -716,6 +745,7 @@ class SSciPipelineAgent:
716
  )
717
 
718
  self._check_cancelled()
 
719
  if response.status_code != 200:
720
  self._raise_for_deepseek_status(response)
721
 
@@ -769,6 +799,7 @@ class SSciPipelineAgent:
769
  self.deepseek_max_retries,
770
  exc,
771
  )
 
772
  if is_last:
773
  break
774
  time.sleep(self._retry_delay(attempt))
@@ -844,10 +875,22 @@ class SSciPipelineAgent:
844
  emitted_content = False
845
  reasoning_open = False
846
  reasoning_chunks = 0
 
 
 
847
  start_time = time.time()
 
848
 
849
  try:
850
  self._check_cancelled()
 
 
 
 
 
 
 
 
851
  with requests.post(
852
  self.deepseek_api_url,
853
  headers=self._deepseek_headers(self.ds_key),
@@ -863,9 +906,22 @@ class SSciPipelineAgent:
863
  ):
864
  self._check_cancelled()
865
  event = self._parse_sse_line(raw_line)
 
 
 
 
 
 
866
  if not event:
867
  continue
868
  if event.get("done"):
 
 
 
 
 
 
 
869
  break
870
 
871
  choices = event.get("choices") or []
@@ -879,7 +935,9 @@ class SSciPipelineAgent:
879
  content = delta.get("content") or ""
880
 
881
  if reasoning_content:
 
882
  reasoning_chunks += 1
 
883
  if not reasoning_open:
884
  reasoning_open = True
885
  logging.info(
@@ -902,6 +960,16 @@ class SSciPipelineAgent:
902
  )
903
 
904
  if content:
 
 
 
 
 
 
 
 
 
 
905
  if (
906
  reasoning_open
907
  and self.deepseek_show_reasoning
@@ -919,9 +987,13 @@ class SSciPipelineAgent:
919
  "DeepSeek stream ended without final content."
920
  )
921
 
 
922
  logging.info(
923
- "DeepSeek stream complete | seconds=%.1f",
924
  time.time() - start_time,
 
 
 
925
  )
926
  return
927
 
@@ -953,6 +1025,7 @@ class SSciPipelineAgent:
953
  "content had already been emitted."
954
  ) from exc
955
 
 
956
  is_last = attempt == self.deepseek_max_retries - 1
957
  if is_last:
958
  break
 
96
  for source in ("s2", "oa")
97
  }
98
  self._cancel_checker = None
99
+ self._execution_progress_hook = None
100
  self.s2_fused = False
101
  self.oa_fused = False
102
  self.citation_map = {}
 
148
  self.openalex_api_url = APP_CONFIG["openalex_api_url"]
149
  self.s2_api_url = APP_CONFIG["s2_api_url"]
150
  self.timeouts = APP_CONFIG["api_timeouts"]
151
+ self.deepseek_stream_idle_seconds = max(
152
+ 20,
153
+ int(APP_CONFIG.get("deepseek_stream_idle_seconds", 60)),
154
+ )
155
 
156
  self.global_memory = {
157
  "hypotheses": "", "variables": "", "model_summary": "",
 
169
  def set_cancel_checker(self, checker):
170
  self._cancel_checker = checker
171
 
172
+ def set_execution_progress_hook(self, hook):
173
+ """Register a best-effort callback tied to real backend execution."""
174
+ self._execution_progress_hook = hook
175
+
176
+ def _touch_execution_progress(self, stage=""):
177
+ hook = self._execution_progress_hook
178
+ if hook is None:
179
+ return
180
+ try:
181
+ hook()
182
+ except Exception as exc:
183
+ logging.warning(
184
+ "Execution heartbeat hook failed | stage=%s | error=%s",
185
+ str(stage or "unknown")[:80],
186
+ type(exc).__name__,
187
+ )
188
+
189
  def _check_cancelled(self):
190
  if self._cancel_checker is not None:
191
  result = self._cancel_checker()
 
730
 
731
  try:
732
  self._check_cancelled()
733
+ self._touch_execution_progress("deepseek_static_request")
734
+ logging.info(
735
+ "DeepSeek static attempt start | attempt=%s/%s | model=%s",
736
+ attempt + 1,
737
+ self.deepseek_max_retries,
738
+ current_model,
739
+ )
740
  response = requests.post(
741
  self.deepseek_api_url,
742
  headers=self._deepseek_headers(self.ds_key),
 
745
  )
746
 
747
  self._check_cancelled()
748
+ self._touch_execution_progress("deepseek_static_response")
749
  if response.status_code != 200:
750
  self._raise_for_deepseek_status(response)
751
 
 
799
  self.deepseek_max_retries,
800
  exc,
801
  )
802
+ self._touch_execution_progress("deepseek_static_retry")
803
  if is_last:
804
  break
805
  time.sleep(self._retry_delay(attempt))
 
875
  emitted_content = False
876
  reasoning_open = False
877
  reasoning_chunks = 0
878
+ content_chunks = 0
879
+ content_chars = 0
880
+ first_content_at = None
881
  start_time = time.time()
882
+ last_model_activity = start_time
883
 
884
  try:
885
  self._check_cancelled()
886
+ self._touch_execution_progress("deepseek_stream_request")
887
+ logging.info(
888
+ "DeepSeek stream attempt start | attempt=%s/%s | model=%s | idle_limit=%ss",
889
+ attempt + 1,
890
+ self.deepseek_max_retries,
891
+ current_model,
892
+ self.deepseek_stream_idle_seconds,
893
+ )
894
  with requests.post(
895
  self.deepseek_api_url,
896
  headers=self._deepseek_headers(self.ds_key),
 
906
  ):
907
  self._check_cancelled()
908
  event = self._parse_sse_line(raw_line)
909
+ now = time.time()
910
+ if now - last_model_activity > self.deepseek_stream_idle_seconds:
911
+ raise DeepSeekAPIError(
912
+ "DeepSeek stream produced no model progress for "
913
+ f"{self.deepseek_stream_idle_seconds} seconds."
914
+ )
915
  if not event:
916
  continue
917
  if event.get("done"):
918
+ logging.info(
919
+ "DeepSeek stream done marker | seconds=%.1f | reasoning_chunks=%s | content_chunks=%s | content_chars=%s",
920
+ now - start_time,
921
+ reasoning_chunks,
922
+ content_chunks,
923
+ content_chars,
924
+ )
925
  break
926
 
927
  choices = event.get("choices") or []
 
935
  content = delta.get("content") or ""
936
 
937
  if reasoning_content:
938
+ last_model_activity = time.time()
939
  reasoning_chunks += 1
940
+ self._touch_execution_progress("deepseek_reasoning")
941
  if not reasoning_open:
942
  reasoning_open = True
943
  logging.info(
 
960
  )
961
 
962
  if content:
963
+ last_model_activity = time.time()
964
+ self._touch_execution_progress("deepseek_content")
965
+ if first_content_at is None:
966
+ first_content_at = last_model_activity
967
+ logging.info(
968
+ "DeepSeek first content | seconds=%.1f",
969
+ first_content_at - start_time,
970
+ )
971
+ content_chunks += 1
972
+ content_chars += len(str(content))
973
  if (
974
  reasoning_open
975
  and self.deepseek_show_reasoning
 
987
  "DeepSeek stream ended without final content."
988
  )
989
 
990
+ self._touch_execution_progress("deepseek_stream_complete")
991
  logging.info(
992
+ "DeepSeek stream complete | seconds=%.1f | reasoning_chunks=%s | content_chunks=%s | content_chars=%s",
993
  time.time() - start_time,
994
+ reasoning_chunks,
995
+ content_chunks,
996
+ content_chars,
997
  )
998
  return
999
 
 
1025
  "content had already been emitted."
1026
  ) from exc
1027
 
1028
+ self._touch_execution_progress("deepseek_stream_retry")
1029
  is_last = attempt == self.deepseek_max_retries - 1
1030
  if is_last:
1031
  break
managed_auth.py CHANGED
@@ -29,6 +29,31 @@ from smartpaper_restore_bridge import edge_bridge
29
  EDGE_ASSERTION_COOKIE_NAME = "smartpaper_edge_assertion"
30
  EDGE_ASSERTION_AUDIENCE = "smartpaper-streamlit"
31
  ASSERTION_CLOCK_SKEW_SECONDS = 30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
 
34
  def _runtime_value(name: str) -> str:
@@ -282,11 +307,19 @@ def heartbeat_generation_task(task_id: str, *, current_node: str = "") -> dict[s
282
  normalized = str(task_id or "").strip()
283
  if not normalized:
284
  raise RuntimeError("task_id_required")
 
 
285
  bucket = int(time.time() // 20)
286
  _edge_call(
287
  "task_heartbeat",
288
  f"heartbeat:{normalized}:{bucket}",
289
- {"task_id": normalized, "current_node": str(current_node or "")[:240]},
 
 
 
 
 
 
290
  fire_and_forget=True,
291
  key_prefix="sp_task_heartbeat",
292
  )
 
29
  EDGE_ASSERTION_COOKIE_NAME = "smartpaper_edge_assertion"
30
  EDGE_ASSERTION_AUDIENCE = "smartpaper-streamlit"
31
  ASSERTION_CLOCK_SKEW_SECONDS = 30
32
+ EXECUTION_HEARTBEAT_PROOF_VERSION = "sp-execution-heartbeat-v1"
33
+
34
+
35
+ def _execution_heartbeat_proof(task_id: str, current_node: str) -> tuple[int, str]:
36
+ """Create a short-lived HMAC proof that only the HF backend can mint."""
37
+ user = st.session_state.get("sp_user") or {}
38
+ user_id = str(user.get("id") or "").strip()
39
+ normalized_task = str(task_id or "").strip()
40
+ normalized_node = str(current_node or "")[:240]
41
+ if not user_id or not normalized_task:
42
+ raise RuntimeError("execution_heartbeat_identity_required")
43
+ issued_at = int(time.time())
44
+ task_hash = hashlib.sha256(normalized_task.encode("utf-8")).hexdigest()
45
+ user_hash = hashlib.sha256(user_id.encode("utf-8")).hexdigest()
46
+ node_hash = hashlib.sha256(normalized_node.encode("utf-8")).hexdigest()
47
+ canonical = (
48
+ f"{EXECUTION_HEARTBEAT_PROOF_VERSION}:"
49
+ f"{task_hash}:{user_hash}:{node_hash}:{issued_at}"
50
+ )
51
+ signature = hmac.new(
52
+ _service_key().encode("utf-8"),
53
+ canonical.encode("ascii"),
54
+ hashlib.sha256,
55
+ ).hexdigest()
56
+ return issued_at, signature
57
 
58
 
59
  def _runtime_value(name: str) -> str:
 
307
  normalized = str(task_id or "").strip()
308
  if not normalized:
309
  raise RuntimeError("task_id_required")
310
+ normalized_node = str(current_node or "")[:240]
311
+ issued_at, proof = _execution_heartbeat_proof(normalized, normalized_node)
312
  bucket = int(time.time() // 20)
313
  _edge_call(
314
  "task_heartbeat",
315
  f"heartbeat:{normalized}:{bucket}",
316
+ {
317
+ "task_id": normalized,
318
+ "current_node": normalized_node,
319
+ "heartbeat_source": "execution",
320
+ "heartbeat_issued_at": issued_at,
321
+ "heartbeat_proof": proof,
322
+ },
323
  fire_and_forget=True,
324
  key_prefix="sp_task_heartbeat",
325
  )
smartpaper_restore_bridge/frontend/index.html CHANGED
@@ -199,25 +199,14 @@
199
  }
200
  }
201
 
202
- async function heartbeatActiveTask() {
203
- const active = readJson(ACTIVE_TASK_KEY, null);
204
- if (!active || !active.api_origin || !active.task_id) return;
205
- const result = await postJson(active.api_origin, ACTION_PATHS.task_heartbeat, {
206
- task_id:active.task_id,
207
- current_node:active.current_node || "generation"
208
- });
209
- if (result.cancel_requested || [401,403,404,409].includes(Number(result.http_status || 0))) {
210
- window.localStorage.removeItem(ACTIVE_TASK_KEY);
211
- }
212
- }
213
-
214
  function startBackgroundWork() {
 
 
 
215
  if (heartbeatTimer) return;
216
  heartbeatTimer = window.setInterval(() => {
217
- heartbeatActiveTask();
218
  drainPending();
219
  }, HEARTBEAT_MS);
220
- heartbeatActiveTask();
221
  drainPending();
222
  }
223
 
 
199
  }
200
  }
201
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  function startBackgroundWork() {
203
+ // R2: the browser may retry commands that Python already emitted, but it
204
+ // must never manufacture task liveness merely because the tab remains open.
205
+ // Only real backend execution progress is allowed to enqueue task_heartbeat.
206
  if (heartbeatTimer) return;
207
  heartbeatTimer = window.setInterval(() => {
 
208
  drainPending();
209
  }, HEARTBEAT_MS);
 
210
  drainPending();
211
  }
212