claude-smart 0.2.48 → 0.2.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +6 -2
  3. package/bin/claude-smart.js +289 -56
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/opencode/dist/server.mjs +60 -2
  8. package/plugin/opencode/server.mts +64 -2
  9. package/plugin/pyproject.toml +2 -2
  10. package/plugin/scripts/_lib.sh +58 -6
  11. package/plugin/scripts/backend-service.sh +15 -10
  12. package/plugin/scripts/codex-hook.js +16 -4
  13. package/plugin/scripts/dashboard-service.sh +1 -1
  14. package/plugin/scripts/ensure-plugin-root.sh +106 -8
  15. package/plugin/scripts/opencode-claude-compat.js +8 -1
  16. package/plugin/scripts/smart-install.sh +101 -20
  17. package/plugin/src/README.md +1 -1
  18. package/plugin/src/claude_smart/cli.py +79 -29
  19. package/plugin/src/claude_smart/env_config.py +53 -11
  20. package/plugin/uv.lock +5 -5
  21. package/plugin/vendor/reflexio/reflexio/cli/README.md +3 -0
  22. package/plugin/vendor/reflexio/reflexio/client/client.py +40 -6
  23. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +18 -0
  24. package/plugin/vendor/reflexio/reflexio/server/api.py +15 -4
  25. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +1 -1
  26. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +1 -0
  27. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +183 -1
  28. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +63 -2
  29. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/__init__.py +13 -0
  30. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/scheduler.py +142 -0
  31. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +193 -0
  32. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +1 -3
  33. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +150 -37
  34. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +44 -22
  35. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +2 -0
  36. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +140 -2
  37. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +379 -0
  38. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -5
  39. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +8 -3
  40. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +86 -2
  41. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +16 -7
  42. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +8 -3
  43. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +12 -5
  44. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +52 -7
  45. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +28 -4
  46. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +17 -0
  47. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_commit_scope.py +9 -0
  48. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +219 -0
  49. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +23 -1
  50. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +22 -0
@@ -445,12 +445,17 @@ class ReflexioClient:
445
445
  explicit retry after reauth or limit reset.
446
446
 
447
447
  Returns:
