R14-RESUME-R1: worker-confirmed checkpoint and local epoch fencing

#56
core/checkpoint_store.py CHANGED
@@ -10,9 +10,20 @@ from __future__ import annotations
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:
@@ -22,50 +33,194 @@ def _atomic_json(path: Path, value: dict[str, Any]) -> None:
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
 
10
  import json
11
  import os
12
  import re
13
+ from contextlib import contextmanager
14
  from datetime import datetime, timezone
15
  from pathlib import Path
16
+ from typing import Any, Iterator
17
+
18
+ try: # Hugging Face production runs on Linux.
19
+ import fcntl # type: ignore
20
+ except ImportError: # pragma: no cover - Windows-only local development fallback.
21
+ fcntl = None
22
+ import msvcrt # type: ignore
23
+
24
+
25
+ CLAIM_HANDSHAKE_FILENAME = "executor_claim.json"
26
+ AUTHORITY_LOCK_FILENAME = ".checkpoint_authority.lock"
27
 
28
 
29
  def _atomic_json(path: Path, value: dict[str, Any]) -> None:
 
33
  os.replace(temporary, path)
34
 
35
 
36
+ @contextmanager
37
+ def checkpoint_authority_lock(task_dir: Path) -> Iterator[None]:
38
+ """Serialize local claim-marker replacement and checkpoint writes.
39
+
40
+ Worker/D1 remains the global authority. This small process lock only closes
41
+ the local race where a superseded executor could otherwise validate an old
42
+ marker immediately before a newer epoch replaces it, then write afterwards.
43
+ """
44
+ lock_path = Path(task_dir) / AUTHORITY_LOCK_FILENAME
45
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
46
+ handle = lock_path.open("a+b")
47
+ try:
48
+ if fcntl is not None:
49
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
50
+ else: # pragma: no cover - Windows-only local development fallback.
51
+ handle.seek(0, os.SEEK_END)
52
+ if handle.tell() == 0:
53
+ handle.write(b"0")
54
+ handle.flush()
55
+ handle.seek(0)
56
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
57
+ yield
58
+ finally:
59
+ try:
60
+ if fcntl is not None:
61
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
62
+ else: # pragma: no cover - Windows-only local development fallback.
63
+ handle.seek(0)
64
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
65
+ finally:
66
+ handle.close()
67
+
68
+
69
  class TaskCheckpointStore:
70
+ def __init__(
71
+ self,
72
+ task_dir: Path,
73
+ request_hash: str,
74
+ execution_epoch: int,
75
+ *,
76
+ task_id: str = "",
77
+ executor_instance_id: str = "",
78
+ confirmed_checkpoint_seq: int | None = None,
79
+ confirmed_checkpoint_stage: str = "",
80
+ ):
81
  self.task_dir = Path(task_dir)
82
  self.request_hash = str(request_hash)
83
  self.execution_epoch = int(execution_epoch)
84
+ self.task_id = str(task_id or "")
85
+ self.executor_instance_id = str(executor_instance_id or "")
86
+ if bool(self.task_id) != bool(self.executor_instance_id):
87
+ raise ValueError("checkpoint_authority_identity_incomplete")
88
+ self.confirmed_checkpoint_seq = (
89
+ None if confirmed_checkpoint_seq is None else max(0, int(confirmed_checkpoint_seq))
90
+ )
91
+ self.confirmed_checkpoint_stage = str(confirmed_checkpoint_stage or "")[:120]
92
  self.directory = self.task_dir / "checkpoints"
93
  self.directory.mkdir(parents=True, exist_ok=True)
