Step 4A-6-C-R3.2.4 Live Progress UX & Terminal Notification Fix

#14
.gitattributes CHANGED
@@ -33,4 +33,3 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
- core/__pycache__/pipeline_agent.cpython-313.pyc filter=lfs diff=lfs merge=lfs -text
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
app.py CHANGED
@@ -30,6 +30,7 @@ from i18n import (
30
  LANGUAGE_OPTIONS, get_amj_csv_template, get_asq_txt_template,
31
  get_output_suffix, get_target_flag, get_target_language, tr,
32
  )
 
33
 
34
  st.set_page_config(
35
  page_title="Article Agent",
@@ -267,6 +268,7 @@ for key, default in {
267
  "sp_terminal_notice": None,
268
  "sp_task_recovery_initialized": False,
269
  "sp_last_ui_task_status": "",
 
270
  }.items():
271
  if key not in st.session_state:
272
  st.session_state[key] = default
@@ -494,7 +496,7 @@ def _process_pending_resume() -> 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,7 +543,7 @@ def _process_pending_launch(lang_code: str) -> None:
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,26 +566,80 @@ os.makedirs(_user_workspace, exist_ok=True)
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 "")
570
  node = str(task.get("current_node") or status or "")
571
  progress = max(0, min(100, int(task.get("progress_pct") or 0)))
572
- if status in {"created", "running", "resumable", "cancelling"}:
573
- st.progress(progress, text=f"{progress}% — {node}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
574
  st.markdown(f"""
575
- <div class='log-box'>
576
- <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>
 
 
 
577
  <div><b>{tr(lang_code, 'monitor_compute')}:</b></div>
578
- <div style='display: flex; gap: 8px;'>
579
- <span style='background: #fef3c7; padding: 4px 8px; border-radius: 4px; color: black;'>🧠 DeepSeek: <b>{int(task.get('ds_calls') or 0)}</b></span>
580
- <span style='background: #dbeafe; padding: 4px 8px; border-radius: 4px; color: black;'>📚 S2: <b>{int(task.get('s2_calls') or 0)}</b></span>
581
- <span style='background: #d1fae5; padding: 4px 8px; border-radius: 4px; color: black;'>🌐 OpenAlex: <b>{int(task.get('oa_calls') or 0)}</b></span>
582
  </div>
583
  </div>
584
  """, unsafe_allow_html=True)
585
 
586
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587
  def _render_task_status_once(lang_code: str) -> None:
588
  task = st.session_state.get("sp_generation_task") or {}
589
  task_id = str(task.get("task_id") or "").strip()
@@ -591,7 +647,7 @@ def _render_task_status_once(lang_code: str) -> None:
591
  return
592
  previous_status = str(st.session_state.get("sp_last_ui_task_status") or str(task.get("status") or ""))
593
  status = str(task.get("status") or "")
594
- if status in {"created", "running", "resumable", "cancelling"}:
595
  try:
596
  result = generation_task_status(task_id)
597
  task = dict(result.get("task") or task)
@@ -601,7 +657,9 @@ def _render_task_status_once(lang_code: str) -> None:
601
  logging.warning("[SP_UI] status_poll_error task_id=%s error=%s", task_id, type(exc).__name__)
602
  st.session_state["sp_last_ui_task_status"] = status
603
  _render_monitor_box(task, lang_code)
604
- if status == "completed":
 
 
605
  output_path = _task_output_path(task)
606
  if output_path and output_path.is_file():
607
  st.success(tr(lang_code, "success"))
@@ -615,27 +673,35 @@ def _render_task_status_once(lang_code: str) -> None:
615
  if detail:
616
  st.warning(detail)
617
 
618
- # Only boundary transitions need one full app rerun so static controls pick
619
- # up resume/terminal state. Running progress never reruns the whole page.
620
  if previous_status and status != previous_status and status in {"resumable", "completed", "failed", "cancelled"}:
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,
@@ -702,6 +768,7 @@ def render_workspace_controls(user):
702
  template = get_amj_csv_template(lang_code)
703
  st.code(template, language="csv")
704
  st.download_button(label=tr(lang_code, "download_amj_template"), data=template, file_name="AMJ_Data_Template.csv", mime="text/csv", use_container_width=True)
 
705
  if st.button(tr(lang_code, "start_amj"), disabled=active, key="btn_amj"):
706
  st.session_state["run_mode"] = "amj"
707
  st.session_state["sp_pending_launch"] = {"mode": "amj", "idempotency_key": _new_generation_idempotency_key()}
@@ -717,6 +784,7 @@ def render_workspace_controls(user):
717
  template = get_asq_txt_template(lang_code)
718
  st.code(template, language="text")
719
  st.download_button(label=tr(lang_code, "download_asq_template"), data=template, file_name="ASQ_Interview_Template.txt", mime="text/plain", use_container_width=True)
 
720
  if st.button(tr(lang_code, "start_asq"), disabled=active, key="btn_asq"):
721
  st.session_state["run_mode"] = "asq"
722
  st.session_state["sp_pending_launch"] = {"mode": "asq", "idempotency_key": _new_generation_idempotency_key()}
@@ -739,13 +807,13 @@ _process_pending_launch(lang_code)
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
 
 
30
  LANGUAGE_OPTIONS, get_amj_csv_template, get_asq_txt_template,
31
  get_output_suffix, get_target_flag, get_target_language, tr,
32
  )