448
- PublishUserInteractionResponse: Server response. In
449
- ``wait_for_response=False`` mode this is a bare
450
- acknowledgement ("Interaction queued for processing")
451
- without extraction counts; in ``wait_for_response=True``
452
- mode it includes request_id, storage routing, and
453
- deltas.
448
+ PublishUserInteractionResponse: Server response.
449
+
450
+ In deferred mode (``wait_for_response=False``) the
451
+ response carries ``learning_status="deferred"`` and a
452
+ ``request_id``; ``profiles_added``/``playbooks_added`` are
453
+ 0 because extraction has not run yet. Poll
454
+ ``get_learning_status(request_id)`` for completion.
455
+
456
+ In ``wait_for_response=True`` mode the server waits for
457
+ extraction and the response includes ``request_id``,
458
+ storage routing, and real profile/playbook deltas.
454
459
  """
455
460
  if session_id is None or not session_id.strip():
456
461
  raise ValueError("session_id is required and cannot be empty")
@@ -481,6 +486,35 @@ class ReflexioClient:
481
486
  self._cache.invalidate("get_agent_playbooks")
482
487
  return result
483
488
 
489
+ def get_learning_status(self, request_id: str) -> str:
490
+ """Poll the learning status for a previously published request.
491
+
492
+ Call this after a deferred ``publish_interaction`` (where
493
+ ``wait_for_response=False``). The response carries
494
+ ``learning_status="deferred"`` to signal that extraction has been
495
+ queued; use this method to track progress once the durable queue
496
+ is active.
497
+
498
+ Args:
499
+ request_id: The ``request_id`` of the published interaction, as
500
+ returned in ``PublishUserInteractionResponse.request_id``
501
+ (populated on both the deferred and ``wait_for_response=True``
502
+ paths) or known by the caller.
503
+
504
+ Returns:
505
+ One of: ``"pending"`` | ``"processing"`` | ``"done"`` | ``"failed"``.
506
+
507
+ Raises:
508
+ requests.HTTPError: 404 when the request_id is not known to the
509
+ server for this org.
510
+ """
511
+ response = self._make_request(
512
+ "GET",
513
+ "/api/learning_status",
514
+ params={"request_id": request_id},
515
+ )
516
+ return str(response["status"])
517
+
484
518
  def search_interactions(
485
519
  self,
486
520
  request: SearchInteractionRequest | dict | None = None,
@@ -123,6 +123,7 @@ __all__ = [
123
123
  "LineageEvent",
124
124
  "LineageContext",
125
125
  "RecordRef",
126
+ "LearningStatusResponse",
126
127
  ]
127
128
 
128
129
  # ===============================
@@ -689,6 +690,23 @@ class PublishUserInteractionResponse(BaseModel):
689
690
  profiles_updated: int | None = None
690
691
  playbooks_added: int | None = None
691
692
  playbooks_updated: int | None = None
693
+ # Set to "deferred" when the server queued extraction asynchronously.
694
+ # None on the sync (wait_for_response=True) path. Poll GET
695
+ # /api/learning_status?request_id=... to track progress once the
696
+ # durable queue is active.
697
+ learning_status: str | None = None
698
+
699
+
700
+ class LearningStatusResponse(BaseModel):
701
+ """Response for GET /api/learning_status.
702
+
703
+ Attributes:
704
+ status: One of ``pending | processing | done | failed``.
705
+ Coverage-based: reflects whether a durable learning job has
706
+ processed through the request's creation timestamp.
707
+ """
708
+
709
+ status: Literal["pending", "processing", "done", "failed"]
692
710
 
693
711
 
694
712
  # whoami response — caller identity + resolved storage routing (masked)
@@ -345,6 +345,9 @@ def create_app(
345
345
  )
346
346
  from reflexio.server.extensions import AppContext
347
347
  from reflexio.server.llm.model_defaults import validate_llm_availability
348
+ from reflexio.server.services.durable_learning import (
349
+ maybe_start_durable_learning,
350
+ )
348
351
  from reflexio.server.services.extraction.resume_scheduler import (
349
352
  maybe_start_resume_scheduler,
350
353
  )
@@ -356,6 +359,7 @@ def create_app(
356
359
  async def lifespan(app: FastAPI) -> AsyncIterator[None]: # noqa: ARG001
357
360
  scheduler = None
358
361
  gc_scheduler = None
362
+ durable_learning_scheduler = None
359
363
  started_caps: list = []
360
364
  if mounts_data_plane:
361
365
  log_publish_hardware_capacity()
@@ -376,6 +380,14 @@ def create_app(
376
380
  lambda org_id: RequestContext(org_id=org_id),
377
381
  bootstrap_org_id=bootstrap_org_id,
378
382
  )
383
+ # Durable learning drains the learning_jobs queue per org. Gated on
384
+ # REFLEXIO_DURABLE_LEARNING_QUEUE; the default provider discovers
385
+ # orgs-with-work via the bootstrap storage (single-ref). Enterprise
386
+ # cross-ref fan-out is a deferred follow-up.
387
+ durable_learning_scheduler = maybe_start_durable_learning(
388
+ lambda org_id: RequestContext(org_id=org_id),
389
+ bootstrap_org_id=bootstrap_org_id,
390
+ )
379
391
  try:
380
392
  if capabilities is not None:
381
393
  ctx = (
@@ -397,10 +409,9 @@ def create_app(
397
409
  cap,
398
410
  exc_info=True,
399
411
  )
400
- if scheduler is not None:
401
- scheduler.stop()
402
- if gc_scheduler is not None:
403
- gc_scheduler.stop()
412
+ for sched in (scheduler, gc_scheduler, durable_learning_scheduler):
413
+ if sched is not None:
414
+ sched.stop()
404
415
  from reflexio.server.services.publish_learning_worker import (
405
416
  stop_publish_learning_worker,
406
417
  )
@@ -523,7 +523,7 @@ class TextGenerationMixin:
523
523
 
524
524
  # The loop only exits with a drained result (every no-result path
525
525
  # above raises); the assert makes that invariant explicit for pyright.
526
- assert result is not None
526
+ assert result is not None # noqa: S101
527
527
  status, payload = result
528
528
  # The payload is drained, so the feeder is unblocked and the child can
529
529
  # exit. Reap it (terminating if it lingers) to avoid a zombie.
@@ -110,6 +110,7 @@ __all__ = [
110
110
  "create_litellm_client",
111
111
  ]
112
112
 
113
+
113
114
  class LiteLLMClient(TextGenerationMixin, EmbeddingMixin, StructuredOutputMixin):
114
115
  """