94
+ self.sequence = int(self.confirmed_checkpoint_seq or 0)
95
+
96
+ def _read_payload(self, path: Path) -> dict[str, Any] | None:
97
+ try:
98
+ payload = json.loads(path.read_text(encoding="utf-8"))
99
+ except Exception:
100
+ return None
101
+ if not isinstance(payload, dict):
102
+ return None
103
+ if payload.get("request_hash") != self.request_hash:
104
+ return None
105
+ try:
106
+ seq = int(payload.get("checkpoint_seq") or 0)
107
+ writer_epoch = int(payload.get("checkpoint_execution_epoch") or 0)
108
+ except (TypeError, ValueError):
109
+ return None
110
+ if seq <= 0 or writer_epoch <= 0 or writer_epoch > self.execution_epoch:
111
+ return None
112
+ return payload
113
+
114
+ def load_confirmed(
115
+ self,
116
+ checkpoint_seq: int | None = None,
117
+ checkpoint_stage: str | None = None,
118
+ ) -> dict[str, Any] | None:
119
+ """Load only the exact checkpoint sequence acknowledged by Worker/D1.
120
+
121
+ Unacknowledged local snapshots from an older network-partitioned epoch
122
+ are intentionally ignored even when they have a larger sequence number.
123
+ """
124
+ seq = self.confirmed_checkpoint_seq if checkpoint_seq is None else max(0, int(checkpoint_seq))
125
+ stage = self.confirmed_checkpoint_stage if checkpoint_stage is None else str(checkpoint_stage or "")[:120]
126
+ if seq is None:
127
+ raise ValueError("confirmed_checkpoint_seq_required")
128
+ self.sequence = int(seq)
129
+ if seq <= 0:
130
+ return None
131
+
132
+ candidates: list[dict[str, Any]] = []
133
+ for path in self.directory.glob(f"checkpoint_{seq:05d}_*.json"):
134
+ payload = self._read_payload(path)
135
+ if payload is None:
136
+ continue
137
+ if int(payload.get("checkpoint_seq") or 0) != seq:
138
+ continue
139
+ if stage and str(payload.get("checkpoint_stage") or "") != stage:
140
+ continue
141
+ candidates.append(payload)
142
+ if len(candidates) != 1:
143
+ return None
144
+ return candidates[0]
145
 
146
  def load_latest(self) -> dict[str, Any] | None:
147
+ # Authoritative production callers always pin recovery to the exact
148
+ # Worker-confirmed checkpoint. Keep the legacy scan only for isolated
149
+ # compatibility/tests that do not yet supply confirmed metadata.
150
+ if self.confirmed_checkpoint_seq is not None:
151
+ return self.load_confirmed()
152
+
153
  candidates: list[tuple[int, Path, dict[str, Any]]] = []
154
  for path in self.directory.glob("checkpoint_*.json"):
155
+ payload = self._read_payload(path)
156
+ if payload is None:
 
 
 
 
 
 
 
 
 
157
  continue
158
+ seq = int(payload.get("checkpoint_seq") or 0)
159
+ candidates.append((seq, path, payload))
160
  if not candidates:
161
  return None
162
  seq, _, payload = max(candidates, key=lambda item: item[0])
163
  self.sequence = seq
164
  return payload
165
 
166
+ def _assert_local_authority_locked(self) -> None:
167
+ if not self.task_id:
168
+ return
169
+ marker_path = self.task_dir / CLAIM_HANDSHAKE_FILENAME
170
+ try:
171
+ marker = json.loads(marker_path.read_text(encoding="utf-8"))
172
+ except Exception as exc:
173
+ raise RuntimeError("checkpoint_authority_marker_unavailable") from exc
174
+ if not isinstance(marker, dict):
175
+ raise RuntimeError("checkpoint_authority_marker_invalid")
176
+
177
+ expected = {
178
+ "task_id": self.task_id,
179
+ "request_hash": self.request_hash,
180
+ "execution_epoch": self.execution_epoch,
181
+ "executor_instance_id": self.executor_instance_id,
182
+ }
183
+ for key, value in expected.items():
184
+ actual = marker.get(key)
185
+ if key == "execution_epoch":
186
+ try:
187
+ actual = int(actual or 0)
188
+ except (TypeError, ValueError):
189
+ actual = 0
190
+ else:
191
+ actual = str(actual or "")
192
+ if actual != value:
193
+ raise RuntimeError("checkpoint_authority_lost")
194
+
195
+ if self.confirmed_checkpoint_seq is not None:
196
+ try:
197
+ marker_base_seq = int(marker.get("worker_checkpoint_seq") or 0)
198
+ except (TypeError, ValueError):
199
+ marker_base_seq = -1
200
+ marker_base_stage = str(marker.get("worker_checkpoint_stage") or "")[:120]
201
+ if marker_base_seq != int(self.confirmed_checkpoint_seq):
202
+ raise RuntimeError("checkpoint_authority_base_mismatch")
203
+ if self.confirmed_checkpoint_stage and marker_base_stage != self.confirmed_checkpoint_stage:
204
+ raise RuntimeError("checkpoint_authority_base_mismatch")
205
+
206
  def save(self, stage: str, state: dict[str, Any]) -> tuple[int, str]:
 
207
  stage_name = re.sub(r"[^A-Za-z0-9_.-]", "_", str(stage or "checkpoint"))[:80]
208
+ with checkpoint_authority_lock(self.task_dir):
209
+ self._assert_local_authority_locked()
210
+ next_sequence = self.sequence + 1
211
+ payload = dict(state or {})
212
+ payload.update(
213
+ {
214
+ "request_hash": self.request_hash,
215
+ "checkpoint_seq": next_sequence,
216
+ "checkpoint_stage": stage_name,
217
+ "checkpoint_execution_epoch": self.execution_epoch,
218
+ "checkpoint_executor_instance_id": self.executor_instance_id,
219
+ "checkpoint_saved_at": datetime.now(timezone.utc).isoformat(),
220
+ }
221
+ )
222
+ path = self.directory / f"checkpoint_{next_sequence:05d}_{stage_name}.json"
223
+ _atomic_json(path, payload)
224
+ _atomic_json(self.directory / "latest.json", payload)
225
+ self.sequence = next_sequence
226
  return self.sequence, stage_name
core/generation_runner.py CHANGED
@@ -146,12 +146,20 @@ class GenerationRunner:
146
  task_dir: str | os.PathLike,
147
  request_data: dict[str, Any],
148
  execution_epoch: int,
 
 
 
149
  progress_callback: ProgressCallback | None = None,
150
  cancel_checker: Callable[[], bool] | None = None,
151
  ):
152
  self.task_dir = Path(task_dir)
153
  self.request = dict(request_data)
154
  self.execution_epoch = int(execution_epoch)
 
 
 
 
 
155
  self.progress_callback = progress_callback or (lambda **_: None)
156
  self.cancel_checker = cancel_checker or (lambda: False)
157
  expected_hash = str(self.request.get("request_hash") or "")
@@ -159,7 +167,15 @@ class GenerationRunner:
159
  if not expected_hash or expected_hash != actual_hash:
160
  raise RuntimeError("request_hash_mismatch")
161
  self.request_hash = expected_hash
162
- self.checkpoints = TaskCheckpointStore(self.task_dir, self.request_hash, self.execution_epoch)
 
 
 
 
 
 
 
 
163
  self.output_dir = self.task_dir / "outputs"
164
  self.output_dir.mkdir(parents=True, exist_ok=True)
165
  self.agent = None
@@ -1516,7 +1532,13 @@ class GenerationRunner:
1516
  quant_engine = get_quantitative_engine()
1517
  qual_engine = get_qualitative_engine(agent)
1518
 
1519
- state = self.checkpoints.load_latest() or {}
 
 
 
 
 
 
1520
  quality_diagnostics = list(state.get("quality_diagnostics") or [])
1521
  if state:
1522
  self._preview_english_text = str(state.get('full_draft_en') or '')
 
146
  task_dir: str | os.PathLike,
147
  request_data: dict[str, Any],
148
  execution_epoch: int,
149
+ executor_instance_id: str = "",
150
+ confirmed_checkpoint_seq: int | None = None,
151
+ confirmed_checkpoint_stage: str = "",
152
  progress_callback: ProgressCallback | None = None,
153
  cancel_checker: Callable[[], bool] | None = None,
154
  ):
155
  self.task_dir = Path(task_dir)
156
  self.request = dict(request_data)
157
  self.execution_epoch = int(execution_epoch)
158
+ self.executor_instance_id = str(executor_instance_id or "")
159
+ self.confirmed_checkpoint_seq = (
160
+ None if confirmed_checkpoint_seq is None else max(0, int(confirmed_checkpoint_seq))
161
+ )
162
+ self.confirmed_checkpoint_stage = str(confirmed_checkpoint_stage or "")[:120]
163
  self.progress_callback = progress_callback or (lambda **_: None)
164
  self.cancel_checker = cancel_checker or (lambda: False)
165
  expected_hash = str(self.request.get("request_hash") or "")
 
167
  if not expected_hash or expected_hash != actual_hash:
168
  raise RuntimeError("request_hash_mismatch")
169
  self.request_hash = expected_hash
170
+ self.checkpoints = TaskCheckpointStore(
171
+ self.task_dir,
172
+ self.request_hash,
173
+ self.execution_epoch,
174
+ task_id=str(self.request.get("task_id") or "") if self.executor_instance_id else "",
175
+ executor_instance_id=self.executor_instance_id,
176
+ confirmed_checkpoint_seq=self.confirmed_checkpoint_seq,
177
+ confirmed_checkpoint_stage=self.confirmed_checkpoint_stage,
178
+ )
179
  self.output_dir = self.task_dir / "outputs"
180
  self.output_dir.mkdir(parents=True, exist_ok=True)
181
  self.agent = None
 
1532
  quant_engine = get_quantitative_engine()
1533
  qual_engine = get_qualitative_engine(agent)
1534
 
1535
+ if self.confirmed_checkpoint_seq is None:
1536
+ # Compatibility path for isolated unit helpers that do not represent
1537
+ # a claimed production executor. Production always supplies Worker
1538
+ # checkpoint metadata through task_executor.
1539
+ state = self.checkpoints.load_latest() or {}
1540
+ else:
1541
+ state = self.checkpoints.load_confirmed() or {}
1542
  quality_diagnostics = list(state.get("quality_diagnostics") or [])
1543
  if state:
1544
  self._preview_english_text = str(state.get('full_draft_en') or '')
task_executor.py CHANGED
@@ -27,6 +27,7 @@ from urllib.parse import urlsplit
27
  import requests
28
 
29
  from config import APP_CONFIG
 
30
 
31
  _log_handlers = [logging.StreamHandler(sys.stdout)]
32
  _executor_log_path = str(os.environ.get("SMARTPAPER_EXECUTOR_LOG_PATH") or "").strip()
@@ -119,6 +120,10 @@ class ExecutorApiError(RuntimeError):
119
  self.payload = dict(payload or {})
120
 
121
 
 
 
 
 
122
  def _is_recoverable_generation_failure(exc: Exception) -> bool:
123
  """Classify only post-claim execution failures.
124
 
@@ -432,18 +437,21 @@ def _bootstrap_claim(*, task_dir: Path, task_id: str, user_id: int, request_data
432
  if int(task.get("target_words") or 0) != int(request_data.get("target_words") or 0):
433
  raise RuntimeError("target_words_claim_mismatch")
434
  try:
435
- _atomic_json(
436
- _claim_marker_path(task_dir),
437
- {
438
- "task_id": task_id,
439
- "user_id": int(user_id),
440
- "request_hash": request_hash,
441
- "pid": os.getpid(),
442
- "execution_epoch": epoch,
443
- "executor_instance_id": instance_id,
444
- "claimed_at": datetime.now(timezone.utc).isoformat(),
445
- },
446
- )
 
 
 
447
  except Exception as exc:
448
  try:
449
  api.finish(
@@ -503,13 +511,29 @@ def main() -> int:
503
  request_data=request_data,
504
  api=api,
505
  )
 
 
506
  state = ExecutionState(task_id=args.task_id, user_id=args.user_id, epoch=epoch, instance_id=instance_id, api=api)
 
 
 
 
 
 
507
  state.start_heartbeat()
508
  logging.info("[SP_EXECUTOR] claimed task_id=%s epoch=%s instance=%s", args.task_id, epoch, instance_id[:12])
509
 
510
- remote_checkpoint_seq = int(task.get("checkpoint_seq") or 0)
511
- local_checkpoints = list((task_dir / "checkpoints").glob("checkpoint_*.json")) if (task_dir / "checkpoints").is_dir() else []
512
- if remote_checkpoint_seq > 0 and not local_checkpoints:
 
 
 
 
 
 
 
 
513
  try:
514
  api.finish(
515
  {
@@ -519,7 +543,7 @@ def main() -> int:
519
  "executor_instance_id": instance_id,
520
  "status": "failed",
521
  "safe_error_code": "E_CHECKPOINT_MISSING",
522
- "safe_error_detail": "Checkpoint metadata exists but local checkpoint files are unavailable after runtime replacement; quota released.",
523
  "current_node": "checkpoint_missing",
524
  }
525
  )
@@ -537,6 +561,9 @@ def main() -> int:
537
  task_dir=task_dir,
538
  request_data=request_data,
539
  execution_epoch=epoch,
 
 
 
540
  progress_callback=state.touch,
541
  cancel_checker=state.cancelled,
542
  )
@@ -570,6 +597,12 @@ def main() -> int:
570
  else:
571
  logging.warning("[SP_EXECUTOR] stopped_non_authoritative task_id=%s epoch=%s reason=%s", args.task_id, epoch, reason or "unknown")
572
  return 3
 
 
 
 
 
 
573
  if isinstance(exc, (ImportError, ModuleNotFoundError)):
574
  logging.exception("[SP_EXECUTOR] bootstrap_import_failed task_id=%s epoch=%s error=%s", args.task_id, epoch, _safe(exc, 240))
575
  _finish_bootstrap_failure(api, task_id=args.task_id, user_id=args.user_id, epoch=epoch, instance_id=instance_id, exc=exc)
 
27
  import requests
28
 
29
  from config import APP_CONFIG
30
+ from core.checkpoint_store import TaskCheckpointStore, checkpoint_authority_lock
31
 
32
  _log_handlers = [logging.StreamHandler(sys.stdout)]
33
  _executor_log_path = str(os.environ.get("SMARTPAPER_EXECUTOR_LOG_PATH") or "").strip()
 
120
  self.payload = dict(payload or {})
121
 
122
 
123
+ def _is_local_checkpoint_authority_failure(exc: Exception) -> bool:
124
+ return isinstance(exc, RuntimeError) and str(exc or "").startswith("checkpoint_authority_")
125
+
126
+
127
  def _is_recoverable_generation_failure(exc: Exception) -> bool:
128
  """Classify only post-claim execution failures.
129
 
 
437
  if int(task.get("target_words") or 0) != int(request_data.get("target_words") or 0):
438
  raise RuntimeError("target_words_claim_mismatch")
439
  try:
440
+ with checkpoint_authority_lock(task_dir):
441
+ _atomic_json(
442
+ _claim_marker_path(task_dir),
443
+ {
444
+ "task_id": task_id,
445
+ "user_id": int(user_id),
446
+ "request_hash": request_hash,
447
+ "pid": os.getpid(),
448
+ "execution_epoch": epoch,
449
+ "executor_instance_id": instance_id,
450
+ "worker_checkpoint_seq": int(task.get("checkpoint_seq") or 0),
451
+ "worker_checkpoint_stage": str(task.get("checkpoint_stage") or "")[:120],
452
+ "claimed_at": datetime.now(timezone.utc).isoformat(),
453
+ },
454
+ )
455
  except Exception as exc:
456
  try:
457
  api.finish(
 
511
  request_data=request_data,
512
  api=api,
513
  )
514
+ remote_checkpoint_seq = int(task.get("checkpoint_seq") or 0)
515
+ remote_checkpoint_stage = str(task.get("checkpoint_stage") or "")[:120]
516
  state = ExecutionState(task_id=args.task_id, user_id=args.user_id, epoch=epoch, instance_id=instance_id, api=api)
517
+ if remote_checkpoint_seq > 0:
518
+ state.touch(
519
+ checkpoint_seq=remote_checkpoint_seq,
520
+ checkpoint_stage=remote_checkpoint_stage,
521
+ current_node=remote_checkpoint_stage or "executor_claimed",
522
+ )
523
  state.start_heartbeat()
524
  logging.info("[SP_EXECUTOR] claimed task_id=%s epoch=%s instance=%s", args.task_id, epoch, instance_id[:12])
525
 
526
+ confirmed_store = TaskCheckpointStore(
527
+ task_dir,
528
+ str(request_data.get("request_hash") or ""),
529
+ epoch,
530
+ task_id=args.task_id,
531
+ executor_instance_id=instance_id,
532
+ confirmed_checkpoint_seq=remote_checkpoint_seq,
533
+ confirmed_checkpoint_stage=remote_checkpoint_stage,
534
+ )
535
+ confirmed_checkpoint = confirmed_store.load_confirmed()
536
+ if remote_checkpoint_seq > 0 and confirmed_checkpoint is None:
537
  try:
538
  api.finish(
539
  {
 
543
  "executor_instance_id": instance_id,
544
  "status": "failed",
545
  "safe_error_code": "E_CHECKPOINT_MISSING",
546
+ "safe_error_detail": "Worker-confirmed checkpoint metadata does not match available local checkpoint material; quota released.",
547
  "current_node": "checkpoint_missing",
548
  }
549
  )
 
561
  task_dir=task_dir,
562
  request_data=request_data,
563
  execution_epoch=epoch,
564
+ executor_instance_id=instance_id,
565
+ confirmed_checkpoint_seq=remote_checkpoint_seq,
566
+ confirmed_checkpoint_stage=remote_checkpoint_stage,
567
  progress_callback=state.touch,
568
  cancel_checker=state.cancelled,
569
  )
 
597
  else:
598
  logging.warning("[SP_EXECUTOR] stopped_non_authoritative task_id=%s epoch=%s reason=%s", args.task_id, epoch, reason or "unknown")
599
  return 3
600
+ if _is_local_checkpoint_authority_failure(exc):
601
+ logging.warning(
602
+ "[SP_EXECUTOR] stopped_non_authoritative task_id=%s epoch=%s reason=local_checkpoint_authority_lost error=%s",
603
+ args.task_id, epoch, _safe(exc, 180),
604
+ )
605
+ return 3
606
  if isinstance(exc, (ImportError, ModuleNotFoundError)):
607
  logging.exception("[SP_EXECUTOR] bootstrap_import_failed task_id=%s epoch=%s error=%s", args.task_id, epoch, _safe(exc, 240))
608
  _finish_bootstrap_failure(api, task_id=args.task_id, user_id=args.user_id, epoch=epoch, instance_id=instance_id, exc=exc)