33
+ from smartpaper_restore_bridge import edge_bridge
34
 
35
  st.set_page_config(
36
  page_title="Article Agent",
 
268
  "sp_terminal_notice": None,
269
  "sp_task_recovery_initialized": False,
270
  "sp_last_ui_task_status": "",
271
+ "sp_scroll_to_generation_action": False,
272
  }.items():
273
  if key not in st.session_state:
274
  st.session_state[key] = default
 
496
  st.rerun()
497
  _launch_executor(task_id, force=True)
498
  st.session_state["sp_pending_resume_task_id"] = ""
499
+ st.session_state["sp_scroll_to_generation_action"] = True
500
  st.rerun()
501
 
502
 
 
543
  st.rerun()
544
  st.session_state["sp_pending_launch"] = None
545
  st.session_state["sp_payment_required"] = False
546
+ st.session_state["sp_scroll_to_generation_action"] = True
547
  st.rerun()
548
 
549
 
 
566
 
567
 
568
  ACTIVE_TASK_STATUSES = {"created", "running", "resumable", "cancelling"}
569
+ LIVE_TASK_STATUSES = {"created", "running", "cancelling"}
570
 
571
  def _render_monitor_box(task: dict, lang_code: str) -> None:
572
+ """Render the approved monitor location with DOM targets for live updates."""
573
  status = str(task.get("status") or "")
574
  node = str(task.get("current_node") or status or "")
575
  progress = max(0, min(100, int(task.get("progress_pct") or 0)))
576
+ checkpoint_seq = int(task.get("checkpoint_seq") or 0)
577
+ checkpoint_stage = str(task.get("checkpoint_stage") or "")
578
+ ds_calls = int(task.get("ds_calls") or 0)
579
+ s2_calls = int(task.get("s2_calls") or 0)
580
+ oa_calls = int(task.get("oa_calls") or 0)
581
+
582
+ progress_markup = ""
583
+ if status in ACTIVE_TASK_STATUSES:
584
+ progress_markup = f"""
585
+ <div id='sp-live-progress-wrap' style='margin:0 0 12px 0;'>
586
+ <div id='sp-live-progress-text' style='margin-bottom:7px;'>{progress}% — {node}</div>
587
+ <div style='width:100%;height:10px;background:rgba(128,128,128,.24);border-radius:999px;overflow:hidden;'>
588
+ <div id='sp-live-progress-fill' style='width:{progress}%;height:100%;background:currentColor;border-radius:999px;transition:width .35s ease;'></div>
589
+ </div>
590
+ </div>
591
+ """
592
+
593
  st.markdown(f"""
594
+ {progress_markup}
595
+ <div class='log-box' id='sp-live-monitor'>
596
+ <div><b>{tr(lang_code, 'monitor_pipeline')}:</b> <span id='sp-live-status'>{status}</span></div>
597
+ <div><b>{tr(lang_code, 'monitor_node')}:</b> <span id='sp-live-node'>{node}</span></div>
598
+ <div><b>{tr(lang_code, 'monitor_log')}:</b> checkpoint <span id='sp-live-checkpoint-seq'>{checkpoint_seq}</span> · <span id='sp-live-checkpoint-stage'>{checkpoint_stage}</span></div><hr>
599
  <div><b>{tr(lang_code, 'monitor_compute')}:</b></div>
600
+ <div style='display:flex;gap:8px;'>
601
+ <span style='background: #fef3c7;padding:4px 8px;border-radius:4px;color:black;'>🧠 DeepSeek: <b id='sp-live-ds'>{ds_calls}</b></span>
602
+ <span style='background: #dbeafe;padding:4px 8px;border-radius:4px;color:black;'>📚 S2: <b id='sp-live-s2'>{s2_calls}</b></span>
603
+ <span style='background: #d1fae5;padding:4px 8px;border-radius:4px;color:black;'>🌐 OpenAlex: <b id='sp-live-oa'>{oa_calls}</b></span>
604
  </div>
605
  </div>
606
  """, unsafe_allow_html=True)