115
116
  Unified LLM client using LiteLLM for multi-provider support.
@@ -18,9 +18,23 @@ from __future__ import annotations
18
18
  import importlib.util
19
19
  import logging
20
20
  import os
21
+ import shutil
21
22
  import threading
23
+ from collections.abc import Iterator
24
+ from contextlib import contextmanager
25
+ from pathlib import Path
22
26
  from typing import Any
23
27
 
28
+ try:
29
+ import fcntl
30
+ except ImportError: # pragma: no cover - Windows only
31
+ fcntl = None # type: ignore[assignment]
32
+
33
+ try:
34
+ import msvcrt
35
+ except ImportError: # pragma: no cover - POSIX only
36
+ msvcrt = None # type: ignore[assignment]
37
+
24
38
  _LOGGER = logging.getLogger(__name__)
25
39
 
26
40
  _ENV_ENABLE = "CLAUDE_SMART_USE_LOCAL_EMBEDDING"
@@ -36,6 +50,17 @@ _TARGET_DIM = 512
36
50
  # ValueError that ONNXMiniLM_L6_V2 throws on over-length input.
37
51
  _MAX_CHARS = 800
38
52
 
53
+ # Chroma keeps this file list private inside ONNXMiniLM_L6_V2's download helper.
54
+ # Keep this in sync with chromadb's all-MiniLM-L6-v2 extracted archive layout.
55
+ _MINILM_EXPECTED_FILES = (
56
+ "config.json",
57
+ "model.onnx",
58
+ "special_tokens_map.json",
59
+ "tokenizer_config.json",
60
+ "tokenizer.json",
61
+ "vocab.txt",
62
+ )
63
+
39
64
 
40
65
  class LocalEmbedderError(RuntimeError):
41
66
  """Raised when the local embedder is called without chromadb installed."""
