Step 4A-6-C-R3.1 Executor Bootstrap and UI State Ownership Correction

#8
.streamlit/desktop.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [.ShellClassInfo]
2
+ IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
app.py CHANGED
@@ -21,6 +21,7 @@ from config import APP_CONFIG
21
  from managed_auth import (
22
  cancel_generation_task,
23
  generation_task_status,
 
24
  require_alipay_login,
25
  render_logged_in_account,
26
  render_payment_purchase,
@@ -265,6 +266,8 @@ for key, default in {
265
  "sp_pending_cancel_task_id": "",
266
  "sp_payment_required": False,
267
  "sp_terminal_notice": None,
 
 
268
  }.items():
269
  if key not in st.session_state:
270
  st.session_state[key] = default
@@ -393,31 +396,54 @@ def _pid_is_task_executor(pid: int, task_id: str, task_dir: Path) -> bool:
393
 
394
 
395
  def _launch_executor(task_id: str, *, force: bool = False) -> int:
 
 
 
 
 
 
 
396
  task_dir = _task_dir(task_id)
397
  request_path = task_dir / "request.json"
398
  if not request_path.is_file():
399
  raise RuntimeError("task_request_manifest_missing")
400
  request_data = json.loads(request_path.read_text(encoding="utf-8"))
401
  numeric_id = int(request_data.get("user_id") or 0)
402
- marker_path = task_dir / "executor_launch.json"
403
- if marker_path.is_file():
 
 
 
 
 
404
  try:
405
- marker = json.loads(marker_path.read_text(encoding="utf-8"))
406
  pid = int(marker.get("pid") or 0)
407
  verified_executor = _pid_is_task_executor(pid, task_id, task_dir)
408
  if verified_executor and not force:
409
- return pid
 
 
 
 
 
 
 
410
  if verified_executor and force:
411
- # Resume means D1 has already made the previous execution
412
- # reclaimable. Stop only a PID whose /proc command line proves
413
- # it is this task's old executor; a new epoch will be the sole
414
- # authority after claim.
415
  try:
416
  os.kill(pid, signal.SIGTERM)
417
  except OSError:
418
  pass
419
  except Exception:
420
  pass
 
 
 
 
 
 
 
 
421
  log_path = task_dir / "executor.log"
422
  task_dir.mkdir(parents=True, exist_ok=True)
423
  command = [
@@ -427,20 +453,54 @@ def _launch_executor(task_id: str, *, force: bool = False) -> int:
427
  "--task-id", str(task_id),
428
  "--user-id", str(numeric_id),
429
  ]
430
- with log_path.open("ab") as log_handle:
431
- process = subprocess.Popen(
432
- command,
433
- cwd=str(Path(__file__).resolve().parent),
434
- stdin=subprocess.DEVNULL,
435
- stdout=log_handle,
436
- stderr=subprocess.STDOUT,
437
- close_fds=True,
438
- start_new_session=True,
439
- env=os.environ.copy(),
440
- )
441
- _atomic_json(marker_path, {"pid": process.pid, "launched_at": datetime.now(timezone.utc).isoformat()})
442
- logging.info("[SP_UI] executor_launched task_id=%s pid=%s", task_id, process.pid)
443
- return int(process.pid)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
 
445
 
446
  def _process_pending_cancel() -> None:
@@ -502,6 +562,7 @@ def _process_pending_launch(lang_code: str) -> None:
502
  st.error("task_start_missing_task_id")
503
  return
504
  st.session_state["sp_generation_task"] = task
 
505
  try:
506
  request_path = _task_dir(task_id) / "request.json"
507
  if not request_path.is_file():
@@ -522,25 +583,19 @@ inject_wechat_browser_notice()
522
  DEFAULT_TOPIC = "Generative Artificial Intelligence in Ubiquitous Learning: Evaluating a Chatbot-based Recommendation Engine for Personalized and Context-aware Education"
523
  DEFAULT_KEYWORDS = "Ubiquitous computing; Context awareness; Deep learning; Personalized educational; Educational technologies; HCI; Recommendation system; LLMs; Generative AI; ChatGPT; Prompting engineering; Few-shot prompting"
524
 
525
- _fragment = getattr(st, "fragment", None) or getattr(st, "experimental_fragment", None)
526
- if _fragment is None:
527
- def _fragment(function=None, **_kwargs):
528
- if function is not None:
529
- return function
530
- return lambda fn: fn
531
-
532
-
533
  def _current_language_code() -> str:
534
  choice = st.session_state.get("interface_language_choice")
535
  return LANGUAGE_OPTIONS.get(choice, "zh")
536
 
537
 
538
  authenticated_user = require_alipay_login(_current_language_code())
 
 
 
539
  _user_workspace = os.path.join(WORKSPACE_DIR, "users", _safe_key(authenticated_user.get("id")))
540
  os.makedirs(_user_workspace, exist_ok=True)
541
 
542
 
543
- @_fragment
544
  def render_workspace_controls(user):
545
  top_toolbar = st.container()
546
  with top_toolbar:
@@ -661,6 +716,7 @@ def _render_task_status_once() -> None:
661
  task_id = str(task.get("task_id") or "").strip()
662
  if not task_id:
663
  return
 
664
  status = str(task.get("status") or "")
665
  if status in {"created", "running", "resumable", "cancelling"}:
666
  try:
@@ -670,6 +726,7 @@ def _render_task_status_once() -> None:
670
  status = str(task.get("status") or "")
671
  except Exception as exc:
672
  logging.warning("[SP_UI] status_poll_error task_id=%s error=%s", task_id, type(exc).__name__)
 
673
  _render_monitor_box(task)
674
  if status == "completed":
675
  output_path = _task_output_path(task)
@@ -685,6 +742,11 @@ def _render_task_status_once() -> None:
685
  if detail:
686
  st.warning(detail)
687
 
 
 
 
 
 
688
 
689
  if getattr(st, "fragment", None) is not None:
690
  @st.fragment(run_every=5)
 
21
  from managed_auth import (
22
  cancel_generation_task,
23
  generation_task_status,
24
+ recover_generation_task,
25
  require_alipay_login,
26
  render_logged_in_account,
27
  render_payment_purchase,
 
266
  "sp_pending_cancel_task_id": "",
267
  "sp_payment_required": False,
268
  "sp_terminal_notice": None,
269
+ "sp_task_recovery_initialized": False,
270
+ "sp_last_ui_task_status": "",
271
  }.items():
272
  if key not in st.session_state:
273
  st.session_state[key] = default
 
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"
408
  if not request_path.is_file():
409
  raise RuntimeError("task_request_manifest_missing")
410
  request_data = json.loads(request_path.read_text(encoding="utf-8"))
411
  numeric_id = int(request_data.get("user_id") or 0)
412
+ request_hash = str(request_data.get("request_hash") or "").strip()
413
+ if numeric_id <= 0 or not request_hash:
414
+ raise RuntimeError("task_request_identity_invalid")
415
+
416
+ launch_marker_path = task_dir / "executor_launch.json"
417
+ claim_marker_path = task_dir / "executor_claim.json"
418
+ if launch_marker_path.is_file():
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:
444
+ if claim_marker_path.exists():
445
+ claim_marker_path.unlink()
446
+
447
  log_path = task_dir / "executor.log"
448
  task_dir.mkdir(parents=True, exist_ok=True)
449
  command = [
 
453
  "--task-id", str(task_id),
454
  "--user-id", str(numeric_id),
455
  ]
456
+ executor_env = os.environ.copy()
457
+ executor_env["SMARTPAPER_EXECUTOR_LOG_PATH"] = str(log_path.resolve())
458
+ process = subprocess.Popen(
459
+ command,
460
+ cwd=str(Path(__file__).resolve().parent),
461
+ stdin=subprocess.DEVNULL,
462
+ # Inherit stdout/stderr so executor bootstrap/errors are visible in HF
463
+ # Container Logs; task_executor also writes the same log to executor.log.
464
+ close_fds=True,
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:
 
562
  st.error("task_start_missing_task_id")
563
  return
564
  st.session_state["sp_generation_task"] = task
565
+ st.session_state["sp_last_ui_task_status"] = str(task.get("status") or "")
566
  try:
567
  request_path = _task_dir(task_id) / "request.json"
568
  if not request_path.is_file():
 
583
  DEFAULT_TOPIC = "Generative Artificial Intelligence in Ubiquitous Learning: Evaluating a Chatbot-based Recommendation Engine for Personalized and Context-aware Education"
584
  DEFAULT_KEYWORDS = "Ubiquitous computing; Context awareness; Deep learning; Personalized educational; Educational technologies; HCI; Recommendation system; LLMs; Generative AI; ChatGPT; Prompting engineering; Few-shot prompting"
585
 
 
 
 
 
 
 
 
 
586
  def _current_language_code() -> str:
587
  choice = st.session_state.get("interface_language_choice")
588
  return LANGUAGE_OPTIONS.get(choice, "zh")
589
 
590
 
591
  authenticated_user = require_alipay_login(_current_language_code())
592
+ if not bool(st.session_state.get("sp_task_recovery_initialized")):
593
+ recover_generation_task()
594
+ st.session_state["sp_task_recovery_initialized"] = True
595
  _user_workspace = os.path.join(WORKSPACE_DIR, "users", _safe_key(authenticated_user.get("id")))
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:
 
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:
 
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)
 
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)
config.py CHANGED
@@ -137,6 +137,14 @@ def load_config():
137
  30,
138
  int(os.environ.get("SMARTPAPER_EXECUTOR_ACTIVITY_GRACE_SECONDS", "45")),
139
  ),
 
 
 
 
 
 
 
 
140
  "manuscript_quality": {
141
  "abstract_max_words": int(os.environ.get("ABSTRACT_MAX_WORDS", "200")),
142
  "abstract_max_numeric_mentions": int(os.environ.get("ABSTRACT_MAX_NUMERIC_MENTIONS", "6")),
 
137
  30,
138
  int(os.environ.get("SMARTPAPER_EXECUTOR_ACTIVITY_GRACE_SECONDS", "45")),
139
  ),
140
+ "executor_bootstrap_timeout_seconds": max(
141
+ 5,
142
+ int(os.environ.get("SMARTPAPER_EXECUTOR_BOOTSTRAP_TIMEOUT_SECONDS", "20")),
143
+ ),
144
+ "executor_bootstrap_poll_ms": max(
145
+ 50,
146
+ int(os.environ.get("SMARTPAPER_EXECUTOR_BOOTSTRAP_POLL_MS", "100")),
147
+ ),
148
  "manuscript_quality": {
149
  "abstract_max_words": int(os.environ.get("ABSTRACT_MAX_WORDS", "200")),
150
  "abstract_max_numeric_mentions": int(os.environ.get("ABSTRACT_MAX_NUMERIC_MENTIONS", "6")),
core/checkpoint_store.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Lightweight task-scoped checkpoint persistence for SmartPaper executors.
3
+
4
+ This module intentionally depends only on the Python standard library. It can
5
+ be used immediately after a Worker claim without importing DeepSeek, RAG,
6
+ statistics, document, or Streamlit modules.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ def _atomic_json(path: Path, value: dict[str, Any]) -> None:
19
+ path.parent.mkdir(parents=True, exist_ok=True)
20
+ temporary = path.with_suffix(path.suffix + ".tmp")
21
+ temporary.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
22
+ os.replace(temporary, path)
23
+
24
+
25
+ class TaskCheckpointStore:
26
+ def __init__(self, task_dir: Path, request_hash: str, execution_epoch: int):
27
+ self.task_dir = Path(task_dir)
28
+ self.request_hash = str(request_hash)
29
+ self.execution_epoch = int(execution_epoch)
30
+ self.directory = self.task_dir / "checkpoints"
31
+ self.directory.mkdir(parents=True, exist_ok=True)
32
+ self.sequence = 0
33
+
34
+ def load_latest(self) -> dict[str, Any] | None:
35
+ candidates: list[tuple[int, Path, dict[str, Any]]] = []
36
+ for path in self.directory.glob("checkpoint_*.json"):
37
+ try:
38
+ payload = json.loads(path.read_text(encoding="utf-8"))
39
+ if not isinstance(payload, dict):
40
+ continue
41
+ if payload.get("request_hash") != self.request_hash:
42
+ continue
43
+ seq = int(payload.get("checkpoint_seq") or 0)
44
+ if seq <= 0:
45
+ continue
46
+ candidates.append((seq, path, payload))
47
+ except Exception:
48
+ continue
49
+ if not candidates:
50
+ return None
51
+ seq, _, payload = max(candidates, key=lambda item: item[0])
52
+ self.sequence = seq
53
+ return payload
54
+
55
+ def save(self, stage: str, state: dict[str, Any]) -> tuple[int, str]:
56
+ self.sequence += 1
57
+ stage_name = re.sub(r"[^A-Za-z0-9_.-]", "_", str(stage or "checkpoint"))[:80]
58
+ payload = dict(state or {})
59
+ payload.update(
60
+ {
61
+ "request_hash": self.request_hash,
62
+ "checkpoint_seq": self.sequence,
63
+ "checkpoint_stage": stage_name,
64
+ "checkpoint_execution_epoch": self.execution_epoch,
65
+ "checkpoint_saved_at": datetime.now(timezone.utc).isoformat(),
66
+ }
67
+ )
68
+ path = self.directory / f"checkpoint_{self.sequence:05d}_{stage_name}.json"
69
+ _atomic_json(path, payload)
70
+ _atomic_json(self.directory / "latest.json", payload)
71
+ return self.sequence, stage_name
core/desktop.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [.ShellClassInfo]
2
+ IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
core/generation_runner.py CHANGED
@@ -18,6 +18,7 @@ from pathlib import Path
18
  from typing import Callable, Any
19
 
20
  from config import APP_CONFIG, AMJ_PROMPTS, ASQ_PROMPTS
 
21
  from core.meta_extractor import extract_golden_metadata
22
  from core.pipeline_agent import SSciPipelineAgent
23
  from core.production_guard import build_validated_package, replace_abstract
@@ -29,13 +30,6 @@ from utils.file_parser import extract_text_from_files
29
  ProgressCallback = Callable[..., None]
30
 
31
 
32
- def _atomic_json(path: Path, value: dict[str, Any]) -> None:
33
- path.parent.mkdir(parents=True, exist_ok=True)
34
- temporary = path.with_suffix(path.suffix + ".tmp")
35
- temporary.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
36
- os.replace(temporary, path)
37
-
38
-
39
  def _clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
40
  lines = str(text or "").split("\n")
41
  for i in range(len(lines) - 1, max(-1, len(lines) - 100), -1):
@@ -55,56 +49,6 @@ def _canonical_request_hash(request_data: dict[str, Any]) -> str:
55
  return hashlib.sha256(encoded).hexdigest()
56
 
57
 
58
- class TaskCheckpointStore:
59
- def __init__(self, task_dir: Path, request_hash: str, execution_epoch: int):
60
- self.task_dir = Path(task_dir)
61
- self.request_hash = str(request_hash)
62
- self.execution_epoch = int(execution_epoch)
63
- self.directory = self.task_dir / "checkpoints"
64
- self.directory.mkdir(parents=True, exist_ok=True)
65
- self.sequence = 0
66
-
67
- def load_latest(self) -> dict[str, Any] | None:
68
- candidates: list[tuple[int, Path, dict[str, Any]]] = []
69
- for path in self.directory.glob("checkpoint_*.json"):
70
- try:
71
- payload = json.loads(path.read_text(encoding="utf-8"))
72
- if not isinstance(payload, dict):
73
- continue
74
- if payload.get("request_hash") != self.request_hash:
75
- continue
76
- seq = int(payload.get("checkpoint_seq") or 0)
77
- if seq <= 0:
78
- continue
79
- candidates.append((seq, path, payload))
80
- except Exception:
81
- continue
82
- if not candidates:
83
- return None
84
- seq, _, payload = max(candidates, key=lambda item: item[0])
85
- self.sequence = seq
86
- return payload
87
-
88
- def save(self, stage: str, state: dict[str, Any]) -> tuple[int, str]:
89
- self.sequence += 1
90
- stage_name = re.sub(r"[^A-Za-z0-9_.-]", "_", str(stage or "checkpoint"))[:80]
91
- payload = dict(state or {})
92
- payload.update(
93
- {
94
- "request_hash": self.request_hash,
95
- "checkpoint_seq": self.sequence,
96
- "checkpoint_stage": stage_name,
97
- "checkpoint_execution_epoch": self.execution_epoch,
98
- "checkpoint_saved_at": datetime.now(timezone.utc).isoformat(),
99
- }
100
- )
101
- path = self.directory / f"checkpoint_{self.sequence:05d}_{stage_name}.json"
102
- _atomic_json(path, payload)
103
- latest = self.directory / "latest.json"
104
- _atomic_json(latest, payload)
105
- return self.sequence, stage_name
106
-
107
-
108
  class GenerationRunner:
109
  def __init__(
110
  self,
 
18
  from typing import Callable, Any
19
 
20
  from config import APP_CONFIG, AMJ_PROMPTS, ASQ_PROMPTS
21
+ from core.checkpoint_store import TaskCheckpointStore
22
  from core.meta_extractor import extract_golden_metadata
23
  from core.pipeline_agent import SSciPipelineAgent
24
  from core.production_guard import build_validated_package, replace_abstract
 
30
  ProgressCallback = Callable[..., None]
31
 
32
 
 
 
 
 
 
 
 
33
  def _clean_trailing_references(text, keywords=("reference", "references", "reference list", "参考文献")):
34
  lines = str(text or "").split("\n")
35
  for i in range(len(lines) - 1, max(-1, len(lines) - 100), -1):
 
49
  return hashlib.sha256(encoded).hexdigest()
50
 
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  class GenerationRunner:
53
  def __init__(
54
  self,
desktop.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [.ShellClassInfo]
2
+ IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
managed_auth.py CHANGED
@@ -29,34 +29,6 @@ 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
- ACTIVE_GENERATION_STATUSES = frozenset({"created", "running", "resumable", "cancelling"})
33
- KNOWN_GENERATION_STATUSES = ACTIVE_GENERATION_STATUSES | frozenset({"completed", "failed", "cancelled"})
34
-
35
-
36
- def _select_generation_task_for_session(
37
- active_task: Any, latest_task: Any, tracked_task_id: str
38
- ) -> dict[str, Any] | None:
39
- """Choose only a genuinely current task for the Streamlit session.
40
-
41
- ``latest_task`` is historical data. It may update the terminal state of a
42
- task that this Streamlit session already tracked, but it must never create a
43
- current-task UI state on a fresh login/session by itself.
44
- """
45
- if isinstance(active_task, dict) and active_task.get("task_id"):
46
- status = str(active_task.get("status") or "").strip().lower()
47
- if status in ACTIVE_GENERATION_STATUSES:
48
- return dict(active_task)
49
-
50
- tracked = str(tracked_task_id or "").strip()
51
- if not tracked or not isinstance(latest_task, dict):
52
- return None
53
- latest_id = str(latest_task.get("task_id") or "").strip()
54
- latest_status = str(latest_task.get("status") or "").strip().lower()
55
- if latest_id == tracked and latest_status in KNOWN_GENERATION_STATUSES:
56
- return dict(latest_task)
57
- return None
58
-
59
-
60
  def _runtime_value(name: str) -> str:
61
  value = os.environ.get(name, "")
62
  if not value:
@@ -158,6 +130,7 @@ def _verify_edge_assertion(token: str) -> dict[str, Any]:
158
  def _clear_generation_session_state() -> None:
159
  for key in (
160
  "sp_generation_task", "sp_tracked_task_id", "sp_current_node",
 
161
  "sp_generation_idempotency_key", "zip_buffer", "zip_filename",
162
  "is_running", "start_pipeline", "resume_pipeline", "run_mode",
163
  "rag_engine", "up_amj_ref", "up_amj_data", "up_asq_ref",
@@ -267,16 +240,9 @@ def get_account_snapshot(*, block: bool = False) -> dict[str, Any] | None:
267
  user = result.get("user")
268
  if isinstance(user, dict):
269
  st.session_state["sp_user"] = dict(user)
270
- task = result.get("task")
271
- latest_task = result.get("latest_task")
272
- selected_task = _select_generation_task_for_session(
273
- task, latest_task, str(st.session_state.get("sp_tracked_task_id") or "")
274
- )
275
- if selected_task is not None:
276
- st.session_state["sp_generation_task"] = selected_task
277
- st.session_state["sp_tracked_task_id"] = str(selected_task.get("task_id") or "")
278
- else:
279
- st.session_state["sp_generation_task"] = None
280
  return result
281
 
282
 
@@ -307,12 +273,28 @@ def start_generation_task(*, idempotency_key: str, mode: str, interface_language
307
 
308
 
309
  def recover_generation_task() -> dict[str, Any]:
 
 
 
 
 
 
310
  command_id = f"recover:{int(time.time() // 10)}"
311
  result = _edge_call("task_recover", command_id, {}, key_prefix="sp_task_recover")
312
  if result is None:
313
  st.stop()
314
  if not result.get("ok"):
315
  _raise_edge_error(result, "task_recover_failed")
 
 
 
 
 
 
 
 
 
 
316
  return dict(result)
317
 
318
 
 
29
  EDGE_ASSERTION_COOKIE_NAME = "smartpaper_edge_assertion"
30
  EDGE_ASSERTION_AUDIENCE = "smartpaper-streamlit"
31
  ASSERTION_CLOCK_SKEW_SECONDS = 30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  def _runtime_value(name: str) -> str:
33
  value = os.environ.get(name, "")
34
  if not value:
 
130
  def _clear_generation_session_state() -> None:
131
  for key in (
132
  "sp_generation_task", "sp_tracked_task_id", "sp_current_node",
133
+ "sp_task_recovery_initialized", "sp_last_ui_task_status",
134
  "sp_generation_idempotency_key", "zip_buffer", "zip_filename",
135
  "is_running", "start_pipeline", "resume_pipeline", "run_mode",
136
  "rag_engine", "up_amj_ref", "up_amj_data", "up_asq_ref",
 
240
  user = result.get("user")
241
  if isinstance(user, dict):
242
  st.session_state["sp_user"] = dict(user)
243
+ # R3.1 single-writer rule: account refresh owns identity/quota only.
244
+ # Task UI state is initialized by recover_generation_task() and thereafter
245
+ # updated only by generation_task_status()/explicit task actions.
 
 
 
 
 
 
 
246
  return result
247
 
248
 
 
273
 
274
 
275
  def recover_generation_task() -> dict[str, Any]:
276
+ """Initialize current task state once from the authoritative Worker.
277
+
278
+ Account polling is intentionally forbidden from writing task state. This
279
+ recovery call is the only fresh-session initializer; later changes come
280
+ from generation_task_status() or explicit start/cancel operations.
281
+ """
282
  command_id = f"recover:{int(time.time() // 10)}"
283
  result = _edge_call("task_recover", command_id, {}, key_prefix="sp_task_recover")
284
  if result is None:
285
  st.stop()
286
  if not result.get("ok"):
287
  _raise_edge_error(result, "task_recover_failed")
288
+ _merge_quota_into_session(result)
289
+ task = result.get("task")
290
+ if isinstance(task, dict) and task.get("task_id"):
291
+ st.session_state["sp_generation_task"] = dict(task)
292
+ st.session_state["sp_tracked_task_id"] = str(task["task_id"])
293
+ st.session_state["sp_last_ui_task_status"] = str(task.get("status") or "")
294
+ else:
295
+ st.session_state["sp_generation_task"] = None
296
+ st.session_state["sp_last_ui_task_status"] = ""
297
+ st.session_state.pop("sp_tracked_task_id", None)
298
  return dict(result)
299
 
300
 
smartpaper_restore_bridge/desktop.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [.ShellClassInfo]
2
+ IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
smartpaper_restore_bridge/frontend/desktop.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [.ShellClassInfo]
2
+ IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27
task_executor.py CHANGED
@@ -1,10 +1,12 @@
1
  # -*- coding: utf-8 -*-
2
- """SmartPaper R3-Free independent task executor.
3
-
4
- Launched by Streamlit as a detached subprocess. It claims a D1 task with a
5
- server-to-server service key, runs the headless generation pipeline, renews the
6
- lease only while real execution activity is recent, and atomically asks the
7
- Worker to commit/release quota at terminal completion.
 
 
8
  """
9
  from __future__ import annotations
10
 
@@ -16,24 +18,32 @@ import re
16
  import sys
17
  import threading
18
  import time
 
19
  from pathlib import Path
20
  from urllib.parse import urlsplit
21
 
22
  import requests
23
 
24
  from config import APP_CONFIG
25
- from core.generation_runner import GenerationRunner
26
- from core.pipeline_agent import (
27
- DeepSeekAPIError, DeepSeekAuthenticationError, DeepSeekBalanceError, DeepSeekRequestError,
28
- )
29
 
 
 
 
 
 
 
 
 
30
  logging.basicConfig(
31
  level=logging.INFO,
32
  format="%(asctime)s [%(levelname)s] %(message)s",
33
- handlers=[logging.StreamHandler(sys.stdout)],
34
  )
35
 
36
 
 
 
 
37
  def _safe(value, limit=500):
38
  text = str(value or "")
39
  text = re.sub(r"(?i)(api[_ -]?key|authorization|bearer|token)\s*[:=]\s*[^\s,;]+", r"\1=[redacted]", text)
@@ -55,6 +65,17 @@ def _service_key() -> str:
55
  return value
56
 
57
 
 
 
 
 
 
 
 
 
 
 
 
58
  class ExecutorApiError(RuntimeError):
59
  def __init__(self, error: str, status_code: int = 0, payload: dict | None = None):
60
  super().__init__(str(error or "executor_api_error"))
@@ -64,19 +85,20 @@ class ExecutorApiError(RuntimeError):
64
 
65
 
66
  def _is_recoverable_generation_failure(exc: Exception) -> bool:
67
- """Return True only for transient execution failures that can use checkpoints.
68
 
69
- Authentication/balance/request-validation failures are terminal and release
70
- quota immediately. Transport/stream failures can be resumed from the last
71
- durable task checkpoint without consuming a second quota unit.
72
  """
73
  if isinstance(exc, ExecutorApiError):
74
  return exc.status_code == 429 or exc.status_code >= 500
75
- if isinstance(exc, (DeepSeekAuthenticationError, DeepSeekBalanceError, DeepSeekRequestError)):
 
76
  return False
77
  if isinstance(exc, (requests.exceptions.Timeout, requests.exceptions.ConnectionError)):
78
  return True
79
- if isinstance(exc, DeepSeekAPIError):
80
  return True
81
  text = str(exc or "").lower()
82
  transient_markers = (
@@ -94,7 +116,7 @@ class ExecutorApi:
94
  "Authorization": f"Bearer {_service_key()}",
95
  "Accept": "application/json",
96
  "Content-Type": "application/json",
97
- "User-Agent": "SmartPaper-R3-Free-Executor/1.0",
98
  }
99
 
100
  def post(self, path: str, payload: dict, *, timeout=20) -> dict:
@@ -204,12 +226,11 @@ class ExecutionState:
204
  def _heartbeat_loop(self):
205
  interval = int(APP_CONFIG.get("executor_heartbeat_interval_seconds", 15))
206
  grace = int(APP_CONFIG.get("executor_activity_grace_seconds", 45))
207
- # Claim itself already set the first heartbeat; delay before the next one.
208
  while not self._stop.wait(interval):
209
  payload, idle_seconds = self._payload()
210
  if idle_seconds > grace:
211
  logging.warning(
212
- "[SP_EXECUTOR] heartbeat paused task_id=%s idle_s=%.1f node=%s",
213
  self.task_id, idle_seconds, payload.get("current_node"),
214
  )
215
  continue
@@ -231,17 +252,9 @@ class ExecutionState:
231
  self.task_id, _safe(exc.error, 180),
232
  )
233
  return
234
- logging.warning(
235
- "[SP_EXECUTOR] heartbeat_error task_id=%s error=%s",
236
- self.task_id, _safe(exc, 180),
237
- )
238
  except Exception as exc:
239
- # A transport outage alone does not authorize another local
240
- # writer. D1 lease/epoch ownership remains authoritative.
241
- logging.warning(
242
- "[SP_EXECUTOR] heartbeat_error task_id=%s error=%s",
243
- self.task_id, _safe(exc, 180),
244
- )
245
 
246
 
247
  def _load_request(task_dir: Path) -> dict:
@@ -254,6 +267,70 @@ def _load_request(task_dir: Path) -> dict:
254
  return value
255
 
256
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  def main() -> int:
258
  parser = argparse.ArgumentParser()
259
  parser.add_argument("--task-dir", required=True)
@@ -267,36 +344,19 @@ def main() -> int:
267
  raise RuntimeError("request_task_id_mismatch")
268
  if int(request_data.get("user_id") or 0) != int(args.user_id):
269
  raise RuntimeError("request_user_id_mismatch")
270
- request_hash = str(request_data.get("request_hash") or "").strip()
271
- if not request_hash:
272
- raise RuntimeError("request_hash_missing")
273
 
274
  api = ExecutorApi()
275
- claim = api.claim(task_id=args.task_id, user_id=args.user_id, request_hash=request_hash)
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
- instance_id = str(execution.get("executor_instance_id") or "")
280
- if epoch <= 0 or not instance_id:
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
-
285
- state = ExecutionState(
286
  task_id=args.task_id,
287
  user_id=args.user_id,
288
- epoch=epoch,
289
- instance_id=instance_id,
290
  api=api,
291
  )
 
292
  state.start_heartbeat()
293
- logging.info(
294
- "[SP_EXECUTOR] claimed task_id=%s epoch=%s instance=%s",
295
- args.task_id, epoch, instance_id[:12],
296
- )
297
 
298
- # If D1 says a prior checkpoint exists but the free Space's local disk no
299
- # longer has it (container rebuild), do not pretend resume is possible.
300
  remote_checkpoint_seq = int(task.get("checkpoint_seq") or 0)
301
  local_checkpoints = list((task_dir / "checkpoints").glob("checkpoint_*.json")) if (task_dir / "checkpoints").is_dir() else []
302
  if remote_checkpoint_seq > 0 and not local_checkpoints:
@@ -318,6 +378,11 @@ def main() -> int:
318
  return 2
319
 
320
  try:
 
 
 
 
 
321
  runner = GenerationRunner(
322
  task_dir=task_dir,
323
  request_data=request_data,
@@ -327,8 +392,6 @@ def main() -> int:
327
  )
328
  result = runner.run()
329
  state.touch(progress_pct=100, current_node="package_validated")
330
- # One last accepted heartbeat publishes the final checkpoint metadata
331
- # before the atomic terminal transaction.
332
  try:
333
  state.heartbeat_once()
334
  except Exception as exc:
@@ -350,19 +413,14 @@ def main() -> int:
350
  return 0
351
  except Exception as exc:
352
  if state.cancelled():
353
- logging.warning(
354
- "[SP_EXECUTOR] stopped_non_authoritative task_id=%s epoch=%s",
355
- args.task_id, epoch,
356
- )
357
  return 3
 
 
 
 
358
  if _is_recoverable_generation_failure(exc):
359
- # Do not terminally fail or release quota. Stopping the real-execution
360
- # heartbeat lets the Worker move this epoch to `resumable`; the next
361
- # epoch continues from the latest immutable local checkpoint.
362
- logging.exception(
363
- "[SP_EXECUTOR] recoverable_interruption task_id=%s epoch=%s error=%s",
364
- args.task_id, epoch, _safe(exc, 240),
365
- )
366
  return 75
367
  logging.exception("[SP_EXECUTOR] failed task_id=%s epoch=%s error=%s", args.task_id, epoch, _safe(exc, 240))
368
  try:
@@ -379,8 +437,6 @@ def main() -> int:
379
  }
380
  )
381
  except Exception as finish_exc:
382
- # A newer epoch or user cancellation may already own/finalize the
383
- # task. Never override it from a stale executor.
384
  logging.warning("[SP_EXECUTOR] failure_finalize_rejected task_id=%s error=%s", args.task_id, _safe(finish_exc, 220))
385
  return 1
386
  finally:
 
1
  # -*- coding: utf-8 -*-
2
+ """SmartPaper R3.1 independent task executor.
3
+
4
+ Bootstrap is intentionally lightweight: the child process validates the local
5
+ request, claims the D1 task, writes a local claim handshake, and only then
6
+ imports the heavy paper-generation modules. A Streamlit rerun/browser event
7
+ therefore cannot be mistaken for successful executor startup, and import or
8
+ pipeline failures after claim can be finalized through the authoritative
9
+ Worker so quota is never left reserved by an unowned task.
10
  """
11
  from __future__ import annotations
12
 
 
18
  import sys
19
  import threading
20
  import time
21
+ from datetime import datetime, timezone
22
  from pathlib import Path
23
  from urllib.parse import urlsplit
24
 
25
  import requests
26
 
27
  from config import APP_CONFIG
 
 
 
 
28
 
29
+ _log_handlers = [logging.StreamHandler(sys.stdout)]
30
+ _executor_log_path = str(os.environ.get("SMARTPAPER_EXECUTOR_LOG_PATH") or "").strip()
31
+ if _executor_log_path:
32
+ try:
33
+ Path(_executor_log_path).parent.mkdir(parents=True, exist_ok=True)
34
+ _log_handlers.append(logging.FileHandler(_executor_log_path, encoding="utf-8"))
35
+ except Exception:
36
+ pass
37
  logging.basicConfig(
38
  level=logging.INFO,
39
  format="%(asctime)s [%(levelname)s] %(message)s",
40
+ handlers=_log_handlers,
41
  )
42
 
43
 
44
+ CLAIM_HANDSHAKE_FILENAME = "executor_claim.json"
45
+
46
+
47
  def _safe(value, limit=500):
48
  text = str(value or "")
49
  text = re.sub(r"(?i)(api[_ -]?key|authorization|bearer|token)\s*[:=]\s*[^\s,;]+", r"\1=[redacted]", text)
 
65
  return value
66
 
67
 
68
+ def _atomic_json(path: Path, value: dict) -> None:
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ temporary = path.with_suffix(path.suffix + ".tmp")
71
+ temporary.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
72
+ os.replace(temporary, path)
73
+
74
+
75
+ def _claim_marker_path(task_dir: Path) -> Path:
76
+ return task_dir / CLAIM_HANDSHAKE_FILENAME
77
+
78
+
79
  class ExecutorApiError(RuntimeError):
80
  def __init__(self, error: str, status_code: int = 0, payload: dict | None = None):
81
  super().__init__(str(error or "executor_api_error"))
 
85
 
86
 
87
  def _is_recoverable_generation_failure(exc: Exception) -> bool:
88
+ """Classify only post-claim execution failures.
89
 
90
+ This deliberately avoids importing the generation pipeline at module import
91
+ time. DeepSeek exception class names are stable public names inside this
92
+ project and are inspected only after claim/bootstrap has succeeded.
93
  """
94
  if isinstance(exc, ExecutorApiError):
95
  return exc.status_code == 429 or exc.status_code >= 500
96
+ class_name = exc.__class__.__name__
97
+ if class_name in {"DeepSeekAuthenticationError", "DeepSeekBalanceError", "DeepSeekRequestError"}:
98
  return False
99
  if isinstance(exc, (requests.exceptions.Timeout, requests.exceptions.ConnectionError)):
100
  return True
101
+ if class_name == "DeepSeekAPIError":
102
  return True
103
  text = str(exc or "").lower()
104
  transient_markers = (
 
116
  "Authorization": f"Bearer {_service_key()}",
117
  "Accept": "application/json",
118
  "Content-Type": "application/json",
119
+ "User-Agent": "SmartPaper-R3.1-Executor/1.0",
120
  }
121
 
122
  def post(self, path: str, payload: dict, *, timeout=20) -> dict:
 
226
  def _heartbeat_loop(self):
227
  interval = int(APP_CONFIG.get("executor_heartbeat_interval_seconds", 15))
228
  grace = int(APP_CONFIG.get("executor_activity_grace_seconds", 45))
 
229
  while not self._stop.wait(interval):
230
  payload, idle_seconds = self._payload()
231
  if idle_seconds > grace:
232
  logging.warning(
233
+ "[SP_EXECUTOR] heartbeat_paused task_id=%s idle_s=%.1f node=%s",
234
  self.task_id, idle_seconds, payload.get("current_node"),
235
  )
236
  continue
 
252
  self.task_id, _safe(exc.error, 180),
253
  )
254
  return
255
+ logging.warning("[SP_EXECUTOR] heartbeat_error task_id=%s error=%s", self.task_id, _safe(exc, 180))
 
 
 
256
  except Exception as exc:
257
+ logging.warning("[SP_EXECUTOR] heartbeat_error task_id=%s error=%s", self.task_id, _safe(exc, 180))
 
 
 
 
 
258
 
259
 
260
  def _load_request(task_dir: Path) -> dict:
 
267
  return value
268
 
269
 
270
+ def _bootstrap_claim(*, task_dir: Path, task_id: str, user_id: int, request_data: dict, api: ExecutorApi) -> tuple[dict, int, str]:
271
+ """Claim first and publish a PID-bound local handshake before heavy imports."""
272
+ request_hash = str(request_data.get("request_hash") or "").strip()
273
+ if not request_hash:
274
+ raise RuntimeError("request_hash_missing")
275
+ claim = api.claim(task_id=task_id, user_id=user_id, request_hash=request_hash)
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
+ instance_id = str(execution.get("executor_instance_id") or "")
280
+ if epoch <= 0 or not instance_id:
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:
285
+ _atomic_json(
286
+ _claim_marker_path(task_dir),
287
+ {
288
+ "task_id": task_id,
289
+ "user_id": int(user_id),
290
+ "request_hash": request_hash,
291
+ "pid": os.getpid(),
292
+ "execution_epoch": epoch,
293
+ "executor_instance_id": instance_id,
294
+ "claimed_at": datetime.now(timezone.utc).isoformat(),
295
+ },
296
+ )
297
+ except Exception as exc:
298
+ try:
299
+ api.finish(
300
+ {
301
+ "task_id": task_id,
302
+ "user_id": int(user_id),
303
+ "execution_epoch": epoch,
304
+ "executor_instance_id": instance_id,
305
+ "status": "failed",
306
+ "safe_error_code": "E_EXECUTOR_HANDSHAKE_FAILED",
307
+ "safe_error_detail": _safe(exc, 800),
308
+ "current_node": "executor_handshake_failed",
309
+ }
310
+ )
311
+ finally:
312
+ raise
313
+ return task, epoch, instance_id
314
+
315
+
316
+ def _finish_bootstrap_failure(api: ExecutorApi, *, task_id: str, user_id: int, epoch: int, instance_id: str, exc: Exception) -> None:
317
+ try:
318
+ api.finish(
319
+ {
320
+ "task_id": task_id,
321
+ "user_id": user_id,
322
+ "execution_epoch": epoch,
323
+ "executor_instance_id": instance_id,
324
+ "status": "failed",
325
+ "safe_error_code": "E_EXECUTOR_BOOTSTRAP_FAILED",
326
+ "safe_error_detail": _safe(exc, 800),
327
+ "current_node": "executor_bootstrap_failed",
328
+ }
329
+ )
330
+ except Exception as finish_exc:
331
+ logging.warning("[SP_EXECUTOR] bootstrap_finalize_rejected task_id=%s error=%s", task_id, _safe(finish_exc, 220))
332
+
333
+
334
  def main() -> int:
335
  parser = argparse.ArgumentParser()
336
  parser.add_argument("--task-dir", required=True)
 
344
  raise RuntimeError("request_task_id_mismatch")
345
  if int(request_data.get("user_id") or 0) != int(args.user_id):
346
  raise RuntimeError("request_user_id_mismatch")
 
 
 
347
 
348
  api = ExecutorApi()
349
+ task, epoch, instance_id = _bootstrap_claim(
350
+ task_dir=task_dir,
 
 
 
 
 
 
 
 
 
351
  task_id=args.task_id,
352
  user_id=args.user_id,
353
+ request_data=request_data,
 
354
  api=api,
355
  )
356
+ state = ExecutionState(task_id=args.task_id, user_id=args.user_id, epoch=epoch, instance_id=instance_id, api=api)
357
  state.start_heartbeat()
358
+ logging.info("[SP_EXECUTOR] claimed task_id=%s epoch=%s instance=%s", args.task_id, epoch, instance_id[:12])
 
 
 
359
 
 
 
360
  remote_checkpoint_seq = int(task.get("checkpoint_seq") or 0)
361
  local_checkpoints = list((task_dir / "checkpoints").glob("checkpoint_*.json")) if (task_dir / "checkpoints").is_dir() else []
362
  if remote_checkpoint_seq > 0 and not local_checkpoints:
 
378
  return 2
379
 
380
  try:
381
+ # Heavy paper modules are deliberately imported only after D1 claim and
382
+ # the local claim handshake. Any import/init failure is now observable
383
+ # and can atomically release the reserved quota.
384
+ from core.generation_runner import GenerationRunner
385
+
386
  runner = GenerationRunner(
387
  task_dir=task_dir,
388
  request_data=request_data,
 
392
  )
393
  result = runner.run()
394
  state.touch(progress_pct=100, current_node="package_validated")
 
 
395
  try:
396
  state.heartbeat_once()
397
  except Exception as exc:
 
413
  return 0
414
  except Exception as exc:
415
  if state.cancelled():
416
+ logging.warning("[SP_EXECUTOR] stopped_non_authoritative task_id=%s epoch=%s", args.task_id, epoch)
 
 
 
417
  return 3
418
+ if isinstance(exc, (ImportError, ModuleNotFoundError)):
419
+ logging.exception("[SP_EXECUTOR] bootstrap_import_failed task_id=%s epoch=%s error=%s", args.task_id, epoch, _safe(exc, 240))
420
+ _finish_bootstrap_failure(api, task_id=args.task_id, user_id=args.user_id, epoch=epoch, instance_id=instance_id, exc=exc)
421
+ return 4
422
  if _is_recoverable_generation_failure(exc):
423
+ logging.exception("[SP_EXECUTOR] recoverable_interruption task_id=%s epoch=%s error=%s", args.task_id, epoch, _safe(exc, 240))
 
 
 
 
 
 
424
  return 75
425
  logging.exception("[SP_EXECUTOR] failed task_id=%s epoch=%s error=%s", args.task_id, epoch, _safe(exc, 240))
426
  try:
 
437
  }
438
  )
439
  except Exception as finish_exc:
 
 
440
  logging.warning("[SP_EXECUTOR] failure_finalize_rejected task_id=%s error=%s", args.task_id, _safe(finish_exc, 220))
441
  return 1
442
  finally:
utils/desktop.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [.ShellClassInfo]
2
+ IconResource=C:\Program Files\Google\Drive File Stream\129.0.1.0\GoogleDriveFS.exe,27