claude-smart 0.2.48 → 0.2.50

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 (193) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.md +11 -40
  3. package/bin/claude-smart.js +393 -55
  4. package/package.json +3 -2
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/.coverage +0 -0
  8. package/plugin/README.md +4 -3
  9. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +4 -2
  10. package/plugin/dashboard/app/dashboard/page.tsx +6 -1
  11. package/plugin/dashboard/app/preferences/[id]/page.tsx +18 -6
  12. package/plugin/dashboard/app/preferences/page.tsx +32 -35
  13. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +16 -1
  14. package/plugin/dashboard/app/sessions/page.tsx +2 -0
  15. package/plugin/dashboard/app/skills/page.tsx +65 -50
  16. package/plugin/dashboard/app/skills/project/[id]/page.tsx +17 -8
  17. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +1 -6
  18. package/plugin/dashboard/components/common/host-badge.tsx +118 -0
  19. package/plugin/dashboard/components/common/learning-application-badge.tsx +34 -0
  20. package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
  21. package/plugin/dashboard/components/common/page-header.tsx +3 -3
  22. package/plugin/dashboard/lib/config-file.ts +5 -1
  23. package/plugin/dashboard/lib/host-attribution.ts +62 -0
  24. package/plugin/dashboard/lib/session-reader.ts +40 -2
  25. package/plugin/dashboard/lib/types.ts +7 -1
  26. package/plugin/opencode/dist/server.mjs +60 -2
  27. package/plugin/opencode/server.mts +64 -2
  28. package/plugin/pyproject.toml +2 -2
  29. package/plugin/scripts/_lib.sh +255 -7
  30. package/plugin/scripts/backend-python-runner.py +46 -0
  31. package/plugin/scripts/backend-service.sh +766 -123
  32. package/plugin/scripts/codex-hook.js +79 -229
  33. package/plugin/scripts/dashboard-open.sh +6 -4
  34. package/plugin/scripts/dashboard-service.sh +118 -137
  35. package/plugin/scripts/ensure-plugin-root.sh +106 -8
  36. package/plugin/scripts/hook_entry.sh +3 -0
  37. package/plugin/scripts/opencode-claude-compat.js +8 -1
  38. package/plugin/scripts/smart-install.sh +116 -21
  39. package/plugin/src/README.md +1 -1
  40. package/plugin/src/claude_smart/cli.py +93 -29
  41. package/plugin/src/claude_smart/context_inject.py +3 -0
  42. package/plugin/src/claude_smart/env_config.py +56 -11
  43. package/plugin/src/claude_smart/events/post_tool.py +2 -1
  44. package/plugin/src/claude_smart/events/session_end.py +2 -1
  45. package/plugin/src/claude_smart/events/stop.py +3 -0
  46. package/plugin/src/claude_smart/events/user_prompt.py +2 -1
  47. package/plugin/src/claude_smart/internal_call.py +5 -2
  48. package/plugin/src/claude_smart/optimizer_assistant.py +59 -13
  49. package/plugin/src/claude_smart/publish.py +59 -7
  50. package/plugin/src/claude_smart/reflexio_adapter.py +137 -14
  51. package/plugin/src/claude_smart/runtime.py +15 -6
  52. package/plugin/src/claude_smart/state.py +211 -52
  53. package/plugin/uv.lock +5 -5
  54. package/plugin/vendor/reflexio/.env.example +13 -0
  55. package/plugin/vendor/reflexio/reflexio/README.md +7 -3
  56. package/plugin/vendor/reflexio/reflexio/__init__.py +12 -0
  57. package/plugin/vendor/reflexio/reflexio/cli/README.md +3 -0
  58. package/plugin/vendor/reflexio/reflexio/client/client.py +166 -9
  59. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +10 -3
  60. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +28 -0
  61. package/plugin/vendor/reflexio/reflexio/lib/_search.py +41 -0
  62. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +195 -25
  63. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +7 -1
  64. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +2 -1
  65. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +63 -4
  66. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +1 -0
  67. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +8 -1
  68. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  69. package/plugin/vendor/reflexio/reflexio/server/README.md +22 -4
  70. package/plugin/vendor/reflexio/reflexio/server/__init__.py +18 -8
  71. package/plugin/vendor/reflexio/reflexio/server/__main__.py +6 -0
  72. package/plugin/vendor/reflexio/reflexio/server/api.py +79 -5
  73. package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +263 -3
  74. package/plugin/vendor/reflexio/reflexio/server/callback_executor.py +164 -0
  75. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +81 -81
  76. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +28 -0
  77. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +63 -6
  78. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -52
  79. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +1 -0
  80. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +28 -0
  81. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +9 -1
  82. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedder_warmup.py +329 -0
  83. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +85 -10
  84. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +199 -2
  85. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +77 -9
  86. package/plugin/vendor/reflexio/reflexio/server/org_fanout.py +184 -0
  87. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +1 -1
  88. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.1.0.prompt.md +32 -0
  89. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +1 -1
  90. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.4.0.prompt.md +63 -0
  91. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +1 -1
  92. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v2.0.0.prompt.md +30 -0
  93. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_impact/v1.0.0.prompt.md +51 -0
  94. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_relevance/v1.0.0.prompt.md +39 -0
  95. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +1 -1
  96. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.1.0.prompt.md +43 -0
  97. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +3 -3
  98. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +122 -28
  99. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +63 -2
  100. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +22 -3
  101. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +64 -3
  102. package/plugin/vendor/reflexio/reflexio/server/services/README.md +6 -4
  103. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +3 -2
  104. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +41 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py +554 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +35 -1
  107. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/runner.py +253 -101
  108. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/scheduler.py +5 -7
  109. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/service.py +27 -0
  110. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +11 -5
  111. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +4 -4
  112. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +6 -2
  113. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +255 -74
  114. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +72 -0
  115. package/plugin/vendor/reflexio/reflexio/server/services/deferred_learning_plan.py +270 -0
  116. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/__init__.py +13 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/scheduler.py +142 -0
  118. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +262 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/hero_state.py +2 -11
  120. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/rule_attribution.py +6 -2
  121. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +17 -13
  122. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +18 -3
  123. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +12 -1
  124. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +1 -1
  125. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +2 -1
  126. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +1 -3
  127. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +66 -0
  128. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +640 -80
  129. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +2 -0
  130. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +179 -99
  131. package/plugin/vendor/reflexio/reflexio/server/services/lineage/vector_backfill_sweep.py +139 -0
  132. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +66 -27
  133. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_trigger.py +177 -0
  134. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -2
  135. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +360 -49
  136. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/extractor.py +27 -30
  137. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +8 -1
  138. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +137 -69
  139. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +20 -1
  140. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +13 -6
  141. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +40 -25
  142. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/consolidator.py +12 -39
  143. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +30 -35
  144. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +166 -66
  145. package/plugin/vendor/reflexio/reflexio/server/services/reflection/service.py +457 -107
  146. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/session_dedup.py +127 -0
  147. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/temporal.py +104 -0
  148. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/dispatcher.py +139 -0
  149. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +16 -6
  150. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/worker.py +137 -0
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +12 -0
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/lifecycle_filters.py +54 -0
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +41 -3
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +40 -2
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +2 -0
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +190 -2
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +11 -4
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +28 -1
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +414 -0
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -5
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +8 -3
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +4 -0
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +16 -5
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +86 -2
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +104 -42
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +7 -1
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +59 -7
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +430 -5
  169. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +8 -3
  170. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +57 -12
  171. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +197 -12
  172. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +70 -11
  173. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +17 -0
  174. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_commit_scope.py +9 -0
  175. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +9 -2
  176. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +269 -0
  177. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +7 -0
  178. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +3 -1
  179. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +4 -0
  180. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +12 -1
  181. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/evaluation_state_keys.py +78 -0
  182. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +38 -0
  183. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +171 -0
  184. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +52 -1
  185. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +82 -1
  186. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +74 -2
  187. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/retrieved_learning_state.py +226 -0
  188. package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +5 -6
  189. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +209 -29
  190. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +3 -0
  191. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -0
  192. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +33 -0
  193. package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