@@ -98,9 +123,54 @@ class LocalEmbedder:
98
123
  """
99
124
  ef = self._load()
100
125
  safe_inputs = [(text or "")[:_MAX_CHARS] for text in texts]
101
- raw = ef(safe_inputs)
126
+ try:
127
+ raw = ef(safe_inputs)
128
+ except Exception as exc: # noqa: BLE001 - Chroma raises varied cache errors.
129
+ raw = self._retry_embed_after_cache_clear(ef, exc, safe_inputs)
102
130
  return [_pad(vec) for vec in raw]
103
131
 
132
+ def _retry_embed_after_cache_clear(
133
+ self, failed_ef: Any, exc: Exception, safe_inputs: list[str]
134
+ ) -> Any:
135
+ with self._ef_lock:
136
+ if self._ef is not None and self._ef is not failed_ef:
137
+ return self._ef(safe_inputs)
138
+
139
+ embedding_cls = type(failed_ef)
140
+ recovery = _recoverable_minilm_cache(embedding_cls, exc)
141
+ if recovery is None:
142
+ raise exc
143
+ cache_path, cache_was_complete = recovery
144
+
145
+ with _exclusive_file_lock(_minilm_cache_lock_path(cache_path)):
146
+ if _should_clear_minilm_cache(
147
+ embedding_cls, cache_path, exc, cache_was_complete
148
+ ):
149
+ shutil.rmtree(cache_path, ignore_errors=True)
150
+ recovery_action = "clearing"
151
+ _LOGGER.warning(
152
+ "Failed to use local MiniLM embedder; cleared cache %s "
153
+ "and retrying once",
154
+ cache_path,
155
+ exc_info=True,
156
+ )
157
+ else:
158
+ recovery_action = "waiting for another process to refresh"
159
+ _LOGGER.info(
160
+ "MiniLM cache %s was refreshed by another process; retrying",
161
+ cache_path,
162
+ )
163
+ try:
164
+ fresh_ef = embedding_cls()
165
+ self._ef = fresh_ef
166
+ return fresh_ef(safe_inputs)
167
+ except Exception as retry_exc:
168
+ raise LocalEmbedderError(
169
+ "Local MiniLM cache recovery failed after "
170
+ f"{recovery_action} {cache_path}. Delete this cache "
171
+ "directory, restart Reflexio, and retry local embedding."
172
+ ) from retry_exc
173
+
104
174
 
105
175
  def _pad(vec: Any) -> list[float]:
106
176
  """Zero-pad a 384-dim vector to ``_TARGET_DIM`` as a plain list[float]."""
@@ -113,6 +183,118 @@ def _pad(vec: Any) -> list[float]:
113
183
  return floats + [0.0] * (_TARGET_DIM - len(floats))
114
184
 
115
185
 
186
+ def _recoverable_minilm_cache(
187
+ embedding_cls: Any, exc: Exception
188
+ ) -> tuple[Path, bool] | None:
189
+ cache_path = _minilm_cache_path(embedding_cls)
190
+ if cache_path is None:
191
+ return None
192
+ cache_was_complete = _minilm_cache_complete(embedding_cls, cache_path)
193
+ if cache_was_complete and not _complete_minilm_cache_error_identifies_cache(
194
+ embedding_cls, cache_path, exc
195
+ ):
196
+ return None
197
+
198
+ return cache_path, cache_was_complete
199
+
200
+
201
+ def _minilm_cache_path(embedding_cls: Any) -> Path | None:
202
+ download_path = getattr(embedding_cls, "DOWNLOAD_PATH", None)
203
+ if not download_path:
204
+ return None
205
+
206
+ model_name = getattr(embedding_cls, "MODEL_NAME", None)
207
+ cache_path = Path(str(download_path)).expanduser()
208
+ if not model_name or cache_path.name != model_name:
209
+ return None
210
+
211
+ return cache_path
212
+
213
+
214
+ def _minilm_cache_lock_path(cache_path: Path) -> Path:
215
+ return cache_path.parent / f".{cache_path.name}.lock"
216
+
217
+
218
+ @contextmanager
219
+ def _exclusive_file_lock(lock_path: Path) -> Iterator[None]:
220
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
221
+ with lock_path.open("a+b") as lock_file:
222
+ lock_file.seek(0)
223
+ if lock_file.read(1) == b"":
224
+ lock_file.write(b"\0")
225
+ lock_file.flush()
226
+ lock_file.seek(0)
227
+
228
+ if fcntl is not None:
229
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
230
+ try:
231
+ yield
232
+ finally:
233
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
234
+ elif msvcrt is not None:
235
+ msvcrt.locking( # type: ignore[reportAttributeAccessIssue]
236
+ lock_file.fileno(),
237
+ msvcrt.LK_LOCK, # type: ignore[reportAttributeAccessIssue]
238
+ 1,
239
+ )
240
+ try:
241
+ yield
242
+ finally:
243
+ lock_file.seek(0)
244
+ msvcrt.locking( # type: ignore[reportAttributeAccessIssue]
245
+ lock_file.fileno(),
246
+ msvcrt.LK_UNLCK, # type: ignore[reportAttributeAccessIssue]
247
+ 1,
248
+ )
249
+ else:
250
+ yield
251
+
252
+
253
+ def _should_clear_minilm_cache(
254
+ embedding_cls: Any, cache_path: Path, exc: Exception, cache_was_complete: bool
255
+ ) -> bool:
256
+ cache_is_complete = _minilm_cache_complete(embedding_cls, cache_path)
257
+ if not cache_is_complete:
258
+ return True
259
+
260
+ if not cache_was_complete:
261
+ return False
262
+
263
+ return _complete_minilm_cache_error_identifies_cache(embedding_cls, cache_path, exc)
264
+
265
+
266
+ def _complete_minilm_cache_error_identifies_cache(
267
+ embedding_cls: Any, cache_path: Path, exc: Exception
268
+ ) -> bool:
269
+ chain = _exception_chain(exc)
270
+ archive_name = str(getattr(embedding_cls, "ARCHIVE_FILENAME", ""))
271
+ messages = "\n".join(str(chain_exc) for chain_exc in chain)
272
+ return (
273
+ str(cache_path) in messages
274
+ or bool(archive_name and archive_name in messages)
275
+ or "does not match expected SHA256" in messages
276
+ )
277
+
278
+
279
+ def _minilm_cache_complete(embedding_cls: Any, cache_path: Path) -> bool:
280
+ extracted_folder = str(getattr(embedding_cls, "EXTRACTED_FOLDER_NAME", "onnx"))
281
+ return all(
282
+ (cache_path / extracted_folder / file_name).exists()
283
+ for file_name in _MINILM_EXPECTED_FILES
284
+ )
285
+
286
+
287
+ def _exception_chain(exc: Exception) -> list[BaseException]:
288
+ chain: list[BaseException] = []
289
+ seen: set[int] = set()
290
+ current: BaseException | None = exc
291
+ while current is not None and id(current) not in seen:
292
+ seen.add(id(current))
293
+ chain.append(current)
294
+ current = current.__cause__ or current.__context__
295
+ return chain
296
+
297
+
116
298
  _REGISTERED = False
117
299
 
118
300
 
@@ -1,6 +1,7 @@
1
1
  """Interaction route handlers (extracted from api.py, Tier3 A2)."""
2
2
 
3
3
  import logging
4
+ import uuid
4
5
  from typing import TYPE_CHECKING
5
6
 
6
7
  if TYPE_CHECKING:
@@ -10,6 +11,7 @@ from fastapi import (
10
11
  APIRouter,
11
12
  BackgroundTasks,
12
13
  Depends,
14
+ HTTPException,
13
15
  Request,
14
16
  )
15
17
 
@@ -30,6 +32,7 @@ from reflexio.models.api_schema.service_schemas import (
30
32
  DeleteSessionResponse,
31
33
  DeleteUserInteractionRequest,
32
34
  DeleteUserInteractionResponse,
35
+ LearningStatusResponse,
33
36
  PublishUserInteractionRequest,
34
37
  PublishUserInteractionResponse,
35
38
  )
@@ -79,6 +82,14 @@ def publish_user_interaction(
79
82
  ),
80
83
  )
81
84
 
85
+ # Resolve the request_id BEFORE backgrounding so we can return it to the
86
+ # caller for polling. GenerationService uses a caller-supplied request_id
87
+ # verbatim (and generates one only when absent), so pinning it on the
88
+ # payload guarantees the background task stores its status under the same
89
+ # id we hand back here.
90
+ request_id = payload.request_id or str(uuid.uuid4())
91
+ payload.request_id = request_id
92
+
82
93
  def _publish_task() -> None:
83
94
  try:
84
95
  publisher_api.add_user_interaction(
@@ -89,10 +100,16 @@ def publish_user_interaction(
89
100
  except Exception:
90
101
  logger.exception("Background publish failed for org %s", org_id)
91
102
 
92
- # Run in background — caller gets immediate acknowledgement
103
+ # Run in background — caller gets immediate acknowledgement.
104
+ # learning_status="deferred" tells the caller that extraction has not yet
105
+ # run; they can poll GET /api/learning_status?request_id=... (using the
106
+ # request_id returned here) to track it.
93
107
  background_tasks.add_task(_publish_task)
94
108
  return PublishUserInteractionResponse(
95
- success=True, message="Interaction queued for processing"
109
+ success=True,
110
+ message="Interaction queued for processing",
111
+ request_id=request_id,
112
+ learning_status="deferred",
96
113
  )
97
114
 
98
115
 
@@ -257,3 +274,47 @@ def get_requests_endpoint(
257
274
  has_more=internal_response.has_more,
258
275
  msg=internal_response.msg,
259
276
  )
277
+
278
+
279
+ @router.get(
280
+ "/api/learning_status",
281
+ response_model=LearningStatusResponse,
282
+ response_model_exclude_none=True,
283
+ )
284
+ @limiter.limit("120/minute")
285
+ def get_learning_status(
286
+ request: Request,
287
+ request_id: str,
288
+ org_id: str = Depends(default_get_org_id),
289
+ ) -> LearningStatusResponse:
290
+ """Return the coverage-based learning status for a published request.
291
+
292
+ The status reflects whether a durable learning job has processed through
293
+ the request's creation timestamp:
294
+
295
+ - ``pending``: not yet picked up.
296
+ - ``processing``: a worker currently holds the job.
297
+ - ``done``: at least one completed job covers this request.
298
+ - ``failed``: a dead job covers this request and no done job does.
299
+
300
+ Note: this endpoint reads ``learning_jobs`` rows written by the durable
301
+ queue. When the durable queue is OFF (in-memory deferred path) it returns
302
+ absence-based status (``pending`` for recent requests, ``done`` for old
303
+ ones) — acceptable for v1; the poll contract is tied to the durable queue.
304
+
305
+ Raises:
306
+ HTTPException: 404 when ``request_id`` is not found for this org.
307
+ Never reports ``done`` for a request that never existed.
308
+ """
309
+ reflexio = reflexio_cache.get_reflexio(org_id=org_id)
310
+ storage = reflexio.request_context.storage
311
+ if storage is None:
312
+ raise HTTPException(status_code=503, detail="Storage not configured")
313
+ req = storage.get_request(request_id)
314
+ if req is None:
315
+ raise HTTPException(status_code=404, detail="request not found")
316
+ status = storage.get_learning_status_for_request(
317
+ user_id=req.user_id,
318
+ request_created_at=float(req.created_at),
319
+ )
320
+ return LearningStatusResponse(status=status)
@@ -0,0 +1,13 @@
1
+ """Durable learning worker: claim → process in commit_scope → fenced completion."""
2
+
3
+ from reflexio.server.services.durable_learning.scheduler import (
4
+ DurableLearningScheduler,
5
+ maybe_start_durable_learning,
6
+ )
7
+ from reflexio.server.services.durable_learning.worker import DurableLearningWorker
8
+
9
+ __all__ = [
10
+ "DurableLearningScheduler",
11
+ "DurableLearningWorker",
12
+ "maybe_start_durable_learning",
13
+ ]
@@ -0,0 +1,142 @@
1
+ """Process-local scheduler for the durable learning-job queue (Task 6).
2
+
3
+ Each tick discovers the orgs with actionable learning jobs (via an injected
4
+ ``org_ids_provider``) and drains each through a :class:`DurableLearningWorker`.
5
+ The worker's claims are org-scoped and the fenced complete/fail transitions make
6
+ the drain exactly-once, so racing scheduler instances across processes are safe.
7
+
8
+ Gating: startup is gated on ``REFLEXIO_DURABLE_LEARNING_QUEUE`` (a rollout flag,
9
+ scalability design 2026-07-04 §8 stage 2). When off, :meth:`start` is a no-op.
10
+
11
+ Multi-ref capability (design §3.1, per-ref polling): the mechanism supports
12
+ per-data-ref fan-out — inject an ``org_ids_provider`` that yields orgs across
13
+ refs plus a ``request_context_factory`` that routes each org to its ref's
14
+ storage. The default provider (single-ref: bootstrap-storage discovery) is wired
15
+ in :func:`maybe_start_durable_learning`; the enterprise cross-ref enumerator is a
16
+ deferred follow-up (tracked with the pre-Task-8 gates).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ from collections.abc import Callable, Iterable
23
+
24
+ from reflexio.server.api_endpoints.request_context import RequestContext
25
+ from reflexio.server.env_utils import env_str, env_truthy
26
+ from reflexio.server.scheduling import ThreadedScheduler
27
+ from reflexio.server.services.durable_learning.worker import DurableLearningWorker
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class DurableLearningScheduler(ThreadedScheduler):
33
+ """Polling daemon that drains the durable learning queue per org.
34
+
35
+ Args:
36
+ request_context_factory: Builds an org-scoped :class:`RequestContext`.
37
+ Passed straight to the worker; a multi-ref deployment routes each org
38
+ to its data ref's storage here.
39
+ org_ids_provider: Called once per tick; yields the org_ids to drain. The
40
+ single-ref default (see :func:`maybe_start_durable_learning`) reads
41
+ them from the bootstrap storage's cross-org discovery query.
42
+ instance_id: Stable label written to ``claimed_by`` on each claim.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ request_context_factory: Callable[[str], RequestContext],
49
+ org_ids_provider: Callable[[], Iterable[str]],
50
+ instance_id: str | None = None,
51
+ ) -> None:
52
+ super().__init__(thread_name="reflexio-durable-learning-scheduler")
53
+ self._worker = DurableLearningWorker(
54
+ request_context_factory, instance_id=instance_id
55
+ )
56
+ self._org_ids_provider = org_ids_provider
57
+ # Read env INSIDE __init__ so tests can set the values before construction.
58
+ self._poll = float(env_str("REFLEXIO_DURABLE_LEARNING_POLL_SECONDS", "2.0"))
59
+ self._batch = int(env_str("REFLEXIO_DURABLE_LEARNING_BATCH", "16"))
60
+ self._lease = int(env_str("REFLEXIO_DURABLE_LEARNING_LEASE_SECONDS", "300"))
61
+
62
+ def _should_start(self) -> bool:
63
+ if env_truthy(env_str("REFLEXIO_DURABLE_LEARNING_QUEUE", "false")):
64
+ return True
65
+ logger.info(
66
+ "event=durable_learning_scheduler_disabled "
67
+ "REFLEXIO_DURABLE_LEARNING_QUEUE not set — not starting"
68
+ )
69
+ return False
70
+
71
+ def _on_started(self) -> None:
72
+ logger.info("event=durable_learning_scheduler_started")
73
+
74
+ def _on_stopped(self) -> None:
75
+ logger.info("event=durable_learning_scheduler_stopped")
76
+
77
+ def _run_once(self) -> float:
78
+ """Drain every discovered org once; per-org error isolation.
79
+
80
+ A single org raising never aborts the tick, and a failure to enumerate
81
+ orgs never kills the daemon thread. Returns the poll interval.
82
+ """
83
+ try:
84
+ for org_id in self._org_ids_provider():
85
+ if self._stop_event.is_set():
86
+ break
87
+ try:
88
+ self._worker.drain_org(org_id, self._batch, self._lease)
89
+ except Exception:
90
+ logger.exception(
91
+ "event=durable_learning_drain_failed org_id=%s", org_id
92
+ )
93
+ except Exception:
94
+ logger.exception("event=durable_learning_tick_failed")
95
+ return self._poll
96
+
97
+
98
+ def maybe_start_durable_learning(
99
+ request_context_factory: Callable[[str], RequestContext],
100
+ *,
101
+ bootstrap_org_id: str,
102
+ org_ids_provider: Callable[[], Iterable[str]] | None = None,
103
+ ) -> DurableLearningScheduler | None:
104
+ """Start the durable-learning scheduler when the rollout flag is on.
105
+
106
+ Returns ``None`` when ``REFLEXIO_DURABLE_LEARNING_QUEUE`` is off (mirrors
107
+ ``maybe_start_resume_scheduler``). The default ``org_ids_provider`` performs
108
+ single-ref discovery: it queries the bootstrap org's storage for every org
109
+ with actionable work (covers OSS/local and the shared managed DATA ref). The
110
+ enterprise cross-ref enumerator is a deferred follow-up.
111
+
112
+ Args:
113
+ request_context_factory: Builds an org-scoped :class:`RequestContext`.
114
+ bootstrap_org_id: Org used to reach a storage for cross-org discovery.
115
+ org_ids_provider: Optional explicit provider (e.g. a future enterprise
116
+ cross-ref enumerator); when ``None`` the single-ref default is used.
117
+ """
118
+ if not env_truthy(env_str("REFLEXIO_DURABLE_LEARNING_QUEUE", "false")):
119
+ return None
120
+
121
+ def _default_provider() -> list[str]:
122
+ try:
123
+ ctx = request_context_factory(bootstrap_org_id)
124
+ storage = getattr(ctx, "storage", None)
125
+ if storage is None:
126
+ return []
127
+ return list(storage.list_org_ids_with_pending_learning_jobs())
128
+ except NotImplementedError:
129
+ return []
130
+ except Exception:
131
+ logger.exception(
132
+ "event=durable_learning_discovery_failed bootstrap_org_id=%s",
133
+ bootstrap_org_id,
134
+ )
135
+ return []
136
+
137
+ scheduler = DurableLearningScheduler(
138
+ request_context_factory=request_context_factory,
139
+ org_ids_provider=org_ids_provider or _default_provider,
140
+ )
141
+ scheduler.start()
142
+ return scheduler