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
@@ -7,12 +7,12 @@ import os
7
7
  import threading
8
8
  import time
9
9
  import uuid
10
- from collections.abc import Callable
10
+ from collections.abc import Callable, Mapping
11
11
  from concurrent.futures import ThreadPoolExecutor
12
12
  from concurrent.futures import TimeoutError as FuturesTimeoutError
13
13
  from contextlib import nullcontext
14
14
  from dataclasses import dataclass, field
15
- from typing import TYPE_CHECKING
15
+ from typing import TYPE_CHECKING, Any
16
16
 
17
17
  from reflexio.defaults import resolve_agent_version
18
18
  from reflexio.models.api_schema.service_schemas import (
@@ -22,6 +22,7 @@ from reflexio.models.api_schema.service_schemas import (
22
22
  )
23
23
  from reflexio.models.config_schema import Config
24
24
  from reflexio.server.api_endpoints.request_context import RequestContext
25
+ from reflexio.server.env_utils import env_str, env_truthy
25
26
  from reflexio.server.llm.litellm_client import LiteLLMClient
26
27
  from reflexio.server.operation_limiter import operation_limit
27
28
  from reflexio.server.services.agent_success_evaluation.runner import (
@@ -30,6 +31,7 @@ from reflexio.server.services.agent_success_evaluation.runner import (
30
31
  from reflexio.server.services.agent_success_evaluation.scheduler import (
31
32
  GroupEvaluationScheduler,
32
33
  )
34
+ from reflexio.server.services.deferred_learning_plan import DeferredLearningPlan
33
35
  from reflexio.server.services.operation_state_utils import OperationStateManager
34
36
  from reflexio.server.services.playbook.playbook_service_utils import (
35
37
  PlaybookGenerationRequest,
@@ -47,6 +49,10 @@ from reflexio.server.services.reflection.reflection_service_utils import (
47
49
  ReflectionServiceRequest,
48
50
  )
49
51
  from reflexio.server.services.reflection.service import ReflectionService
52
+ from reflexio.server.services.shadow_comparison.worker import (
53
+ ShadowComparisonJob,
54
+ enqueue_shadow_comparison,
55
+ )
50
56
  from reflexio.server.services.storage.retention import (
51
57
  delete_count_for_retention,
52
58
  get_row_retention_limits,
@@ -56,6 +62,7 @@ from reflexio.server.tracing import sentry_tags
56
62
  from reflexio.server.usage_metrics import record_usage_event
57
63
 
58
64
  if TYPE_CHECKING:
65
+ from reflexio.server.services.deferred_learning_plan import ReflectionWritePlan
59
66
  from reflexio.server.services.unified_search_service import UnifiedSearchService
60
67
 
61
68
  logger = logging.getLogger(__name__)
@@ -65,6 +72,24 @@ CLEANUP_STALE_LOCK_SECONDS = 600
65
72
  GENERATION_SERVICE_TIMEOUT_SECONDS = 600
66
73
  _STALL_WARNING_PREFIX = "Reflexio learning is paused"
67
74
 
75
+ # ── Durable-learning same-user guard (F4) ──
76
+ # Service prefix for the per-user in-progress lock that serializes concurrent
77
+ # durable-learning jobs for one user (compute → persist across the fence).
78
+ _DURABLE_LEARNING_LOCK_SERVICE = "durable_learning"
79
+ # Stale timeout for the per-user durable lock. Deliberately set ABOVE the 300s
80
+ # durable-job claim lease (V4): a job's claim can be re-leased after 300s, so the
81
+ # lock must outlive one claim window and NOT expire in lockstep with the lease
82
+ # under compute-time throttling — 900s = 3x the lease (>= the required 2x).
83
+ _DURABLE_LOCK_STALE_SECONDS = 900
84
+ # Operation-state payload written when releasing the per-user lock (mirrors the
85
+ # clean-slate state OperationStateManager.clear_lock_if_owner writes).
86
+ _DURABLE_LOCK_CLEARED_STATE = {
87
+ "in_progress": False,
88
+ "current_request_id": None,
89
+ "pending_request_id": None,
90
+ "pending_request_queue": [],
91
+ }
92
+
68
93
 
69
94
  def _retention_cleanup_interval_seconds() -> float:
70
95
  raw = os.getenv("REFLEXIO_RETENTION_CLEANUP_INTERVAL_SECONDS", "300") or "300"
@@ -92,6 +117,22 @@ def _stable_group_sampling_fraction(
92
117
  return int.from_bytes(digest[:8], "big") / 2**64
93
118
 
94
119
 
120
+ def _org_in_durable_allowlist(org_id: str | None) -> bool:
121
+ """Whether ``org_id`` may use the durable learning queue.
122
+
123
+ Reads ``REFLEXIO_DURABLE_LEARNING_QUEUE_ORG_ALLOWLIST`` (comma-separated org
124
+ IDs). An empty/whitespace-only value means the allowlist is unset — the
125
+ default global behavior, so every org is eligible. The allowlist can only
126
+ *narrow* the durable path; it never enables it on its own (the
127
+ ``REFLEXIO_DURABLE_LEARNING_QUEUE`` flag still gates activation).
128
+ """
129
+ raw = env_str("REFLEXIO_DURABLE_LEARNING_QUEUE_ORG_ALLOWLIST", "")
130
+ allowed = {s.strip() for s in raw.split(",") if s.strip()}
131
+ if not allowed:
132
+ return True
133
+ return str(org_id) in allowed
134
+
135
+
95
136
  @dataclass
96
137
  class GenerationServiceResult:
97
138
  """Result of a GenerationService.run call.
@@ -249,35 +290,51 @@ class GenerationService:
249
290
  session_id=publish_user_interaction_request.session_id,
250
291
  evaluation_only=publish_user_interaction_request.evaluation_only,
251
292
  )
252
- self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
253
293
 
254
- # Add interactions to storage (bulk insert with batched embedding generation)
255
- self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
256
- user_id=user_id, interactions=new_interactions
294
+ # When the durable queue is enabled and this is a deferred publish, the
295
+ # request + interactions + job row are written together inside a single
296
+ # commit_scope (below). Every other path persists here unconditionally.
297
+ use_durable_queue = env_truthy(
298
+ env_str("REFLEXIO_DURABLE_LEARNING_QUEUE", "false")
299
+ )
300
+ _durable_defer = (
301
+ defer_learning
302
+ and use_durable_queue
303
+ and _org_in_durable_allowlist(self.org_id)
257
304
  )
258
305
 
306
+ if not _durable_defer:
307
+ self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
308
+ # Add interactions to storage (bulk insert with batched embedding generation)
309
+ self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
310
+ user_id=user_id, interactions=new_interactions
311
+ )
312
+
259
313
  # Extract source (empty string treated as None)
260
314
  source = publish_user_interaction_request.source or None
261
315
 
262
316
  if publish_user_interaction_request.evaluation_only:
263
- self._schedule_group_evaluation_if_needed(
317
+ if _durable_defer:
318
+ # evaluation_only returns early; ensure data lands even on durable path.
319
+ self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
320
+ self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
321
+ user_id=user_id, interactions=new_interactions
322
+ )
323
+ self._schedule_post_publish_evaluations(
264
324
  new_request=new_request,
325
+ interactions=new_interactions,
265
326
  user_id=user_id,
266
327
  agent_version=agent_version,
267
328
  source=source,
268
329
  )
269
- record_usage_event(
270
- org_id=self.org_id,
330
+ self._emit_publish_success_events(
331
+ interactions=new_interactions,
271
332
  user_id=user_id,
272
333
  request_id=request_id,
273
334
  session_id=new_request.session_id,
274
335
  source=source,
275
336
  agent_version=agent_version,
276
337
  backend="evaluation_only",
277
- event_name="publish_request_succeeded",
278
- event_category="publish",
279
- outcome="success",
280
- count_value=len(new_interactions),
281
338
  duration_ms=int((time.perf_counter() - publish_start) * 1000),
282
339
  metadata={
283
340
  "evaluation_only": True,
@@ -290,32 +347,87 @@ class GenerationService:
290
347
  not publish_user_interaction_request.override_learning_stall
291
348
  and (stall_warning := self._active_learning_stall_warning()) is not None
292
349
  ):
350
+ if _durable_defer:
351
+ # Stall skips learning but data must still land.
352
+ self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
353
+ self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
354
+ user_id=user_id, interactions=new_interactions
355
+ )
293
356
  result.warnings.append(stall_warning)
294
357
  logger.warning("%s; skipping automatic extraction", stall_warning)
295
- record_usage_event(
296
- org_id=self.org_id,
358
+ self._schedule_post_publish_evaluations(
359
+ new_request=new_request,
360
+ interactions=new_interactions,
361
+ user_id=user_id,
362
+ agent_version=agent_version,
363
+ source=source,
364
+ )
365
+ self._emit_publish_success_events(
366
+ interactions=new_interactions,
297
367
  user_id=user_id,
298
368
  request_id=request_id,
299
369
  session_id=new_request.session_id,
300
370
  source=source,
301
371
  agent_version=agent_version,
302
- event_name="publish_request_succeeded",
303
- event_category="publish",
304
- outcome="success",
305
- count_value=len(new_interactions),
306
372
  duration_ms=int((time.perf_counter() - publish_start) * 1000),
307
373
  metadata={"warning_count": len(result.warnings)},
308
374
  )
309
375
  return result
310
376
 
311
377
  if defer_learning:
378
+ if _durable_defer:
379
+ # Durable atomic path: pre-generate embeddings OUTSIDE the
380
+ # transaction (network call), then persist request + interactions
381
+ # + job in one commit_scope.
382
+ self.storage.prepare_interaction_embeddings(new_interactions) # type: ignore[reportOptionalMemberAccess]
383
+ covers_through = max(i.created_at for i in new_interactions)
384
+ with self.storage.commit_scope(): # type: ignore[reportOptionalMemberAccess]
385
+ self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
386
+ self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
387
+ user_id=user_id,
388
+ interactions=new_interactions,
389
+ embeddings_prepared=True,
390
+ )
391
+ self.storage.enqueue_learning_job( # type: ignore[reportOptionalMemberAccess]
392
+ org_id=self.org_id,
393
+ user_id=user_id,
394
+ request_id=request_id,
395
+ covers_through=covers_through,
396
+ force_extraction=publish_user_interaction_request.force_extraction,
397
+ skip_aggregation=publish_user_interaction_request.skip_aggregation,
398
+ )
399
+ self._schedule_post_publish_evaluations(
400
+ new_request=new_request,
401
+ interactions=new_interactions,
402
+ user_id=user_id,
403
+ agent_version=agent_version,
404
+ source=source,
405
+ )
406
+ self._emit_publish_success_events(
407
+ interactions=new_interactions,
408
+ user_id=user_id,
409
+ request_id=request_id,
410
+ session_id=new_request.session_id,
411
+ source=source,
412
+ agent_version=agent_version,
413
+ backend="durable",
414
+ duration_ms=int((time.perf_counter() - publish_start) * 1000),
415
+ metadata={
416
+ "defer_learning": True,
417
+ "durable_queue": True,
418
+ "warning_count": len(result.warnings),
419
+ },
420
+ )
421
+ return result
422
+
312
423
  from reflexio.server.services.publish_learning_worker import (
313
424
  PublishLearningJob,
314
425
  enqueue_publish_learning,
315
426
  )
316
427
 
317
- self._schedule_group_evaluation_if_needed(
428
+ self._schedule_post_publish_evaluations(
318
429
  new_request=new_request,
430
+ interactions=new_interactions,
319
431
  user_id=user_id,
320
432
  agent_version=agent_version,
321
433
  source=source,
@@ -332,18 +444,14 @@ class GenerationService:
332
444
  skip_aggregation=publish_user_interaction_request.skip_aggregation,
333
445
  )
334
446
  )
335
- record_usage_event(
336
- org_id=self.org_id,
447
+ self._emit_publish_success_events(
448
+ interactions=new_interactions,
337
449
  user_id=user_id,
338
450
  request_id=request_id,
339
451
  session_id=new_request.session_id,
340
452
  source=source,
341
453
  agent_version=agent_version,
342
454
  backend="classic",
343
- event_name="publish_request_succeeded",
344
- event_category="publish",
345
- outcome="success",
346
- count_value=len(new_interactions),
347
455
  duration_ms=int((time.perf_counter() - publish_start) * 1000),
348
456
  metadata={
349
457
  "defer_learning": True,
@@ -377,30 +485,26 @@ class GenerationService:
377
485
  result=result,
378
486
  )
379
487
 
380
- # Schedule delayed group evaluation for the required session.
381
- self._schedule_group_evaluation_if_needed(
382
- new_request=new_request,
383
- user_id=user_id,
384
- agent_version=agent_version,
385
- source=source,
386
- )
488
+ self._schedule_post_publish_evaluations(
489
+ new_request=new_request,
490
+ interactions=new_interactions,
491
+ user_id=user_id,
492
+ agent_version=agent_version,
493
+ source=source,
494
+ )
387
495
 
388
- record_usage_event(
389
- org_id=self.org_id,
390
- user_id=user_id,
391
- request_id=request_id,
392
- session_id=new_request.session_id,
393
- source=source,
394
- agent_version=agent_version,
395
- backend="classic",
396
- event_name="publish_request_succeeded",
397
- event_category="publish",
398
- outcome="success",
399
- count_value=len(new_interactions),
400
- duration_ms=int((time.perf_counter() - publish_start) * 1000),
401
- metadata={"warning_count": len(result.warnings)},
402
- )
403
- return result
496
+ self._emit_publish_success_events(
497
+ interactions=new_interactions,
498
+ user_id=user_id,
499
+ request_id=request_id,
500
+ session_id=new_request.session_id,
501
+ source=source,
502
+ agent_version=agent_version,
503
+ backend="classic",
504
+ duration_ms=int((time.perf_counter() - publish_start) * 1000),
505
+ metadata={"warning_count": len(result.warnings)},
506
+ )
507
+ return result
404
508
 
405
509
  except Exception as e:
406
510
  record_usage_event(
@@ -430,6 +534,47 @@ class GenerationService:
430
534
  )
431
535
  raise
432
536
 
537
+ def _emit_publish_success_events(
538
+ self,
539
+ *,
540
+ interactions: list[Interaction],
541
+ user_id: str,
542
+ request_id: str,
543
+ session_id: str,
544
+ source: str | None,
545
+ agent_version: str,
546
+ duration_ms: int,
547
+ metadata: Mapping[str, Any],
548
+ backend: str | None = None,
549
+ ) -> None:
550
+ """Emit one ``publish_request_succeeded`` event per interaction.
551
+
552
+ Replaces the old single aggregate event (``count_value=len(interactions)``)
553
+ with one entity-backed event per interaction (``count_value=1``,
554
+ ``event_key=f"pub:{interaction_id}"``, ``entity_id=interaction_id``) so
555
+ downstream dedup can key on the interaction id. The summed
556
+ ``count_value`` across the emitted events equals the old aggregate
557
+ count, so totals are unchanged.
558
+ """
559
+ for interaction in interactions:
560
+ record_usage_event(
561
+ org_id=self.org_id,
562
+ user_id=user_id,
563
+ request_id=request_id,
564
+ session_id=session_id,
565
+ source=source,
566
+ agent_version=agent_version,
567
+ backend=backend,
568
+ event_name="publish_request_succeeded",
569
+ event_category="publish",
570
+ outcome="success",
571
+ count_value=1,
572
+ event_key=f"pub:{interaction.interaction_id}",
573
+ entity_id=str(interaction.interaction_id),
574
+ duration_ms=duration_ms,
575
+ metadata=metadata,
576
+ )
577
+
433
578
  # ===============================
434
579
  # deferred learning
435
580
  # ===============================
@@ -445,6 +590,13 @@ class GenerationService:
445
590
  force_extraction: bool,
446
591
  skip_aggregation: bool,
447
592
  ) -> GenerationServiceResult:
593
+ """Run the learning steps for a deferred job (synchronous / non-durable
594
+ callers: ``PublishLearningWorker`` and manual reruns).
595
+
596
+ Runs compute + persist + side-effects together, with profile and playbook
597
+ generation in parallel — no external ``commit_scope`` is held on this
598
+ path (the durable worker uses the compute/persist/emit split instead).
599
+ """
448
600
  result = GenerationServiceResult(request_id=request_id)
449
601
  self._run_learning_steps(
450
602
  user_id=user_id,
@@ -454,49 +606,97 @@ class GenerationService:
454
606
  skip_aggregation=skip_aggregation,
455
607
  source=source,
456
608
  result=result,
609
+ parallel=True,
457
610
  )
458
611
  return result
459
612
 
460
613
  # ===============================
461
- # private methods
614
+ # deferred learning — compute / persist / emit split (gate b)
462
615
  # ===============================
463
616
 
464
- def _run_learning_steps(
617
+ def compute_deferred_learning(
465
618
  self,
466
619
  *,
467
620
  user_id: str,
468
621
  request_id: str,
622
+ session_id: str | None, # noqa: ARG002 - kept for worker call symmetry
623
+ source: str | None,
469
624
  agent_version: str,
470
625
  force_extraction: bool,
471
626
  skip_aggregation: bool,
472
- source: str | None,
473
- result: GenerationServiceResult,
474
- ) -> None:
475
- # Reflection runs as its own sliding-window step BEFORE the extractor
476
- # pool spins up, so replacements are visible to extractors.
477
- self._maybe_run_reflection(
627
+ ) -> DeferredLearningPlan:
628
+ """Compute half of one durable-learning job — NO ``commit_scope`` held.
629
+
630
+ Runs the same-user guard (F4) first, then reflection + profile + playbook
631
+ compute (LLM extraction, dedup, embeddings) entirely OUTSIDE any writer
632
+ transaction, and assembles a :class:`DeferredLearningPlan` of the held
633
+ ``(service, plan)`` pairs. Issues NO learning DB write (F3): the only
634
+ writes are the extractor's own ``agent_run`` rows + the per-user
635
+ coordination lock (``try_acquire_in_progress_lock``, not a learning
636
+ terminal).
637
+
638
+ F4 GUARD FIRST: acquires a DB-backed per-user in-progress lock BEFORE any
639
+ LLM work. On contention returns ``DeferredLearningPlan(lock_acquired=
640
+ False, reflection/profile/playbook=None)`` immediately (no compute) — the
641
+ worker must then leave the job reclaimable (Task 9), NOT complete it.
642
+
643
+ Reflection runs first on the calling thread (it reads the pre-persist
644
+ snapshot); profile + playbook ``compute_generation`` then run in parallel
645
+ (no ``commit_scope`` is held, so the old ``sequential=True`` RLock
646
+ avoidance is unnecessary). Per-half failures are best-effort — captured
647
+ into ``warnings`` and the half dropped — mirroring
648
+ ``_run_learning_steps``.
649
+ """
650
+ warnings: list[str] = []
651
+
652
+ # F4 GUARD — before any LLM. On contention, no compute runs.
653
+ if not self._acquire_durable_learning_lock(
654
+ user_id=user_id, request_id=request_id
655
+ ):
656
+ logger.info(
657
+ "durable-learning same-user lock held; leaving job reclaimable "
658
+ "(user_id=%s request_id=%s)",
659
+ user_id,
660
+ request_id,
661
+ )
662
+ return DeferredLearningPlan(
663
+ request_id=request_id,
664
+ user_id=user_id,
665
+ agent_version=agent_version,
666
+ lock_acquired=False,
667
+ reflection=None,
668
+ profile=None,
669
+ playbook=None,
670
+ warnings=warnings,
671
+ )
672
+
673
+ # Reflection first (calling thread) — one pre-persist read snapshot.
674
+ reflection_pair = self._compute_reflection(
478
675
  user_id=user_id,
479
676
  request_id=request_id,
480
677
  agent_version=agent_version,
481
678
  source=source,
679
+ warnings=warnings,
482
680
  )
483
681
 
484
- profile_generation_service = ProfileGenerationService(
682
+ # Profile + playbook compute in parallel — no scope held, so no RLock
683
+ # contention (the pre-split sequential mode only existed to avoid the
684
+ # SQLite commit_scope RLock, which compute never takes).
685
+ profile_service = ProfileGenerationService(
485
686
  llm_client=self.client, request_context=self.request_context
486
687
  )
487
- profile_generation_request = ProfileGenerationRequest(
688
+ profile_request = ProfileGenerationRequest(
488
689
  user_id=user_id,
489
690
  request_id=request_id,
490
691
  source=source,
491
692
  force_extraction=force_extraction,
492
693
  )
493
-
494
- playbook_generation_service = PlaybookGenerationService(
694
+ playbook_service = PlaybookGenerationService(
495
695
  llm_client=self.client,
496
696
  request_context=self.request_context,
497
697
  skip_aggregation=skip_aggregation,
498
698
  )
499
- playbook_generation_request = PlaybookGenerationRequest(
699
+ playbook_request = PlaybookGenerationRequest(
500
700
  request_id=request_id,
501
701
  agent_version=agent_version,
502
702
  user_id=user_id,
@@ -504,25 +704,26 @@ class GenerationService:
504
704
  force_extraction=force_extraction,
505
705
  )
506
706
 
707
+ profile_pair: tuple = None # type: ignore[assignment]
708
+ playbook_pair: tuple = None # type: ignore[assignment]
507
709
  executor = ThreadPoolExecutor(max_workers=2)
508
710
  try:
509
- futures = [
510
- executor.submit(
511
- contextvars.copy_context().run,
512
- profile_generation_service.run,
513
- profile_generation_request,
514
- ),
515
- executor.submit(
516
- contextvars.copy_context().run,
517
- playbook_generation_service.run,
518
- playbook_generation_request,
519
- ),
520
- ]
521
-
522
- service_names = ["profile_generation", "playbook_generation"]
523
- for future, service_name in zip(futures, service_names, strict=True):
711
+ profile_future = executor.submit(
712
+ contextvars.copy_context().run,
713
+ profile_service.compute_generation,
714
+ profile_request,
715
+ )
716
+ playbook_future = executor.submit(
717
+ contextvars.copy_context().run,
718
+ playbook_service.compute_generation,
719
+ playbook_request,
720
+ )
721
+ for future, service_name, service in (
722
+ (profile_future, "profile_generation", profile_service),
723
+ (playbook_future, "playbook_generation", playbook_service),
724
+ ):
524
725
  try:
525
- future.result(timeout=GENERATION_SERVICE_TIMEOUT_SECONDS)
726
+ plan = future.result(timeout=GENERATION_SERVICE_TIMEOUT_SECONDS)
526
727
  except FuturesTimeoutError: # noqa: PERF203
527
728
  msg = (
528
729
  f"{service_name} timed out after "
@@ -535,7 +736,8 @@ class GenerationService:
535
736
  error_type="timeout",
536
737
  ):
537
738
  logger.error("%s for request %s", msg, request_id)
538
- result.warnings.append(msg)
739
+ warnings.append(msg)
740
+ continue
539
741
  except Exception as e:
540
742
  msg = f"{service_name} failed: {e}"
541
743
  with sentry_tags(
@@ -548,10 +750,334 @@ class GenerationService:
548
750
  "Generation service failed for request %s",
549
751
  request_id,
550
752
  )
551
- result.warnings.append(msg)
753
+ warnings.append(msg)
754
+ continue
755
+ if plan is None:
756
+ continue
757
+ if service_name == "profile_generation":
758
+ profile_pair = (service, plan)
759
+ else:
760
+ playbook_pair = (service, plan)
552
761
  finally:
553
762
  executor.shutdown(wait=False, cancel_futures=True)
554
763
 
764
+ return DeferredLearningPlan(
765
+ request_id=request_id,
766
+ user_id=user_id,
767
+ agent_version=agent_version,
768
+ lock_acquired=True,
769
+ reflection=reflection_pair,
770
+ profile=profile_pair,
771
+ playbook=playbook_pair,
772
+ warnings=warnings,
773
+ )
774
+
775
+ def persist_deferred_learning(self, plan: DeferredLearningPlan) -> None:
776
+ """Persist half — apply the fence-critical writes for a computed job.
777
+
778
+ This is the ONLY part the durable worker runs inside its fenced
779
+ ``commit_scope``. For each present half it calls the held instance's
780
+ persist (reflection apply loop + bookmark; profile/playbook row writes +
781
+ extractor bookmark advance). Issues NO side-effects (telemetry, billing,
782
+ tagging, off-thread schedulers, lock release) — those are post-commit
783
+ (``emit_deferred_learning_side_effects``) so a fence-lost job never fires
784
+ them.
785
+ """
786
+ if plan.reflection is not None:
787
+ reflection_service, reflection_plan = plan.reflection
788
+ reflection_service.persist(reflection_plan)
789
+ if plan.profile is not None:
790
+ profile_service, profile_plan = plan.profile
791
+ profile_service.persist_generation(profile_plan)
792
+ if plan.playbook is not None:
793
+ playbook_service, playbook_plan = plan.playbook
794
+ playbook_service.persist_generation(playbook_plan)
795
+
796
+ def emit_deferred_learning_side_effects(self, plan: DeferredLearningPlan) -> None:
797
+ """Post-commit side-effects — telemetry, billing, tagging, lock release.
798
+
799
+ Runs only for a fence-winning job (the durable worker calls it after the
800
+ scope commits). Fires each held half's post-commit emit + schedules the
801
+ deferred tagging pass, then ALWAYS releases the per-user F4 lock (even if
802
+ an emit raised) so a completed job never strands the lock.
803
+
804
+ A ``lock_acquired=False`` plan never reaches here (the worker skips emit
805
+ on contention), so releasing here is safe — this instance owns the lock.
806
+
807
+ Each emit half is isolated in its own try/except (mirroring how
808
+ ``schedule_tagging`` already self-guards): the halves are independent
809
+ post-commit side effects (billing / telemetry / off-thread schedulers),
810
+ so a failure in an early half must NOT starve the later halves. The
811
+ per-user lock release always runs in ``finally`` so a completed job never
812
+ strands the lock.
813
+ """
814
+ try:
815
+ if plan.reflection is not None:
816
+ reflection_service, reflection_plan = plan.reflection
817
+ try:
818
+ reflection_service.emit_side_effects(reflection_plan)
819
+ except Exception:
820
+ logger.exception(
821
+ "Failed to emit reflection side effects for deferred "
822
+ "learning request %s",
823
+ plan.request_id,
824
+ )
825
+ if plan.profile is not None:
826
+ profile_service, profile_plan = plan.profile
827
+ try:
828
+ profile_service.emit_generation_side_effects(profile_plan)
829
+ except Exception:
830
+ logger.exception(
831
+ "Failed to emit profile side effects for deferred "
832
+ "learning request %s",
833
+ plan.request_id,
834
+ )
835
+ if plan.playbook is not None:
836
+ playbook_service, playbook_plan = plan.playbook
837
+ try:
838
+ playbook_service.emit_generation_side_effects(playbook_plan)
839
+ except Exception:
840
+ logger.exception(
841
+ "Failed to emit playbook side effects for deferred "
842
+ "learning request %s",
843
+ plan.request_id,
844
+ )
845
+ try:
846
+ schedule_tagging(
847
+ org_id=self.org_id,
848
+ user_id=plan.user_id,
849
+ agent_version=plan.agent_version,
850
+ request_context=self.request_context,
851
+ llm_client=self.client,
852
+ )
853
+ except Exception:
854
+ logger.exception(
855
+ "Failed to schedule tagging for deferred learning request %s",
856
+ plan.request_id,
857
+ )
858
+ finally:
859
+ self._release_durable_learning_lock(
860
+ user_id=plan.user_id, request_id=plan.request_id
861
+ )
862
+
863
+ # ===============================
864
+ # private methods
865
+ # ===============================
866
+
867
+ def _durable_learning_lock_key(self, user_id: str) -> str:
868
+ """Per-user durable-learning in-progress lock key.
869
+
870
+ Mirrors ``OperationStateManager._lock_key`` shape
871
+ (``{service}::{org}::{scope}::lock``) but on a DEDICATED
872
+ ``durable_learning`` service prefix, distinct from the
873
+ profile/playbook generation locks and from any extractor bookmark row.
874
+ """
875
+ return f"{_DURABLE_LEARNING_LOCK_SERVICE}::{self.org_id}::{user_id}::lock"
876
+
877
+ def _acquire_durable_learning_lock(self, *, user_id: str, request_id: str) -> bool:
878
+ """Try to acquire the per-user durable-learning lock (F4).
879
+
880
+ Uses the atomic DB-backed ``try_acquire_in_progress_lock`` (a distinct
881
+ storage method, NOT one of the learning-write terminals the compute
882
+ purity contract forbids), so the guard does not trip the compute
883
+ write-tripwire. Returns ``True`` when acquired (proceed with compute),
884
+ ``False`` on contention (another same-user durable job holds it).
885
+ """
886
+ if self.storage is None:
887
+ return True
888
+ result = self.storage.try_acquire_in_progress_lock(
889
+ self._durable_learning_lock_key(user_id),
890
+ request_id,
891
+ stale_lock_seconds=_DURABLE_LOCK_STALE_SECONDS,
892
+ )
893
+ return bool(result.get("acquired", False))
894
+
895
+ def _release_durable_learning_lock(self, *, user_id: str, request_id: str) -> None:
896
+ """Release the per-user durable-learning lock iff we still own it.
897
+
898
+ Uses ``clear_in_progress_lock_if_owner`` (CAS on the holder) so a job
899
+ whose lock was already stolen after a stale-timeout does not clobber the
900
+ new holder's lock.
901
+ """
902
+ if self.storage is None:
903
+ return
904
+ self.storage.clear_in_progress_lock_if_owner(
905
+ self._durable_learning_lock_key(user_id),
906
+ request_id,
907
+ dict(_DURABLE_LOCK_CLEARED_STATE),
908
+ )
909
+
910
+ def _compute_reflection(
911
+ self,
912
+ *,
913
+ user_id: str,
914
+ request_id: str,
915
+ agent_version: str,
916
+ source: str | None,
917
+ warnings: list[str],
918
+ ) -> tuple[ReflectionService, ReflectionWritePlan] | None:
919
+ """Run reflection ``compute`` best-effort; return its held pair or None.
920
+
921
+ Mirrors ``_maybe_run_reflection`` (a reflection failure must never break
922
+ the publish) but returns the ``(service, plan)`` pair so the caller can
923
+ defer ``persist`` / ``emit_side_effects`` across the worker fence.
924
+ Returns ``None`` when reflection has nothing to persist (gate closed /
925
+ disabled / storage None) or a compute error (logged + appended to
926
+ ``warnings``).
927
+ """
928
+ try:
929
+ service = ReflectionService(
930
+ request_context=self.request_context,
931
+ llm_client=self.client,
932
+ )
933
+ reflection_plan = service.compute(
934
+ ReflectionServiceRequest(
935
+ user_id=user_id,
936
+ request_id=request_id,
937
+ agent_version=agent_version,
938
+ source=source,
939
+ )
940
+ )
941
+ if reflection_plan is None:
942
+ return None
943
+ return (service, reflection_plan)
944
+ except Exception as exc: # noqa: BLE001 — reflection must not break publish
945
+ with sentry_tags(
946
+ subsystem="generation",
947
+ op="reflection",
948
+ org_id=self.org_id,
949
+ user_id=user_id,
950
+ error_type=type(exc).__name__,
951
+ ):
952
+ logger.exception("reflection compute failed for user %s", user_id)
953
+ warnings.append(f"reflection failed: {exc}")
954
+ return None
955
+
956
+ def _run_learning_steps(
957
+ self,
958
+ *,
959
+ user_id: str,
960
+ request_id: str,
961
+ agent_version: str,
962
+ force_extraction: bool,
963
+ skip_aggregation: bool,
964
+ source: str | None,
965
+ result: GenerationServiceResult,
966
+ parallel: bool = True,
967
+ ) -> None:
968
+ # Reflection runs as its own sliding-window step BEFORE the extractor
969
+ # pool spins up, so replacements are visible to extractors.
970
+ self._maybe_run_reflection(
971
+ user_id=user_id,
972
+ request_id=request_id,
973
+ agent_version=agent_version,
974
+ source=source,
975
+ )
976
+
977
+ profile_generation_service = ProfileGenerationService(
978
+ llm_client=self.client, request_context=self.request_context
979
+ )
980
+ source_request_id = request_id
981
+ profile_generation_request = ProfileGenerationRequest(
982
+ user_id=user_id,
983
+ request_id=source_request_id,
984
+ source=source,
985
+ force_extraction=force_extraction,
986
+ )
987
+
988
+ playbook_generation_service = PlaybookGenerationService(
989
+ llm_client=self.client,
990
+ request_context=self.request_context,
991
+ skip_aggregation=skip_aggregation,
992
+ )
993
+ playbook_generation_request = PlaybookGenerationRequest(
994
+ request_id=source_request_id,
995
+ agent_version=agent_version,
996
+ user_id=user_id,
997
+ source=source,
998
+ force_extraction=force_extraction,
999
+ )
1000
+
1001
+ if not parallel:
1002
+ # Sequential mode: run both services on the calling thread.
1003
+ # Required when the caller holds a commit_scope lock — spawning
1004
+ # child threads inside that scope would deadlock the SQLite RLock.
1005
+ for svc_name, _run in (
1006
+ (
1007
+ "profile_generation",
1008
+ lambda: profile_generation_service.run(profile_generation_request),
1009
+ ),
1010
+ (
1011
+ "playbook_generation",
1012
+ lambda: playbook_generation_service.run(
1013
+ playbook_generation_request
1014
+ ),
1015
+ ),
1016
+ ):
1017
+ try:
1018
+ _run()
1019
+ except Exception as e:
1020
+ msg = f"{svc_name} failed: {e}"
1021
+ with sentry_tags(
1022
+ subsystem="generation",
1023
+ service=svc_name,
1024
+ request_id=request_id,
1025
+ error_type=type(e).__name__,
1026
+ ):
1027
+ logger.exception(
1028
+ "Generation service failed for request %s",
1029
+ request_id,
1030
+ )
1031
+ result.warnings.append(msg)
1032
+ else:
1033
+ executor = ThreadPoolExecutor(max_workers=2)
1034
+ try:
1035
+ futures = [
1036
+ executor.submit(
1037
+ contextvars.copy_context().run,
1038
+ profile_generation_service.run,
1039
+ profile_generation_request,
1040
+ ),
1041
+ executor.submit(
1042
+ contextvars.copy_context().run,
1043
+ playbook_generation_service.run,
1044
+ playbook_generation_request,
1045
+ ),
1046
+ ]
1047
+
1048
+ service_names = ["profile_generation", "playbook_generation"]
1049
+ for future, service_name in zip(futures, service_names, strict=True):
1050
+ try:
1051
+ future.result(timeout=GENERATION_SERVICE_TIMEOUT_SECONDS)
1052
+ except FuturesTimeoutError: # noqa: PERF203
1053
+ msg = (
1054
+ f"{service_name} timed out after "
1055
+ f"{GENERATION_SERVICE_TIMEOUT_SECONDS}s"
1056
+ )
1057
+ with sentry_tags(
1058
+ subsystem="generation",
1059
+ service=service_name,
1060
+ request_id=request_id,
1061
+ error_type="timeout",
1062
+ ):
1063
+ logger.error("%s for request %s", msg, request_id)
1064
+ result.warnings.append(msg)
1065
+ except Exception as e:
1066
+ msg = f"{service_name} failed: {e}"
1067
+ with sentry_tags(
1068
+ subsystem="generation",
1069
+ service=service_name,
1070
+ request_id=request_id,
1071
+ error_type=type(e).__name__,
1072
+ ):
1073
+ logger.exception(
1074
+ "Generation service failed for request %s",
1075
+ request_id,
1076
+ )
1077
+ result.warnings.append(msg)
1078
+ finally:
1079
+ executor.shutdown(wait=False, cancel_futures=True)
1080
+
555
1081
  try:
556
1082
  schedule_tagging(
557
1083
  org_id=self.org_id,
@@ -566,6 +1092,39 @@ class GenerationService:
566
1092
  request_id,
567
1093
  )
568
1094
 
1095
+ def _schedule_post_publish_evaluations(
1096
+ self,
1097
+ *,
1098
+ new_request: Request,
1099
+ interactions: list[Interaction],
1100
+ user_id: str,
1101
+ agent_version: str,
1102
+ source: str | None,
1103
+ ) -> None:
1104
+ """Schedule all best-effort evaluation work after publish persistence."""
1105
+ if any(interaction.shadow_content for interaction in interactions):
1106
+ try:
1107
+ enqueue_shadow_comparison(
1108
+ ShadowComparisonJob(
1109
+ org_id=self.org_id,
1110
+ interactions=interactions,
1111
+ session_id=new_request.session_id,
1112
+ agent_version=agent_version,
1113
+ )
1114
+ )
1115
+ except Exception:
1116
+ logger.exception(
1117
+ "Failed to enqueue shadow comparison for session %s",
1118
+ new_request.session_id,
1119
+ )
1120
+
1121
+ self._schedule_group_evaluation_if_needed(
1122
+ new_request=new_request,
1123
+ user_id=user_id,
1124
+ agent_version=agent_version,
1125
+ source=source,
1126
+ )
1127
+
569
1128
  def _schedule_group_evaluation_if_needed(
570
1129
  self,
571
1130
  *,
@@ -845,6 +1404,7 @@ class GenerationService:
845
1404
  expert_content=interaction_data.expert_content,
846
1405
  tools_used=interaction_data.tools_used,
847
1406
  citations=interaction_data.citations,
1407
+ retrieved_learnings=interaction_data.retrieved_learnings,
848
1408
  )
849
1409
  for interaction_data in interaction_data_list
850
1410
  ]