@@ -23,6 +23,8 @@ _DELETE_TARGET_NAME_TO_RESULT_KEY = {
23
23
  "profile": "profiles",
24
24
  "request": "requests",
25
25
  "agent_success_evaluation_result": "agent_success_evaluation_results",
26
+ "retrieved_learning_evaluation_result": "retrieved_learning_evaluation_results",
27
+ "evaluation_operation_state": "evaluation_operation_states",
26
28
  "profile_purge": "purged_profiles",
27
29
  "user_playbook_purge": "purged_user_playbooks",
28
30
  }
@@ -28,13 +28,17 @@ import time
28
28
  from collections.abc import Callable
29
29
 
30
30
  from reflexio.server.api_endpoints.request_context import RequestContext
31
- from reflexio.server.scheduling import ThreadedScheduler
31
+ from reflexio.server.env_utils import env_str
32
+ from reflexio.server.org_fanout import iterate_orgs_bounded
33
+ from reflexio.server.scheduling import LeaderGate, ThreadedScheduler
32
34
  from reflexio.server.tracing import capture_anomaly
33
35
 
34
36
  logger = logging.getLogger(__name__)
35
37
 
36
38
  _DEFAULT_POLL_INTERVAL_SECONDS = 86400
37
39
  _MIN_POLL_SECONDS = 1
40
+ _ORG_SWEEP_TIMEOUT_SECONDS = 60.0
41
+ _DEFAULT_ORG_FANOUT_WORKERS = 8
38
42
 
39
43
  # Window-misconfiguration tripwire: if a single tick deletes more than this
40
44
  # many tombstones for one org, something is likely wrong with the grace window.
@@ -73,6 +77,27 @@ def set_org_id_provider(provider: Callable[[], list[str]] | None) -> None:
73
77
  _org_id_provider_hook = provider
74
78
 
75
79
 
80
+ # Injection seam: enterprise sets the fleet leader gate here at composition
81
+ # time (spec 4.3), exactly like set_org_id_provider. None (OSS default) keeps
82
+ # the always-tick behavior.
83
+ _leader_gate_hook: LeaderGate | None = None
84
+
85
+
86
+ def set_leader_gate(gate: LeaderGate | None) -> None:
87
+ """Register the leader gate consulted by ``maybe_start_lineage_gc``.
88
+
89
+ Enterprise deployments call this at app-composition time so the scheduler
90
+ only ticks on the elected leader (``gate.should_run()`` wraps fleet leader
91
+ election, e.g. an advisory-lock gate). ``None`` (OSS default) preserves
92
+ always-tick behavior. Pass ``None`` to clear the hook (tests restore defaults).
93
+
94
+ Args:
95
+ gate (LeaderGate | None): Fleet-coordination gate, or ``None`` to clear.
96
+ """
97
+ global _leader_gate_hook # noqa: PLW0603
98
+ _leader_gate_hook = gate
99
+
100
+
76
101
  # Global sweeps run once per tick (for GLOBAL tables like invitation_codes),
77
102
  # gated on expiry_reclamation.enabled. Each fn takes `now` (unix epoch) and
78
103
  # returns a deleted-row count. Enterprise registers its sweeps here at startup;
@@ -109,8 +134,8 @@ _per_org_sweep_hooks: list[Callable[[str, int], int]] = []
109
134
  def register_per_org_sweep(fn: Callable[[str, int], int]) -> None:
110
135
  """Register a per-org reclamation sweep.
111
136
 
112
- The sweep is invoked once per org inside ``_gc_tick``'s org loop, after
113
- the Class-B block, unconditionally (not gated on ``lineage_gc`` or
137
+ The sweep is invoked once per org inside ``_sweep_org``, after the
138
+ Class-B block, unconditionally (not gated on ``lineage_gc`` or
114
139
  ``expiry_reclamation`` — the real gate lives in the enterprise closure).
115
140
 
116
141
  Note: a registered sweep that absorbs its own exceptions (returns instead
@@ -138,8 +163,11 @@ class LineageGCScheduler(ThreadedScheduler):
138
163
  request_context_factory: Callable[[str], RequestContext],
139
164
  bootstrap_org_id: str,
140
165
  org_id_provider: Callable[[], list[str]] | None = None,
166
+ leader_gate: LeaderGate | None = None,
141
167
  ) -> None:
142
- super().__init__(thread_name="reflexio-lineage-gc-scheduler")
168
+ super().__init__(
169
+ thread_name="reflexio-lineage-gc-scheduler", leader_gate=leader_gate
170
+ )
143
171
  self.request_context_factory = request_context_factory
144
172
  self.bootstrap_org_id = bootstrap_org_id
145
173
  # Optional injectable org-id source. When set (managed/multi-tenant mode),
@@ -149,6 +177,8 @@ class LineageGCScheduler(ThreadedScheduler):
149
177
  # raises NotImplementedError on Supabase. When None (OSS default),
150
178
  # discovery falls back to storage.list_org_ids() exactly as before.
151
179
  self.org_id_provider = org_id_provider
180
+ # Orgs that timed out on the PREVIOUS tick; a repeat escalates.
181
+ self._prior_timeout_orgs: set[str] = set()
152
182
 
153
183
  def _on_started(self) -> None:
154
184
  logger.info("event=lineage_gc_scheduler_started")
@@ -197,119 +227,163 @@ class LineageGCScheduler(ThreadedScheduler):
197
227
  org_ids = [bootstrap_ctx.org_id, *org_ids]
198
228
  return org_ids
199
229
 
200
- def _gc_tick(self, org_ids: list[str]) -> None:
201
- """Run one GC pass across the given org IDs.
230
+ def _sweep_org(self, org_id: str) -> None:
231
+ """Run Class A, Class B, and per-org sweeps for a single org.
202
232
 
203
- Factored out of ``_run_loop`` so tests can exercise it without threads.
233
+ Extracted from ``_gc_tick``'s per-org loop body so it can be handed to
234
+ :func:`iterate_orgs_bounded` as the per-org work item; the stop-event
235
+ check that used to guard the loop iteration now lives in that helper.
204
236
 
205
237
  Args:
206
- org_ids (list[str]): Org IDs to process in this tick.
238
+ org_id (str): The org to sweep this tick.
207
239
  """
208
- for org_id in org_ids:
209
- if self._stop_event.is_set():
210
- break
240
+ try:
241
+ ctx = self.request_context_factory(org_id)
242
+ if ctx.storage is None:
243
+ return
244
+ cfg = ctx.configurator.get_config()
245
+ except Exception:
246
+ capture_anomaly("lineage.gc.run_failed", org_id=org_id)
247
+ logger.exception("event=lineage_gc_org_failed org_id=%s", org_id)
248
+ return
249
+
250
+ # Class A: profile expiry sweep (requires PII/grace sign-off; gated on
251
+ # lineage_gc.enabled independently of Class B).
252
+ if cfg.lineage_gc.enabled:
211
253
  try:
212
- ctx = self.request_context_factory(org_id)
213
- if ctx.storage is None:
214
- continue
215
- cfg = ctx.configurator.get_config()
254
+ expired_tombstoned = ctx.storage.expire_active_profiles(
255
+ now=int(time.time())
256
+ )
257
+ if expired_tombstoned:
258
+ logger.info(
259
+ "event=expiry_sweep org_id=%s profiles_tombstoned=%d",
260
+ org_id,
261
+ expired_tombstoned,
262
+ )
263
+ if expired_tombstoned > _HIGH_VOLUME_THRESHOLD:
264
+ capture_anomaly(
265
+ "lineage.expiry_sweep.high_volume",
266
+ org_id=org_id,
267
+ count=expired_tombstoned,
268
+ )
216
269
  except Exception:
217
- capture_anomaly("lineage.gc.run_failed", org_id=org_id)
218
- logger.exception("event=lineage_gc_org_failed org_id=%s", org_id)
219
- continue
270
+ capture_anomaly("lineage.expiry_sweep.failed", org_id=org_id)
271
+ logger.exception("event=lineage_expiry_sweep_failed org_id=%s", org_id)
220
272
 
221
- # Class A: profile expiry sweep (requires PII/grace sign-off; gated on
222
- # lineage_gc.enabled independently of Class B).
223
- if cfg.lineage_gc.enabled:
224
- try:
225
- expired_tombstoned = ctx.storage.expire_active_profiles(
226
- now=int(time.time())
273
+ # Tombstone GC: each entity type is independent within the loop.
274
+ try:
275
+ older_than_epoch = (
276
+ int(time.time())
277
+ - cfg.lineage_gc.tombstone_grace_window_days * 86400
278
+ )
279
+ tombstone_deleted = 0
280
+ for entity_type in _ENTITY_TYPES:
281
+ tombstone_deleted += ctx.storage.gc_expired_tombstones(
282
+ entity_type=entity_type,
283
+ older_than_epoch=older_than_epoch,
227
284
  )
228
- if expired_tombstoned:
229
- logger.info(
230
- "event=expiry_sweep org_id=%s profiles_tombstoned=%d",
231
- org_id,
232
- expired_tombstoned,
233
- )
234
- if expired_tombstoned > _HIGH_VOLUME_THRESHOLD:
235
- capture_anomaly(
236
- "lineage.expiry_sweep.high_volume",
237
- org_id=org_id,
238
- count=expired_tombstoned,
239
- )
240
- except Exception:
241
- capture_anomaly("lineage.expiry_sweep.failed", org_id=org_id)
242
- logger.exception(
243
- "event=lineage_expiry_sweep_failed org_id=%s", org_id
285
+ if tombstone_deleted:
286
+ logger.info(
287
+ "event=lineage_gc_tick org_id=%s tombstone_deleted=%d",
288
+ org_id,
289
+ tombstone_deleted,
244
290
  )
291
+ if tombstone_deleted > _HIGH_VOLUME_THRESHOLD:
292
+ capture_anomaly(
293
+ "lineage.gc.high_volume",
294
+ org_id=org_id,
295
+ count=tombstone_deleted,
296
+ )
297
+ except Exception:
298
+ capture_anomaly("lineage.gc.tombstone_gc_failed", org_id=org_id)
299
+ logger.exception(
300
+ "event=lineage_gc_tombstone_gc_failed org_id=%s", org_id
301
+ )
245
302
 
246
- # Tombstone GC: each entity type is independent within the loop.
303
+ # Class B: direct-delete of expired plain rows (no audit/grace
304
+ # obligation; independent of lineage_gc). Each sweep is isolated so
305
+ # one failing method does not skip the rest.
306
+ if (
307
+ getattr(cfg, "expiry_reclamation", None) is not None
308
+ and cfg.expiry_reclamation.enabled
309
+ ):
310
+ now = int(time.time())
311
+ for method_name, grace, limit in _CLASS_B_SWEEPS:
312
+ method = getattr(ctx.storage, method_name, None)
313
+ if method is None:
314
+ continue
247
315
  try:
248
- older_than_epoch = (
249
- int(time.time())
250
- - cfg.lineage_gc.tombstone_grace_window_days * 86400
251
- )
252
- tombstone_deleted = 0
253
- for entity_type in _ENTITY_TYPES:
254
- tombstone_deleted += ctx.storage.gc_expired_tombstones(
255
- entity_type=entity_type,
256
- older_than_epoch=older_than_epoch,
257
- )
258
- if tombstone_deleted:
316
+ deleted = method(now=now, grace_seconds=grace, limit=limit)
317
+ if deleted:
259
318
  logger.info(
260
- "event=lineage_gc_tick org_id=%s tombstone_deleted=%d",
319
+ "event=class_b_reclaim org_id=%s method=%s deleted=%d",
261
320
  org_id,
262
- tombstone_deleted,
263
- )
264
- if tombstone_deleted > _HIGH_VOLUME_THRESHOLD:
265
- capture_anomaly(
266
- "lineage.gc.high_volume",
267
- org_id=org_id,
268
- count=tombstone_deleted,
321
+ method_name,
322
+ deleted,
269
323
  )
270
324
  except Exception:
271
- capture_anomaly("lineage.gc.tombstone_gc_failed", org_id=org_id)
325
+ capture_anomaly(
326
+ "lineage.class_b_reclaim.failed",
327
+ org_id=org_id,
328
+ method=method_name,
329
+ )
272
330
  logger.exception(
273
- "event=lineage_gc_tombstone_gc_failed org_id=%s", org_id
331
+ "event=class_b_reclaim_failed org_id=%s method=%s",
332
+ org_id,
333
+ method_name,
274
334
  )
275
335
 
276
- # Class B: direct-delete of expired plain rows (no audit/grace
277
- # obligation; independent of lineage_gc). Each sweep is isolated so
278
- # one failing method does not skip the rest.
279
- if (
280
- getattr(cfg, "expiry_reclamation", None) is not None
281
- and cfg.expiry_reclamation.enabled
282
- ):
283
- now = int(time.time())
284
- for method_name, grace, limit in _CLASS_B_SWEEPS:
285
- method = getattr(ctx.storage, method_name, None)
286
- if method is None:
287
- continue
288
- try:
289
- deleted = method(now=now, grace_seconds=grace, limit=limit)
290
- if deleted:
291
- logger.info(
292
- "event=class_b_reclaim org_id=%s method=%s deleted=%d",
293
- org_id,
294
- method_name,
295
- deleted,
296
- )
297
- except Exception:
298
- capture_anomaly(
299
- "lineage.class_b_reclaim.failed",
300
- org_id=org_id,
301
- method=method_name,
302
- )
303
- logger.exception(
304
- "event=class_b_reclaim_failed org_id=%s method=%s",
305
- org_id,
306
- method_name,
307
- )
336
+ # Per-org sweeps: invoked unconditionally once per org (the real gate
337
+ # lives in each enterprise closure). Extracted to keep _gc_tick's
338
+ # cyclomatic complexity in check and to mirror _run_global_sweeps.
339
+ self._run_per_org_sweeps(org_id)
308
340
 
309
- # Per-org sweeps: invoked unconditionally once per org (the real gate
310
- # lives in each enterprise closure). Extracted to keep _gc_tick's
311
- # cyclomatic complexity in check and to mirror _run_global_sweeps.
312
- self._run_per_org_sweeps(org_id)
341
+ def _gc_tick(self, org_ids: list[str], *, max_workers: int = 1) -> None:
342
+ """Run one GC pass across the given org IDs.
343
+
344
+ Factored out of ``_run_loop`` so tests can exercise it without threads.
345
+ Default ``max_workers=1`` is the serial path (used by existing tests);
346
+ ``_run_once`` passes the configured fan-out width.
347
+
348
+ Args:
349
+ org_ids (list[str]): Org IDs to process in this tick.
350
+ max_workers (int): Bounded fan-out width (1 = serial).
351
+ """
352
+ timed_out = iterate_orgs_bounded(
353
+ org_ids,
354
+ self._sweep_org,
355
+ max_workers=max_workers,
356
+ per_org_timeout_seconds=_ORG_SWEEP_TIMEOUT_SECONDS,
357
+ stop_event=self._stop_event,
358
+ )
359
+ for org_id in set(timed_out) & self._prior_timeout_orgs:
360
+ capture_anomaly("lineage.gc.org_sweep_timeout_repeat", org_id=org_id)
361
+ self._prior_timeout_orgs = set(timed_out)
362
+
363
+ def _org_fanout_workers(self, bootstrap_ctx: RequestContext) -> int:
364
+ """Resolve the fan-out width: SQLite pins to 1; else env (default 8).
365
+
366
+ SQLite: each worker would construct a fresh storage (connection +
367
+ migrate()) against one shared DB file — contention the serial loop
368
+ never produces — and OSS-local is usually one org anyway (spec 6.1).
369
+
370
+ Args:
371
+ bootstrap_ctx (RequestContext): This tick's bootstrap context.
372
+
373
+ Returns:
374
+ int: The pool width for this tick.
375
+ """
376
+ storage = getattr(bootstrap_ctx, "storage", None)
377
+ if storage is not None and "sqlite" in type(storage).__name__.lower():
378
+ return 1
379
+ raw = env_str(
380
+ "REFLEXIO_SCHEDULER_ORG_WORKERS", str(_DEFAULT_ORG_FANOUT_WORKERS)
381
+ )
382
+ try:
383
+ value = int(raw)
384
+ except ValueError:
385
+ return _DEFAULT_ORG_FANOUT_WORKERS
386
+ return value if value > 0 else _DEFAULT_ORG_FANOUT_WORKERS
313
387
 
314
388
  def _run_per_org_sweeps(self, org_id: str) -> None:
315
389
  """Invoke each registered per-org sweep once for this org.
@@ -378,7 +452,7 @@ class LineageGCScheduler(ThreadedScheduler):
378
452
  cfg = bootstrap_ctx.configurator.get_config()
379
453
  poll_interval = cfg.lineage_gc.poll_interval_seconds
380
454
  org_ids = self._discover_org_ids(bootstrap_ctx)
381
- self._gc_tick(org_ids)
455
+ self._gc_tick(org_ids, max_workers=self._org_fanout_workers(bootstrap_ctx))
382
456
  self._run_global_sweeps(cfg)
383
457
  except Exception:
384
458
  logger.exception("event=lineage_gc_scheduler_tick_failed")
@@ -390,6 +464,7 @@ def maybe_start_lineage_gc(
390
464
  *,
391
465
  bootstrap_org_id: str,
392
466
  org_id_provider: Callable[[], list[str]] | None = None,
467
+ leader_gate: LeaderGate | None = None,
393
468
  ) -> LineageGCScheduler | None:
394
469
  """Start the scheduler when bootstrap config enables tombstone GC or expiry reclamation.
395
470
 
@@ -429,6 +504,10 @@ def maybe_start_lineage_gc(
429
504
  (OSS default), falls back to the module-level provider hook set via
430
505
  :func:`set_org_id_provider`; when both are ``None`` discovery uses
431
506
  ``storage.list_org_ids()`` exactly as before.
507
+ leader_gate: Optional fleet-coordination gate. When ``None`` (OSS
508
+ default), falls back to the module-level hook set via
509
+ :func:`set_leader_gate`; when both are ``None`` the scheduler ticks
510
+ unconditionally (today's behavior).
432
511
 
433
512
  Returns:
434
513
  LineageGCScheduler: The started scheduler, or ``None`` if no start
@@ -495,6 +574,7 @@ def maybe_start_lineage_gc(
495
574
  org_id_provider=(
496
575
  org_id_provider if org_id_provider is not None else _org_id_provider_hook
497
576
  ),
577
+ leader_gate=leader_gate if leader_gate is not None else _leader_gate_hook,
498
578
  )
499
579
  scheduler.start()
500
580
  return scheduler
@@ -0,0 +1,139 @@
1
+ """Missing-vector backfill sweep for the shared ``LineageGCScheduler``.
2
+
3
+ When an embedding call fails or degrades, the ingest path stores an empty
4
+ embedding (and writes no vector row), returns success, and nothing ever
5
+ re-embeds the row — so that interaction is invisible to vector/hybrid search
6
+ forever. This is a latent durability hole. This module registers a bounded,
7
+ idempotent, per-org sweep that re-embeds those interactions on the shared GC
8
+ scheduler cadence.
9
+
10
+ Design:
11
+
12
+ - **Per-org**: registered via :func:`register_per_org_sweep`, so it runs once
13
+ per org per tick with the org id supplied by the scheduler (the ``(org_id,
14
+ now) -> int`` seam). The global ``register_global_sweep`` seam only passes
15
+ ``now`` and cannot drive a per-org backfill, so it is the wrong hook here.
16
+ - **Bounded**: re-embeds at most ``REFLEXIO_MISSING_VECTOR_BACKFILL_CAP``
17
+ interactions per org per tick (prior art warns unbounded backfills flood
18
+ logs / embedding cost — see ``llm/_litellm_embedding.py``).
19
+ - **Opt-in**: only registered when ``REFLEXIO_MISSING_VECTOR_BACKFILL_ENABLED``
20
+ is truthy, so a default OSS deployment is byte-for-byte unchanged (the
21
+ scheduler does not start on this hook alone). The enable flag is re-read every
22
+ tick so a live disable is honoured without a restart.
23
+ - **Fail-safe**: the storage layer catches ``EmbeddingUnavailableError`` and
24
+ returns 0 (work left for the next tick); this closure additionally captures
25
+ any unexpected error as an anomaly and returns the count so far, so one org's
26
+ failure never stalls the shared loop.
27
+ - **Idempotent**: once an interaction's vector is written it no longer matches
28
+ detection and is skipped next tick.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import logging
34
+
35
+ from reflexio.server.env_utils import env_str, env_truthy
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ ENABLE_ENV_VAR = "REFLEXIO_MISSING_VECTOR_BACKFILL_ENABLED"
40
+ CAP_ENV_VAR = "REFLEXIO_MISSING_VECTOR_BACKFILL_CAP"
41
+ _DEFAULT_CAP = 200
42
+
43
+ # Guard against duplicate registration when the app lifespan runs more than once
44
+ # in a single process (e.g. test apps).
45
+ _installed = False
46
+
47
+
48
+ def _resolve_cap() -> int:
49
+ """Return the per-org per-tick backfill cap from the environment.
50
+
51
+ Falls back to :data:`_DEFAULT_CAP` when unset, blank, or unparseable/<= 0.
52
+ """
53
+ raw = env_str(CAP_ENV_VAR, str(_DEFAULT_CAP))
54
+ try:
55
+ cap = int(raw)
56
+ except ValueError:
57
+ logger.warning(
58
+ "event=missing_vector_backfill_bad_cap value=%r — using default %d",
59
+ raw,
60
+ _DEFAULT_CAP,
61
+ )
62
+ return _DEFAULT_CAP
63
+ return cap if cap > 0 else _DEFAULT_CAP
64
+
65
+
66
+ def missing_vector_backfill_sweep(org_id: str, _now: int) -> int:
67
+ """Re-embed a bounded batch of vector-less interactions for one org.
68
+
69
+ Registered with the OSS ``register_per_org_sweep`` seam. Reads the enable
70
+ flag and cap each tick so live config changes are honoured. Absorbs its own
71
+ errors (returns instead of raising) and therefore emits its own anomaly.
72
+
73
+ Args:
74
+ org_id (str): The org to sweep.
75
+ _now (int): Current unix epoch supplied by the scheduler. Unused — the
76
+ cap, not a time cutoff, bounds the work.
77
+
78
+ Returns:
79
+ int: Number of interactions whose vector was backfilled (0 on skip/error).
80
+ """
81
+ if not env_truthy(env_str(ENABLE_ENV_VAR, "false")):
82
+ return 0
83
+
84
+ # Imported lazily so importing this module never drags in the request stack.
85
+ from reflexio.server.api_endpoints.request_context import RequestContext
86
+ from reflexio.server.tracing import capture_anomaly
87
+
88
+ try:
89
+ ctx = RequestContext(org_id=org_id)
90
+ if ctx.storage is None:
91
+ return 0
92
+ backfilled = ctx.storage.backfill_missing_interaction_vectors(_resolve_cap())
93
+ if backfilled:
94
+ logger.info(
95
+ "event=missing_vector_backfill_tick org_id=%s backfilled=%d",
96
+ org_id,
97
+ backfilled,
98
+ )
99
+ return backfilled
100
+ except Exception:
101
+ capture_anomaly(
102
+ "interactions.missing_vector_backfill.run_failed", org_id=org_id
103
+ )
104
+ logger.exception("event=missing_vector_backfill_org_failed org_id=%s", org_id)
105
+ return 0
106
+
107
+
108
+ def install_missing_vector_backfill_sweep() -> None:
109
+ """Register the backfill sweep on the OSS lineage GC scheduler when enabled.
110
+
111
+ Only registers when ``REFLEXIO_MISSING_VECTOR_BACKFILL_ENABLED`` is truthy,
112
+ so an OSS deployment that has not opted in keeps its scheduler start
113
+ conditions unchanged. Idempotent: a second call in the same process is a
114
+ no-op. Non-fatal: a registration failure logs and skips rather than aborting
115
+ app startup.
116
+
117
+ Call this before ``maybe_start_lineage_gc`` so the registered hook is
118
+ visible when the scheduler evaluates its start conditions.
119
+ """
120
+ global _installed # noqa: PLW0603
121
+ if _installed:
122
+ return
123
+ if not env_truthy(env_str(ENABLE_ENV_VAR, "false")):
124
+ return
125
+ try:
126
+ from reflexio.server.services.lineage.gc_scheduler import (
127
+ register_per_org_sweep,
128
+ )
129
+
130
+ register_per_org_sweep(missing_vector_backfill_sweep)
131
+ _installed = True
132
+ logger.info(
133
+ "event=missing_vector_backfill_sweep_registered cap=%d", _resolve_cap()
134
+ )
135
+ except Exception:
136
+ from reflexio.server.tracing import capture_anomaly
137
+
138
+ capture_anomaly("interactions.missing_vector_backfill.install_failed")
139
+ logger.exception("event=missing_vector_backfill_install_failed")