607
 
608
 
609
+ def _watch_task_status_live(task: dict) -> None:
610
+ """Start browser-only read polling for active task UI state.
611
+
612
+ The component updates monitor DOM locally. It sends a Streamlit value only
613
+ when a terminal task state is observed, causing exactly one boundary rerun.
614
+ It never sends heartbeat, claim, finish, or cancellation commands.
615
+ """
616
+ task_id = str(task.get("task_id") or "").strip()
617
+ status = str(task.get("status") or "")
618
+ if not task_id or status not in LIVE_TASK_STATUSES:
619
+ return
620
+ api_origin = str(st.session_state.get("sp_auth_api_origin") or "").strip()
621
+ if not api_origin:
622
+ logging.warning("[SP_UI] live_watch_missing_api_origin task_id=%s", task_id)
623
+ return
624
+ try:
625
+ watch_result = edge_bridge(
626
+ action="task_watch",
627
+ command_id=f"watch:{task_id}",
628
+ api_origin=api_origin,
629
+ payload={"task_id": task_id, "poll_interval_ms": 2500},
630
+ key=f"sp_task_watch_{_safe_key(task_id)}",
631
+ )
632
+ boundary_task = watch_result.get("task") if isinstance(watch_result, dict) and watch_result.get("boundary") else None
633
+ if isinstance(boundary_task, dict) and str(boundary_task.get("task_id") or "") == task_id:
634
+ # Consume the watcher's authoritative boundary snapshot directly so
635
+ # the ordinary 5-second task-status cache cannot delay terminal UI.
636
+ st.session_state["sp_generation_task"] = dict(boundary_task)
637
+ st.session_state["sp_last_ui_task_status"] = str(boundary_task.get("status") or "")
638
+ st.rerun()
639
+ except Exception as exc:
640
+ logging.warning("[SP_UI] live_watch_error task_id=%s error=%s", task_id, type(exc).__name__)
641
+
642
+
643
  def _render_task_status_once(lang_code: str) -> None:
644
  task = st.session_state.get("sp_generation_task") or {}
645
  task_id = str(task.get("task_id") or "").strip()
 
647
  return
648
  previous_status = str(st.session_state.get("sp_last_ui_task_status") or str(task.get("status") or ""))
649
  status = str(task.get("status") or "")
650
+ if status in ACTIVE_TASK_STATUSES:
651
  try:
652
  result = generation_task_status(task_id)
653
  task = dict(result.get("task") or task)
 
657
  logging.warning("[SP_UI] status_poll_error task_id=%s error=%s", task_id, type(exc).__name__)
658
  st.session_state["sp_last_ui_task_status"] = status
659
  _render_monitor_box(task, lang_code)
660
+ if status in LIVE_TASK_STATUSES:
661
+ _watch_task_status_live(task)
662
+ elif status == "completed":
663
  output_path = _task_output_path(task)
664
  if output_path and output_path.is_file():
665
  st.success(tr(lang_code, "success"))
 
673
  if detail:
