A5-R3-R5J-R4-R2 dormant dual-tier runtime

#115
Files changed (1) hide show
  1. huggingface/task_executor.py +766 -0
huggingface/task_executor.py ADDED
@@ -0,0 +1,766 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """SmartPaper R3.2.2 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
+
13
+ import argparse
14
+ import hashlib
15
+ import json
16
+ import logging
17
+ import os
18
+ import re
19
+ import secrets
20
+ import shutil
21
+ import sys
22
+ import threading
23
+ import time
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from urllib.parse import urlsplit
27
+
28
+ import requests
29
+
30
+ from config import APP_CONFIG
31
+ from core.checkpoint_store import TaskCheckpointStore, checkpoint_authority_lock
32
+
33
+ _log_handlers = [logging.StreamHandler(sys.stdout)]
34
+ _executor_log_path = str(os.environ.get("SMARTPAPER_EXECUTOR_LOG_PATH") or "").strip()
35
+ if _executor_log_path:
36
+ try:
37
+ Path(_executor_log_path).parent.mkdir(parents=True, exist_ok=True)
38
+ _log_handlers.append(logging.FileHandler(_executor_log_path, encoding="utf-8"))
39
+ except Exception:
40
+ pass
41
+ logging.basicConfig(
42
+ level=logging.INFO,
43
+ format="%(asctime)s [%(levelname)s] %(message)s",
44
+ handlers=_log_handlers,
45
+ )
46
+
47
+
48
+ CLAIM_HANDSHAKE_FILENAME = "executor_claim.json"
49
+ EXECUTOR_API_DEFAULT = "https://smartpaper-auth-relay-pages.pages.dev"
50
+
51
+
52
+ def _bounded_env_float(name: str, default: float, minimum: float, maximum: float) -> float:
53
+ try:
54
+ value = float(os.environ.get(name, str(default)))
55
+ except (TypeError, ValueError):
56
+ value = float(default)
57
+ return max(float(minimum), min(float(maximum), value))
58
+
59
+
60
+ # The current Worker startup lease is 90 seconds. R3.2 deliberately keeps the
61
+ # default executor claim window below that lease while allowing materially
62
+ # slower HF->Cloudflare TLS handshakes than R3.1.1's 4-second timeout.
63
+ EXECUTOR_CLAIM_WINDOW_SECONDS = _bounded_env_float(
64
+ "SMARTPAPER_EXECUTOR_CLAIM_WINDOW_SECONDS", 70.0, 20.0, 80.0
65
+ )
66
+ EXECUTOR_CLAIM_REQUEST_TIMEOUT_SECONDS = _bounded_env_float(
67
+ "SMARTPAPER_EXECUTOR_CLAIM_REQUEST_TIMEOUT_SECONDS", 18.0, 5.0, 30.0
68
+ )
69
+ EXECUTOR_CLAIM_BACKOFF_INITIAL_SECONDS = _bounded_env_float(
70
+ "SMARTPAPER_EXECUTOR_CLAIM_BACKOFF_INITIAL_SECONDS", 1.0, 0.2, 5.0
71
+ )
72
+ EXECUTOR_CLAIM_BACKOFF_MAX_SECONDS = _bounded_env_float(
73
+ "SMARTPAPER_EXECUTOR_CLAIM_BACKOFF_MAX_SECONDS", 4.0, 1.0, 10.0
74
+ )
75
+
76
+
77
+ def _new_executor_instance_id() -> str:
78
+ """Create one stable identity for this executor claim attempt sequence."""
79
+ return f"exec_{secrets.token_urlsafe(18)}"
80
+
81
+
82
+ def _safe(value, limit=500):
83
+ text = str(value or "")
84
+ text = re.sub(r"(?i)(api[_ -]?key|authorization|bearer|token)\s*[:=]\s*[^\s,;]+", r"\1=[redacted]", text)
85
+ return text[:limit]
86
+
87
+
88
+ def _api_origin() -> str:
89
+ # Executor traffic intentionally uses the Pages Service-Binding relay.
90
+ # Browser/auth traffic continues to use SMARTPAPER_AUTH_API elsewhere.
91
+ value = str(os.environ.get("SMARTPAPER_EXECUTOR_API") or EXECUTOR_API_DEFAULT).strip().rstrip("/")
92
+ parsed = urlsplit(value)
93
+ if parsed.scheme != "https" or not parsed.netloc or parsed.path not in ("", "/") or parsed.query or parsed.fragment:
94
+ raise RuntimeError("SMARTPAPER_EXECUTOR_API_invalid")
95
+ return f"https://{parsed.netloc}"
96
+
97
+
98
+ def _service_key() -> str:
99
+ value = str(os.environ.get("SMARTPAPER_SERVICE_KEY") or "").strip()
100
+ if not value:
101
+ raise RuntimeError("SMARTPAPER_SERVICE_KEY_missing")
102
+ return value
103
+
104
+
105
+ def _atomic_json(path: Path, value: dict) -> None:
106
+ path.parent.mkdir(parents=True, exist_ok=True)
107
+ temporary = path.with_suffix(path.suffix + ".tmp")
108
+ temporary.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
109
+ os.replace(temporary, path)
110
+
111
+
112
+ def _claim_marker_path(task_dir: Path) -> Path:
113
+ return task_dir / CLAIM_HANDSHAKE_FILENAME
114
+
115
+
116
+ class ExecutorApiError(RuntimeError):
117
+ def __init__(self, error: str, status_code: int = 0, payload: dict | None = None):
118
+ super().__init__(str(error or "executor_api_error"))
119
+ self.error = str(error or "executor_api_error")
120
+ self.status_code = int(status_code or 0)
121
+ self.payload = dict(payload or {})
122
+
123
+
124
+ def _is_local_checkpoint_authority_failure(exc: Exception) -> bool:
125
+ return isinstance(exc, RuntimeError) and str(exc or "").startswith("checkpoint_authority_")
126
+
127
+
128
+ def _is_recoverable_generation_failure(exc: Exception) -> bool:
129
+ """Classify only post-claim execution failures.
130
+
131
+ This deliberately avoids importing the generation pipeline at module import
132
+ time. Provider/model exception class names are inspected only after
133
+ claim/bootstrap has succeeded, so future provider adapters do not require
134
+ executor imports or vendor-specific branching.
135
+ """
136
+ if isinstance(exc, ExecutorApiError):
137
+ return exc.status_code == 429 or exc.status_code >= 500
138
+ class_name = exc.__class__.__name__
139
+ if class_name in {
140
+ "DeepSeekAuthenticationError", "DeepSeekBalanceError", "DeepSeekRequestError",
141
+ "ModelAuthenticationError", "ModelBalanceError", "ModelRequestError",
142
+ "ModelProviderUnavailableError", "ModelGatewayError",
143
+ }:
144
+ return False
145
+ if isinstance(exc, (requests.exceptions.Timeout, requests.exceptions.ConnectionError)):
146
+ return True
147
+ if class_name == "DeepSeekAPIError":
148
+ return True
149
+ text = str(exc or "").lower()
150
+ transient_markers = (
151
+ "connection", "timed out", "timeout", "temporarily unavailable",
152
+ "streaming failed after", "api failed after", "stream was interrupted",
153
+ "insufficient eligible references",
154
+ )
155
+ return any(marker in text for marker in transient_markers)
156
+
157
+
158
+ class ExecutorApi:
159
+ def __init__(self):
160
+ self.origin = _api_origin()
161
+ self.headers = {
162
+ "Authorization": f"Bearer {_service_key()}",
163
+ "Accept": "application/json",
164
+ "Content-Type": "application/json",
165
+ "User-Agent": "SmartPaper-R3.2.2-Executor/1.0",
166
+ }
167
+ # Keep direct networking first for the relay endpoint, with the ordinary
168
+ # environment-aware session only as a transport fallback. Business/HTTP
169
+ # errors never trigger fallback.
170
+ self._direct_session = requests.Session()
171
+ self._direct_session.trust_env = False
172
+ self._environment_session = requests.Session()
173
+
174
+ def post(self, path: str, payload: dict, *, timeout=20) -> dict:
175
+ url = self.origin + path
176
+ response = None
177
+ last_transport_error = None
178
+ transports = (
179
+ ("direct", self._direct_session),
180
+ ("environment", self._environment_session),
181
+ )
182
+ for index, (transport_name, session) in enumerate(transports):
183
+ try:
184
+ response = session.post(
185
+ url,
186
+ headers=self.headers,
187
+ json=payload,
188
+ timeout=timeout,
189
+ )
190
+ break
191
+ except (
192
+ requests.exceptions.SSLError,
193
+ requests.exceptions.Timeout,
194
+ requests.exceptions.ConnectionError,
195
+ ) as exc:
196
+ last_transport_error = exc
197
+ if index == 0:
198
+ logging.warning(
199
+ "[SP_EXECUTOR] transport_fallback path=%s from=direct to=environment error=%s",
200
+ path, type(exc).__name__,
201
+ )
202
+ continue
203
+ raise
204
+
205
+ if response is None:
206
+ if last_transport_error is not None:
207
+ raise last_transport_error
208
+ raise RuntimeError("executor_transport_unavailable")
209
+
210
+ try:
211
+ data = response.json()
212
+ except Exception as exc:
213
+ raise RuntimeError(f"executor_worker_non_json_http_{response.status_code}") from exc
214
+ if not response.ok or data.get("ok") is False:
215
+ error = str(data.get("error") or data.get("reason") or f"HTTP_{response.status_code}")
216
+ raise ExecutorApiError(error, response.status_code, data)
217
+ return data
218
+
219
+ def claim(self, *, task_id: str, user_id: int, request_hash: str, executor_instance_id: str) -> dict:
220
+ payload = {
221
+ "task_id": task_id,
222
+ "user_id": user_id,
223
+ "request_hash": request_hash,
224
+ "executor_instance_id": executor_instance_id,
225
+ }
226
+ deadline = time.monotonic() + EXECUTOR_CLAIM_WINDOW_SECONDS
227
+ attempt = 0
228
+ while True:
229
+ remaining = deadline - time.monotonic()
230
+ if remaining <= 0:
231
+ raise RuntimeError("executor_claim_deadline_exhausted")
232
+ attempt += 1
233
+ request_timeout = max(1.0, min(EXECUTOR_CLAIM_REQUEST_TIMEOUT_SECONDS, remaining))
234
+ try:
235
+ return self.post(
236
+ "/executor/task/claim",
237
+ payload,
238
+ timeout=request_timeout,
239
+ )
240
+ except (
241
+ requests.exceptions.SSLError,
242
+ requests.exceptions.Timeout,
243
+ requests.exceptions.ConnectionError,
244
+ ) as exc:
245
+ remaining = deadline - time.monotonic()
246
+ if remaining <= 0:
247
+ raise
248
+ delay = min(
249
+ EXECUTOR_CLAIM_BACKOFF_MAX_SECONDS,
250
+ EXECUTOR_CLAIM_BACKOFF_INITIAL_SECONDS * (2 ** max(0, attempt - 1)),
251
+ max(0.0, remaining - 0.25),
252
+ )
253
+ logging.warning(
254
+ "[SP_EXECUTOR] claim_transport_retry task_id=%s attempt=%s error=%s delay=%.1fs remaining=%.1fs",
255
+ task_id, attempt, type(exc).__name__, delay, remaining,
256
+ )
257
+ if delay <= 0:
258
+ raise
259
+ time.sleep(delay)
260
+
261
+ def heartbeat(self, payload: dict) -> dict:
262
+ return self.post("/executor/task/heartbeat", payload)
263
+
264
+ def finish(self, payload: dict) -> dict:
265
+ return self.post("/executor/task/finish", payload, timeout=30)
266
+
267
+
268
+ class ExecutionState:
269
+ def __init__(self, *, task_id: str, user_id: int, epoch: int, instance_id: str, api: ExecutorApi):
270
+ self.task_id = task_id
271
+ self.user_id = int(user_id)
272
+ self.epoch = int(epoch)
273
+ self.instance_id = str(instance_id)
274
+ self.api = api
275
+ self._lock = threading.Lock()
276
+ self._last_activity = time.monotonic()
277
+ self._progress_pct = 0
278
+ self._current_node = "executor_claimed"
279
+ self._checkpoint_seq = 0
280
+ self._checkpoint_stage = ""
281
+ self._ds_calls = 0
282
+ self._s2_calls = 0
283
+ self._oa_calls = 0
284
+ self._cancel = False
285
+ self._cancel_reason = ""
286
+ self._stop = threading.Event()
287
+ self._thread = None
288
+
289
+ 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):
290
+ with self._lock:
291
+ self._last_activity = time.monotonic()
292
+ # Request counters are observational metrics, not task-liveness
293
+ # authority. They may advance on activity-only callbacks while
294
+ # progress/node/checkpoint ownership remains unchanged.
295
+ if ds_calls is not None:
296
+ self._ds_calls = max(self._ds_calls, int(ds_calls))
297
+ if s2_calls is not None:
298
+ self._s2_calls = max(self._s2_calls, int(s2_calls))
299
+ if oa_calls is not None:
300
+ self._oa_calls = max(self._oa_calls, int(oa_calls))
301
+ if not activity_only:
302
+ if progress_pct is not None:
303
+ self._progress_pct = max(self._progress_pct, max(0, min(100, int(progress_pct))))
304
+ if current_node:
305
+ self._current_node = str(current_node)[:240]
306
+ if checkpoint_seq is not None and int(checkpoint_seq) >= self._checkpoint_seq:
307
+ self._checkpoint_seq = int(checkpoint_seq)
308
+ self._checkpoint_stage = str(checkpoint_stage or self._checkpoint_stage)[:120]
309
+
310
+ def cancelled(self) -> bool:
311
+ with self._lock:
312
+ return bool(self._cancel)
313
+
314
+ def cancel_reason(self) -> str:
315
+ with self._lock:
316
+ return str(self._cancel_reason or "")
317
+
318
+ def _mark_cancelled(self, reason: str) -> None:
319
+ with self._lock:
320
+ self._cancel = True
321
+ if reason:
322
+ self._cancel_reason = str(reason)[:80]
323
+
324
+ def start_heartbeat(self):
325
+ self._thread = threading.Thread(target=self._heartbeat_loop, name="smartpaper-executor-heartbeat", daemon=True)
326
+ self._thread.start()
327
+
328
+ def stop_heartbeat(self):
329
+ self._stop.set()
330
+ if self._thread and self._thread.is_alive():
331
+ self._thread.join(timeout=2)
332
+
333
+ def _payload(self):
334
+ with self._lock:
335
+ return {
336
+ "task_id": self.task_id,
337
+ "user_id": self.user_id,
338
+ "execution_epoch": self.epoch,
339
+ "executor_instance_id": self.instance_id,
340
+ "current_node": self._current_node,
341
+ "progress_pct": self._progress_pct,
342
+ "checkpoint_seq": self._checkpoint_seq,
343
+ "checkpoint_stage": self._checkpoint_stage,
344
+ "ds_calls": self._ds_calls,
345
+ "s2_calls": self._s2_calls,
346
+ "oa_calls": self._oa_calls,
347
+ }, time.monotonic() - self._last_activity
348
+
349
+ def heartbeat_once(self):
350
+ payload, _ = self._payload()
351
+ result = self.api.heartbeat(payload)
352
+ if result.get("cancel_requested"):
353
+ self._mark_cancelled("user_cancel")
354
+ return result
355
+
356
+ def _heartbeat_loop(self):
357
+ interval = int(APP_CONFIG.get("executor_heartbeat_interval_seconds", 15))
358
+ grace = int(APP_CONFIG.get("executor_activity_grace_seconds", 45))
359
+ while not self._stop.wait(interval):
360
+ payload, idle_seconds = self._payload()
361
+ # Long synchronous model calls can legitimately produce no Python
362
+ # callbacks for more than one Worker lease window. Model activity
363
+ # is diagnostic only; process/epoch ownership is the liveness
364
+ # authority. Therefore heartbeat must continue while this executor
365
+ # is alive and authoritative.
366
+ if idle_seconds > grace:
367
+ logging.info(
368
+ "[SP_EXECUTOR] heartbeat_during_long_call task_id=%s idle_s=%.1f node=%s",
369
+ self.task_id, idle_seconds, payload.get("current_node"),
370
+ )
371
+ try:
372
+ result = self.api.heartbeat(payload)
373
+ if result.get("cancel_requested"):
374
+ self._mark_cancelled("user_cancel")
375
+ return
376
+ except ExecutorApiError as exc:
377
+ if exc.error == "task_cancel_requested":
378
+ self._mark_cancelled("user_cancel")
379
+ logging.warning(
380
+ "[SP_EXECUTOR] task_cancel_requested task_id=%s", self.task_id
381
+ )
382
+ return
383
+ if exc.error in {
384
+ "executor_superseded", "generation_lock_lost",
385
+ "task_already_finalized",
386
+ } or exc.status_code in {404, 409, 410}:
387
+ self._mark_cancelled("authority_lost")
388
+ logging.warning(
389
+ "[SP_EXECUTOR] executor_no_longer_authoritative task_id=%s error=%s",
390
+ self.task_id, _safe(exc.error, 180),
391
+ )
392
+ return
393
+ logging.warning("[SP_EXECUTOR] heartbeat_error task_id=%s error=%s", self.task_id, _safe(exc, 180))
394
+ except Exception as exc:
395
+ logging.warning("[SP_EXECUTOR] heartbeat_error task_id=%s error=%s", self.task_id, _safe(exc, 180))
396
+
397
+
398
+ def _cleanup_user_cancelled_task_artifacts(task_dir: Path, request_data: dict) -> None:
399
+ """Delete only local, user-cancelled stage artifacts. D1 audit state remains authoritative."""
400
+ try:
401
+ token = str((request_data or {}).get("preview_token") or "").strip()
402
+ if re.fullmatch(r"[A-Za-z0-9_-]{32,128}", token):
403
+ preview = Path(__file__).resolve().parent / "static" / "previews" / f"{token}.json"
404
+ preview.unlink(missing_ok=True)
405
+ except Exception as exc:
406
+ logging.warning("[SP_EXECUTOR] cancel_preview_cleanup_error task_id=%s error=%s", task_dir.name, _safe(exc, 160))
407
+ try:
408
+ shutil.rmtree(task_dir, ignore_errors=True)
409
+ except Exception as exc:
410
+ logging.warning("[SP_EXECUTOR] cancel_task_cleanup_error task_id=%s error=%s", task_dir.name, _safe(exc, 160))
411
+
412
+
413
+ def _snapshot_nullable_version(value):
414
+ text = str(value or "").strip()
415
+ return text or None
416
+
417
+
418
+ def _trusted_task_policy_snapshot_envelope(snapshot: dict, resolved: dict) -> dict:
419
+ return {
420
+ "task_id": str(snapshot.get("task_id") or ""),
421
+ "snapshot_schema_version": str(snapshot.get("snapshot_schema_version") or ""),
422
+ "product_tier": str(snapshot.get("product_tier") or ""),
423
+ "product_policy_version": str(snapshot.get("product_policy_version") or ""),
424
+ "word_count_policy_version": str(snapshot.get("word_count_policy_version") or ""),
425
+ "model_policy_version": str(snapshot.get("model_policy_version") or ""),
426
+ "cost_policy_version": str(snapshot.get("cost_policy_version") or ""),
427
+ "billing_policy_version": _snapshot_nullable_version(snapshot.get("billing_policy_version")),
428
+ "trial_policy_version": _snapshot_nullable_version(snapshot.get("trial_policy_version")),
429
+ "entitlement_class": _snapshot_nullable_version(snapshot.get("entitlement_class")),
430
+ "min_body_words": int(snapshot.get("min_body_words") or 0),
431
+ "max_body_words": int(snapshot.get("max_body_words") or 0),
432
+ "counting_contract_version": str(snapshot.get("counting_contract_version") or ""),
433
+ "resolved_policy": resolved,
434
+ }
435
+
436
+
437
+ def _validate_trusted_task_policy_snapshot(claim: dict, task: dict, request_data: dict) -> dict | None:
438
+ raw = claim.get("task_policy_snapshot")
439
+ if raw is None:
440
+ return None
441
+ if not isinstance(raw, dict):
442
+ raise RuntimeError("task_policy_snapshot_invalid")
443
+ snapshot = dict(raw)
444
+ resolved = snapshot.get("resolved_policy")
445
+ if not isinstance(resolved, dict):
446
+ raise RuntimeError("task_policy_snapshot_resolved_policy_missing")
447
+
448
+ snapshot_task_id = str(snapshot.get("task_id") or "").strip()
449
+ claimed_task_id = str(task.get("task_id") or "").strip()
450
+ request_task_id = str(request_data.get("task_id") or "").strip()
451
+ if not snapshot_task_id or snapshot_task_id != claimed_task_id or snapshot_task_id != request_task_id:
452
+ raise RuntimeError("task_policy_snapshot_task_mismatch")
453
+
454
+ tier = str(snapshot.get("product_tier") or "").strip().upper()
455
+ entitlement_class = str(snapshot.get("entitlement_class") or "").strip().upper()
456
+ if tier not in {"STANDARD", "FLAGSHIP"}:
457
+ raise RuntimeError("task_policy_snapshot_product_tier_invalid")
458
+ trial = resolved.get("trial") if isinstance(resolved.get("trial"), dict) else None
459
+ billing = resolved.get("billing") if isinstance(resolved.get("billing"), dict) else None
460
+ allowed_entitlements = []
461
+ if trial and str(trial.get("entitlement_class") or "").strip():
462
+ allowed_entitlements.append(str(trial.get("entitlement_class") or "").strip().upper())
463
+ if billing and str(billing.get("entitlement_class") or "").strip():
464
+ paid_class = str(billing.get("entitlement_class") or "").strip().upper()
465
+ if paid_class not in allowed_entitlements:
466
+ allowed_entitlements.append(paid_class)
467
+ if not entitlement_class or entitlement_class not in allowed_entitlements:
468
+ raise RuntimeError("task_policy_snapshot_entitlement_invalid")
469
+ if any(not value.startswith(f"{tier}_") for value in allowed_entitlements):
470
+ raise RuntimeError("task_policy_snapshot_entitlement_mismatch")
471
+
472
+ if str(resolved.get("snapshot_schema_version") or "") != str(snapshot.get("snapshot_schema_version") or ""):
473
+ raise RuntimeError("task_policy_snapshot_schema_mismatch")
474
+ if str(resolved.get("product_tier") or "").strip().upper() != tier:
475
+ raise RuntimeError("task_policy_snapshot_product_tier_mismatch")
476
+ version_fields = (
477
+ "product_policy_version",
478
+ "word_count_policy_version",
479
+ "model_policy_version",
480
+ "cost_policy_version",
481
+ )
482
+ for field in version_fields:
483
+ if str(resolved.get(field) or "") != str(snapshot.get(field) or ""):
484
+ raise RuntimeError("task_policy_snapshot_policy_version_mismatch")
485
+ for field in ("billing_policy_version", "trial_policy_version"):
486
+ if _snapshot_nullable_version(resolved.get(field)) != _snapshot_nullable_version(snapshot.get(field)):
487
+ raise RuntimeError("task_policy_snapshot_policy_version_mismatch")
488
+
489
+ if str(resolved.get("manuscript_mode") or "").strip().lower() != str(task.get("mode") or "").strip().lower():
490
+ raise RuntimeError("task_policy_snapshot_mode_mismatch")
491
+ target_words = int(task.get("target_words") or 0)
492
+ if target_words != int(request_data.get("target_words") or 0):
493
+ raise RuntimeError("target_words_claim_mismatch")
494
+ minimum = int(snapshot.get("min_body_words") or 0)
495
+ maximum = int(snapshot.get("max_body_words") or 0)
496
+ if minimum <= 0 or maximum < minimum or not minimum <= target_words <= maximum:
497
+ raise RuntimeError("task_policy_snapshot_word_range_mismatch")
498
+ word_count = resolved.get("word_count") if isinstance(resolved.get("word_count"), dict) else {}
499
+ if (
500
+ int(word_count.get("min_body_words") or 0) != minimum
501
+ or int(word_count.get("max_body_words") or 0) != maximum
502
+ or str(word_count.get("counting_contract_version") or "") != str(snapshot.get("counting_contract_version") or "")
503
+ ):
504
+ raise RuntimeError("task_policy_snapshot_word_policy_mismatch")
505
+
506
+ observed = hashlib.sha256(
507
+ json.dumps(
508
+ _trusted_task_policy_snapshot_envelope(snapshot, resolved),
509
+ ensure_ascii=False,
510
+ sort_keys=True,
511
+ separators=(",", ":"),
512
+ ).encode("utf-8")
513
+ ).hexdigest()
514
+ expected = str(snapshot.get("snapshot_sha256") or "").strip().lower()
515
+ if not re.fullmatch(r"[a-f0-9]{64}", expected) or observed != expected:
516
+ raise RuntimeError("task_policy_snapshot_sha_mismatch")
517
+ return snapshot
518
+
519
+
520
+ def _load_request(task_dir: Path) -> dict:
521
+ path = task_dir / "request.json"
522
+ if not path.is_file():
523
+ raise RuntimeError("request_manifest_missing")
524
+ value = json.loads(path.read_text(encoding="utf-8"))
525
+ if not isinstance(value, dict):
526
+ raise RuntimeError("request_manifest_invalid")
527
+ return value
528
+
529
+
530
+ def _bootstrap_claim(*, task_dir: Path, task_id: str, user_id: int, request_data: dict, api: ExecutorApi) -> tuple[dict, int, str]:
531
+ """Claim first and publish a PID-bound local handshake before heavy imports."""
532
+ request_hash = str(request_data.get("request_hash") or "").strip()
533
+ if not request_hash:
534
+ raise RuntimeError("request_hash_missing")
535
+ instance_id = _new_executor_instance_id()
536
+ claim = api.claim(
537
+ task_id=task_id,
538
+ user_id=user_id,
539
+ request_hash=request_hash,
540
+ executor_instance_id=instance_id,
541
+ )
542
+ task = dict(claim.get("task") or {})
543
+ execution = dict(claim.get("execution") or {})
544
+ epoch = int(execution.get("execution_epoch") or task.get("execution_epoch") or 0)
545
+ claimed_instance_id = str(execution.get("executor_instance_id") or "")
546
+ if epoch <= 0 or not claimed_instance_id:
547
+ raise RuntimeError("executor_claim_identity_missing")
548
+ if claimed_instance_id != instance_id:
549
+ raise RuntimeError("executor_claim_identity_mismatch")
550
+ if int(task.get("target_words") or 0) != int(request_data.get("target_words") or 0):
551
+ raise RuntimeError("target_words_claim_mismatch")
552
+ trusted_task_policy_snapshot = _validate_trusted_task_policy_snapshot(
553
+ claim, task, request_data
554
+ )
555
+ if trusted_task_policy_snapshot is not None:
556
+ task["_trusted_task_policy_snapshot"] = trusted_task_policy_snapshot
557
+ try:
558
+ with checkpoint_authority_lock(task_dir):
559
+ _atomic_json(
560
+ _claim_marker_path(task_dir),
561
+ {
562
+ "task_id": task_id,
563
+ "user_id": int(user_id),
564
+ "request_hash": request_hash,
565
+ "pid": os.getpid(),
566
+ "execution_epoch": epoch,
567
+ "executor_instance_id": instance_id,
568
+ "worker_checkpoint_seq": int(task.get("checkpoint_seq") or 0),
569
+ "worker_checkpoint_stage": str(task.get("checkpoint_stage") or "")[:120],
570
+ "task_policy_snapshot_sha256": str(
571
+ (trusted_task_policy_snapshot or {}).get("snapshot_sha256") or ""
572
+ ),
573
+ "task_policy_product_tier": str(
574
+ (trusted_task_policy_snapshot or {}).get("product_tier") or ""
575
+ ),
576
+ "claimed_at": datetime.now(timezone.utc).isoformat(),
577
+ },
578
+ )
579
+ except Exception as exc:
580
+ try:
581
+ api.finish(
582
+ {
583
+ "task_id": task_id,
584
+ "user_id": int(user_id),
585
+ "execution_epoch": epoch,
586
+ "executor_instance_id": instance_id,
587
+ "status": "failed",
588
+ "safe_error_code": "E_EXECUTOR_HANDSHAKE_FAILED",
589
+ "safe_error_detail": _safe(exc, 800),
590
+ "current_node": "executor_handshake_failed",
591
+ }
592
+ )
593
+ finally:
594
+ raise
595
+ return task, epoch, instance_id
596
+
597
+
598
+ def _finish_bootstrap_failure(api: ExecutorApi, *, task_id: str, user_id: int, epoch: int, instance_id: str, exc: Exception) -> None:
599
+ try:
600
+ api.finish(
601
+ {
602
+ "task_id": task_id,
603
+ "user_id": user_id,
604
+ "execution_epoch": epoch,
605
+ "executor_instance_id": instance_id,
606
+ "status": "failed",
607
+ "safe_error_code": "E_EXECUTOR_BOOTSTRAP_FAILED",
608
+ "safe_error_detail": _safe(exc, 800),
609
+ "current_node": "executor_bootstrap_failed",
610
+ }
611
+ )
612
+ except Exception as finish_exc:
613
+ logging.warning("[SP_EXECUTOR] bootstrap_finalize_rejected task_id=%s error=%s", task_id, _safe(finish_exc, 220))
614
+
615
+
616
+ def main() -> int:
617
+ parser = argparse.ArgumentParser()
618
+ parser.add_argument("--task-dir", required=True)
619
+ parser.add_argument("--task-id", required=True)
620
+ parser.add_argument("--user-id", required=True, type=int)
621
+ args = parser.parse_args()
622
+
623
+ task_dir = Path(args.task_dir).resolve()
624
+ request_data = _load_request(task_dir)
625
+ if str(request_data.get("task_id") or "") != args.task_id:
626
+ raise RuntimeError("request_task_id_mismatch")
627
+ if int(request_data.get("user_id") or 0) != int(args.user_id):
628
+ raise RuntimeError("request_user_id_mismatch")
629
+
630
+ api = ExecutorApi()
631
+ task, epoch, instance_id = _bootstrap_claim(
632
+ task_dir=task_dir,
633
+ task_id=args.task_id,
634
+ user_id=args.user_id,
635
+ request_data=request_data,
636
+ api=api,
637
+ )
638
+ remote_checkpoint_seq = int(task.get("checkpoint_seq") or 0)
639
+ remote_checkpoint_stage = str(task.get("checkpoint_stage") or "")[:120]
640
+ state = ExecutionState(task_id=args.task_id, user_id=args.user_id, epoch=epoch, instance_id=instance_id, api=api)
641
+ if remote_checkpoint_seq > 0:
642
+ state.touch(
643
+ checkpoint_seq=remote_checkpoint_seq,
644
+ checkpoint_stage=remote_checkpoint_stage,
645
+ current_node=remote_checkpoint_stage or "executor_claimed",
646
+ )
647
+ state.start_heartbeat()
648
+ logging.info("[SP_EXECUTOR] claimed task_id=%s epoch=%s instance=%s", args.task_id, epoch, instance_id[:12])
649
+
650
+ confirmed_store = TaskCheckpointStore(
651
+ task_dir,
652
+ str(request_data.get("request_hash") or ""),
653
+ epoch,
654
+ task_id=args.task_id,
655
+ executor_instance_id=instance_id,
656
+ confirmed_checkpoint_seq=remote_checkpoint_seq,
657
+ confirmed_checkpoint_stage=remote_checkpoint_stage,
658
+ )
659
+ confirmed_checkpoint = confirmed_store.load_confirmed()
660
+ if remote_checkpoint_seq > 0 and confirmed_checkpoint is None:
661
+ try:
662
+ api.finish(
663
+ {
664
+ "task_id": args.task_id,
665
+ "user_id": args.user_id,
666
+ "execution_epoch": epoch,
667
+ "executor_instance_id": instance_id,
668
+ "status": "failed",
669
+ "safe_error_code": "E_CHECKPOINT_MISSING",
670
+ "safe_error_detail": "Worker-confirmed checkpoint metadata does not match available local checkpoint material; quota released.",
671
+ "current_node": "checkpoint_missing",
672
+ }
673
+ )
674
+ finally:
675
+ state.stop_heartbeat()
676
+ return 2
677
+
678
+ try:
679
+ # Heavy paper modules are deliberately imported only after D1 claim and
680
+ # the local claim handshake. Any import/init failure is now observable
681
+ # and can atomically release the reserved quota.
682
+ from core.generation_runner import GenerationRunner
683
+
684
+ runner = GenerationRunner(
685
+ task_dir=task_dir,
686
+ request_data=request_data,
687
+ execution_epoch=epoch,
688
+ executor_instance_id=instance_id,
689
+ confirmed_checkpoint_seq=remote_checkpoint_seq,
690
+ confirmed_checkpoint_stage=remote_checkpoint_stage,
691
+ trusted_task_policy_snapshot=task.get("_trusted_task_policy_snapshot"),
692
+ progress_callback=state.touch,
693
+ cancel_checker=state.cancelled,
694
+ )
695
+ result = runner.run()
696
+ state.touch(progress_pct=100, current_node="package_validated")
697
+ try:
698
+ state.heartbeat_once()
699
+ except Exception as exc:
700
+ logging.warning("[SP_EXECUTOR] final_heartbeat_error task_id=%s error=%s", args.task_id, _safe(exc, 180))
701
+ if state.cancelled():
702
+ raise RuntimeError("task_cancel_requested")
703
+ api.finish(
704
+ {
705
+ "task_id": args.task_id,
706
+ "user_id": args.user_id,
707
+ "execution_epoch": epoch,
708
+ "executor_instance_id": instance_id,
709
+ "status": "completed",
710
+ "current_node": "package_validated",
711
+ "output_filename": str(result.get("output_filename") or "")[:240],
712
+ # R14-TQ2-PQ1-R3-R1: completed tasks may carry a nonfatal,
713
+ # backend-trusted quality warning. Worker/D1 already persists
714
+ # safe_error_code/detail for completed terminal transitions;
715
+ # quota semantics remain the ordinary completed/committed path.
716
+ "safe_error_code": str(result.get("safe_error_code") or "")[:80],
717
+ "safe_error_detail": str(result.get("safe_error_detail") or "")[:800],
718
+ }
719
+ )
720
+ logging.info("[SP_EXECUTOR] completed task_id=%s epoch=%s", args.task_id, epoch)
721
+ return 0
722
+ except Exception as exc:
723
+ if state.cancelled():
724
+ reason = state.cancel_reason()
725
+ if reason == "user_cancel":
726
+ _cleanup_user_cancelled_task_artifacts(task_dir, request_data)
727
+ logging.info("[SP_EXECUTOR] cancelled_and_cleaned task_id=%s epoch=%s", args.task_id, epoch)
728
+ else:
729
+ logging.warning("[SP_EXECUTOR] stopped_non_authoritative task_id=%s epoch=%s reason=%s", args.task_id, epoch, reason or "unknown")
730
+ return 3
731
+ if _is_local_checkpoint_authority_failure(exc):
732
+ logging.warning(
733
+ "[SP_EXECUTOR] stopped_non_authoritative task_id=%s epoch=%s reason=local_checkpoint_authority_lost error=%s",
734
+ args.task_id, epoch, _safe(exc, 180),
735
+ )
736
+ return 3
737
+ if isinstance(exc, (ImportError, ModuleNotFoundError)):
738
+ logging.exception("[SP_EXECUTOR] bootstrap_import_failed task_id=%s epoch=%s error=%s", args.task_id, epoch, _safe(exc, 240))
739
+ _finish_bootstrap_failure(api, task_id=args.task_id, user_id=args.user_id, epoch=epoch, instance_id=instance_id, exc=exc)
740
+ return 4
741
+ if _is_recoverable_generation_failure(exc):
742
+ logging.exception("[SP_EXECUTOR] recoverable_interruption task_id=%s epoch=%s error=%s", args.task_id, epoch, _safe(exc, 240))
743
+ return 75
744
+ logging.exception("[SP_EXECUTOR] failed task_id=%s epoch=%s error=%s", args.task_id, epoch, _safe(exc, 240))
745
+ try:
746
+ api.finish(
747
+ {
748
+ "task_id": args.task_id,
749
+ "user_id": args.user_id,
750
+ "execution_epoch": epoch,
751
+ "executor_instance_id": instance_id,
752
+ "status": "failed",
753
+ "safe_error_code": "E_GENERATION_FAILED",
754
+ "safe_error_detail": _safe(exc, 800),
755
+ "current_node": "generation_failed",
756
+ }
757
+ )
758
+ except Exception as finish_exc:
759
+ logging.warning("[SP_EXECUTOR] failure_finalize_rejected task_id=%s error=%s", args.task_id, _safe(finish_exc, 220))
760
+ return 1
761
+ finally:
762
+ state.stop_heartbeat()
763
+
764
+
765
+ if __name__ == "__main__":
766
+ raise SystemExit(main())