674
  st.warning(detail)
675
 
676
+ # Only terminal/resume boundaries may rerun the whole Streamlit app. Live
677
+ # progress is DOM-only and therefore cannot reintroduce periodic flicker.
678
  if previous_status and status != previous_status and status in {"resumable", "completed", "failed", "cancelled"}:
679
  st.rerun()
680
 
681
 
682
+ def _scroll_to_generation_action_once() -> None:
683
+ """One-time post-launch/resume scroll to the paper-generation action area."""
684
+ if not bool(st.session_state.get("sp_scroll_to_generation_action")):
685
  return
686
+ st.session_state["sp_scroll_to_generation_action"] = False
687
+ mode = str(st.session_state.get("run_mode") or "amj").lower()
688
+ target_id = "sp-generation-action-asq" if mode == "asq" else "sp-generation-action-amj"
689
  components.html(
690
+ f"""
691
  <script>
692
+ (() => {{
693
  const parentDocument = window.parent.document;
694
+ const targetId = {json.dumps(target_id)};
695
+ let done = false;
696
+ const locate = () => {{
697
+ if (done) return;
698
+ const target = parentDocument.getElementById(targetId);
699
+ if (!target) return;
700
+ done = true;
701
+ target.scrollIntoView({{behavior:'auto', block:'center'}});
702
+ }};
703
+ [60, 160, 320, 650, 1100].forEach((delay) => window.parent.setTimeout(locate, delay));
704
+ }})();
705
  </script>
706
  """,
707
  height=0,
 
768
  template = get_amj_csv_template(lang_code)
769
  st.code(template, language="csv")
770
  st.download_button(label=tr(lang_code, "download_amj_template"), data=template, file_name="AMJ_Data_Template.csv", mime="text/csv", use_container_width=True)
771
+ st.markdown("<div id='sp-generation-action-amj'></div>", unsafe_allow_html=True)
772
  if st.button(tr(lang_code, "start_amj"), disabled=active, key="btn_amj"):
773
  st.session_state["run_mode"] = "amj"
774
  st.session_state["sp_pending_launch"] = {"mode": "amj", "idempotency_key": _new_generation_idempotency_key()}
 
784
  template = get_asq_txt_template(lang_code)
785
  st.code(template, language="text")
786
  st.download_button(label=tr(lang_code, "download_asq_template"), data=template, file_name="ASQ_Interview_Template.txt", mime="text/plain", use_container_width=True)
787
+ st.markdown("<div id='sp-generation-action-asq'></div>", unsafe_allow_html=True)
788
  if st.button(tr(lang_code, "start_asq"), disabled=active, key="btn_asq"):
789
  st.session_state["run_mode"] = "asq"
790
  st.session_state["sp_pending_launch"] = {"mode": "asq", "idempotency_key": _new_generation_idempotency_key()}
 
807
  if st.session_state.get("sp_payment_required"):
808
  render_payment_purchase(lang_code)
809
 
810
+ # R3.2.4 keeps the approved task-monitor location. Active progress is updated
811
+ # inside the browser by read-only edge polling; Streamlit reruns only on a
812
+ # user action or a terminal/resumable boundary, so there is no periodic flicker.
813
  _task_for_monitor = st.session_state.get("sp_generation_task") or {}
814
  if str(_task_for_monitor.get("task_id") or "").strip():
815
  st.markdown("<div id='sp-task-monitor-anchor'></div>", unsafe_allow_html=True)
816
  _render_task_status_once(lang_code)
817
+ _scroll_to_generation_action_once()
818
 
819
 
core/generation_runner.py CHANGED
@@ -74,16 +74,30 @@ class GenerationRunner:
74
  self.output_dir.mkdir(parents=True, exist_ok=True)
75
  self.agent = None
76
 
77
- def _progress(self, pct: int, node: str, *, checkpoint_seq=None, checkpoint_stage=None) -> None:
 
78
  agent = self.agent
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  self.progress_callback(
80
  progress_pct=max(0, min(100, int(pct))),
81
  current_node=str(node or "")[:240],
82
  checkpoint_seq=checkpoint_seq,
83
  checkpoint_stage=checkpoint_stage,
84
- ds_calls=int(getattr(agent, "ds_calls", 0) or 0),
85
- s2_calls=int(getattr(agent, "s2_calls", 0) or 0),
86
- oa_calls=int(getattr(agent, "oa_calls", 0) or 0),
87
  )
88
 
89
  def _checkpoint(self, stage: str, state: dict[str, Any], pct: int) -> None:
@@ -527,10 +541,15 @@ Output only final academic prose. Do not report the word count."""
527
  return {"output_filename": output_filename, "output_path": str(final)}
528
 
529
  def _progress_activity(self) -> None:
530
- # This call intentionally does not update D1 directly. The executor's
531
- # heartbeat thread uses it only to prove that real backend work is still
532
- # occurring, which avoids browser-driven or timer-only zombie liveness.
533
  try:
534
- self.progress_callback(activity_only=True)
 
 
 
 
 
535
  except TypeError:
536
  pass
 
74
  self.output_dir.mkdir(parents=True, exist_ok=True)
75
  self.agent = None
76
 
77
+ def _compute_request_counts(self) -> tuple[int, int, int]:
78
+ """Return one consistent UI metric: actual outbound request attempts."""
79
  agent = self.agent
80
+ if agent is None:
81
+ return 0, 0, 0
82
+ metrics = getattr(agent, "source_metrics", {}) or {}
83
+ s2_metric = metrics.get("s2", {}) if isinstance(metrics, dict) else {}
84
+ oa_metric = metrics.get("oa", {}) if isinstance(metrics, dict) else {}
85
+ return (
86
+ int(getattr(agent, "ds_calls", 0) or 0),
87
+ int(s2_metric.get("request_count", 0) or 0),
88
+ int(oa_metric.get("request_count", 0) or 0),
89
+ )
90
+
91
+ def _progress(self, pct: int, node: str, *, checkpoint_seq=None, checkpoint_stage=None) -> None:
92
+ ds_calls, s2_calls, oa_calls = self._compute_request_counts()
93
  self.progress_callback(
94
  progress_pct=max(0, min(100, int(pct))),
95
  current_node=str(node or "")[:240],
96
  checkpoint_seq=checkpoint_seq,
97
  checkpoint_stage=checkpoint_stage,
98
+ ds_calls=ds_calls,
99
+ s2_calls=s2_calls,
100
+ oa_calls=oa_calls,
101
  )
102
 
103
  def _checkpoint(self, stage: str, state: dict[str, Any], pct: int) -> None:
 
541
  return {"output_filename": output_filename, "output_path": str(final)}
542
 
543
  def _progress_activity(self) -> None:
544
+ # Activity remains Executor-owned. We also snapshot actual request
545
+ # counters so the next Executor heartbeat can persist live UI metrics.
546
+ ds_calls, s2_calls, oa_calls = self._compute_request_counts()
547
  try:
548
+ self.progress_callback(
549
+ activity_only=True,
550
+ ds_calls=ds_calls,
551
+ s2_calls=s2_calls,
552
+ oa_calls=oa_calls,
553
+ )
554
  except TypeError:
555
  pass
smartpaper_restore_bridge/frontend/index.html CHANGED
@@ -19,7 +19,9 @@
19
  const MAX_TOKEN_LENGTH = 4096;
20
  const MAX_RESULT_AGE_MS = 10 * 60 * 1000;
21
  const REQUEST_TIMEOUT_MS = 20000;
 
22
  let lastResponseSignature = "";
 
23
 
24
  const ACTION_PATHS = Object.freeze({
25
  task_start: "/edge/task/start",
@@ -172,6 +174,90 @@
172
  writeJson(RESULT_PREFIX + commandId, { saved_at:Date.now(), value });
173
  }
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  async function handleEdgeAction(action, commandId, args) {
176
  const path = ACTION_PATHS[action];
177
  if (!path) return { ok:false, error:"unsupported_edge_action", action, command_id:commandId };
@@ -196,6 +282,11 @@
196
  const args = message.args || {};
197
  const action = typeof args.action === "string" ? args.action : "read";
198
  const commandId = typeof args.command_id === "string" ? args.command_id : "";
 
 
 
 
 
199
  let response;
200
  try {
201
  response = ["read","write","delete"].includes(action)
 
19
  const MAX_TOKEN_LENGTH = 4096;
20
  const MAX_RESULT_AGE_MS = 10 * 60 * 1000;
21
  const REQUEST_TIMEOUT_MS = 20000;
22
+ const TERMINAL_TASK_STATUSES = new Set(["completed", "failed", "cancelled"]);
23
  let lastResponseSignature = "";
24
+ let activeTaskWatch = null;
25
 
26
  const ACTION_PATHS = Object.freeze({
27
  task_start: "/edge/task/start",
 
174
  writeJson(RESULT_PREFIX + commandId, { saved_at:Date.now(), value });
175
  }
176
 
177
+ function asCount(value) {
178
+ const number = Number(value || 0);
179
+ return Number.isFinite(number) ? Math.max(0, Math.trunc(number)) : 0;
180
+ }
181
+
182
+ function updateText(parentDocument, id, value) {
183
+ const element = parentDocument.getElementById(id);
184
+ if (element) element.textContent = String(value ?? "");
185
+ }
186
+
187
+ function updateTaskMonitor(task) {
188
+ let parentDocument;
189
+ try { parentDocument = window.parent.document; }
190
+ catch (_) { return; }
191
+ const status = String(task && task.status || "");
192
+ const node = String(task && task.current_node || status || "");
193
+ const progress = Math.max(0, Math.min(100, asCount(task && task.progress_pct)));
194
+ updateText(parentDocument, "sp-live-status", status);
195
+ updateText(parentDocument, "sp-live-node", node);
196
+ updateText(parentDocument, "sp-live-checkpoint-seq", asCount(task && task.checkpoint_seq));
197
+ updateText(parentDocument, "sp-live-checkpoint-stage", String(task && task.checkpoint_stage || ""));
198
+ updateText(parentDocument, "sp-live-ds", asCount(task && task.ds_calls));
199
+ updateText(parentDocument, "sp-live-s2", asCount(task && task.s2_calls));
200
+ updateText(parentDocument, "sp-live-oa", asCount(task && task.oa_calls));
201
+ updateText(parentDocument, "sp-live-progress-text", `${progress}% — ${node}`);
202
+ const fill = parentDocument.getElementById("sp-live-progress-fill");
203
+ if (fill) fill.style.width = `${progress}%`;
204
+ }
205
+
206
+ function stopTaskWatch() {
207
+ const watch = activeTaskWatch;
208
+ activeTaskWatch = null;
209
+ if (watch && watch.timer) window.clearTimeout(watch.timer);
210
+ if (watch && watch.controller) {
211
+ try { watch.controller.abort(); } catch (_) {}
212
+ }
213
+ }
214
+
215
+ function startTaskWatch(commandId, args) {
216
+ stopTaskWatch();
217
+ const apiOrigin = normalizeOrigin(args.api_origin || "");
218
+ const payload = args.payload && typeof args.payload === "object" ? args.payload : {};
219
+ const taskId = typeof payload.task_id === "string" ? payload.task_id.trim() : "";
220
+ const requestedInterval = Number(payload.poll_interval_ms || 2500);
221
+ const intervalMs = Math.max(1500, Math.min(10000, Number.isFinite(requestedInterval) ? requestedInterval : 2500));
222
+ if (!apiOrigin || !taskId) {
223
+ sendValue({ ok:false, action:"task_watch", command_id:commandId, error:"invalid_task_watch" });
224
+ return;
225
+ }
226
+
227
+ const watch = { commandId, taskId, apiOrigin, intervalMs, timer:null, controller:null, stopped:false };
228
+ activeTaskWatch = watch;
229
+
230
+ const schedule = () => {
231
+ if (activeTaskWatch !== watch || watch.stopped) return;
232
+ watch.timer = window.setTimeout(poll, watch.intervalMs);
233
+ };
234
+
235
+ const poll = async () => {
236
+ if (activeTaskWatch !== watch || watch.stopped) return;
237
+ const result = await postJson(watch.apiOrigin, ACTION_PATHS.task_status, { task_id:watch.taskId });
238
+ if (activeTaskWatch !== watch || watch.stopped) return;
239
+ const task = result && result.task && typeof result.task === "object" ? result.task : null;
240
+ if (result.ok && task) {
241
+ updateTaskMonitor(task);
242
+ const status = String(task.status || "");
243
+ if (TERMINAL_TASK_STATUSES.has(status) || status === "resumable") {
244
+ watch.stopped = true;
245
+ activeTaskWatch = null;
246
+ sendValue(Object.assign({}, result, {
247
+ action:"task_watch",
248
+ command_id:commandId,
249
+ terminal:TERMINAL_TASK_STATUSES.has(status),
250
+ boundary:true
251
+ }));
252
+ return;
253
+ }
254
+ }
255
+ schedule();
256
+ };
257
+
258
+ poll();
259
+ }
260
+
261
  async function handleEdgeAction(action, commandId, args) {
262
  const path = ACTION_PATHS[action];
263
  if (!path) return { ok:false, error:"unsupported_edge_action", action, command_id:commandId };
 
282
  const args = message.args || {};
283
  const action = typeof args.action === "string" ? args.action : "read";
284
  const commandId = typeof args.command_id === "string" ? args.command_id : "";
285
+ if (action === "task_watch") {
286
+ setFrameHeight();
287
+ startTaskWatch(commandId, args);
288
+ return;
289
+ }
290
  let response;
291
  try {
292
  response = ["read","write","delete"].includes(action)
task_executor.py CHANGED
@@ -276,6 +276,15 @@ class ExecutionState:
276
  def touch(self, *, progress_pct=None, current_node=None, checkpoint_seq=None, checkpoint_stage=None, ds_calls=None, s2_calls=None, oa_calls=None, activity_only=False):
277
  with self._lock:
278
  self._last_activity = time.monotonic()
 
 
 
 
 
 
 
 
 
279
  if not activity_only:
280
  if progress_pct is not None:
281
  self._progress_pct = max(self._progress_pct, max(0, min(100, int(progress_pct))))
@@ -284,12 +293,6 @@ class ExecutionState:
284
  if checkpoint_seq is not None and int(checkpoint_seq) >= self._checkpoint_seq:
285
  self._checkpoint_seq = int(checkpoint_seq)
286
  self._checkpoint_stage = str(checkpoint_stage or self._checkpoint_stage)[:120]
287
- if ds_calls is not None:
288
- self._ds_calls = max(self._ds_calls, int(ds_calls))
289
- if s2_calls is not None:
290
- self._s2_calls = max(self._s2_calls, int(s2_calls))
291
- if oa_calls is not None:
292
- self._oa_calls = max(self._oa_calls, int(oa_calls))
293
 
294
  def cancelled(self) -> bool:
295
  with self._lock:
 
276
  def touch(self, *, progress_pct=None, current_node=None, checkpoint_seq=None, checkpoint_stage=None, ds_calls=None, s2_calls=None, oa_calls=None, activity_only=False):
277
  with self._lock:
278
  self._last_activity = time.monotonic()
279
+ # Request counters are observational metrics, not task-liveness
280
+ # authority. They may advance on activity-only callbacks while
281
+ # progress/node/checkpoint ownership remains unchanged.
282
+ if ds_calls is not None:
283
+ self._ds_calls = max(self._ds_calls, int(ds_calls))
284
+ if s2_calls is not None:
285
+ self._s2_calls = max(self._s2_calls, int(s2_calls))
286
+ if oa_calls is not None:
287
+ self._oa_calls = max(self._oa_calls, int(oa_calls))
288
  if not activity_only:
289
  if progress_pct is not None:
290
  self._progress_pct = max(self._progress_pct, max(0, min(100, int(progress_pct))))
 
293
  if checkpoint_seq is not None and int(checkpoint_seq) >= self._checkpoint_seq:
294
  self._checkpoint_seq = int(checkpoint_seq)
295
  self._checkpoint_stage = str(checkpoint_stage or self._checkpoint_stage)[:120]
 
 
 
 
 
 
296
 
297
  def cancelled(self) -> bool:
298
  with self._lock: