claude-smart 0.2.49 → 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 (177) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.md +10 -43
  3. package/bin/claude-smart.js +105 -0
  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/pyproject.toml +1 -1
  27. package/plugin/scripts/_lib.sh +197 -1
  28. package/plugin/scripts/backend-python-runner.py +46 -0
  29. package/plugin/scripts/backend-service.sh +757 -119
  30. package/plugin/scripts/codex-hook.js +63 -225
  31. package/plugin/scripts/dashboard-open.sh +6 -4
  32. package/plugin/scripts/dashboard-service.sh +117 -136
  33. package/plugin/scripts/hook_entry.sh +3 -0
  34. package/plugin/scripts/smart-install.sh +15 -1
  35. package/plugin/src/claude_smart/cli.py +14 -0
  36. package/plugin/src/claude_smart/context_inject.py +3 -0
  37. package/plugin/src/claude_smart/env_config.py +4 -1
  38. package/plugin/src/claude_smart/events/post_tool.py +2 -1
  39. package/plugin/src/claude_smart/events/session_end.py +2 -1
  40. package/plugin/src/claude_smart/events/stop.py +3 -0
  41. package/plugin/src/claude_smart/events/user_prompt.py +2 -1
  42. package/plugin/src/claude_smart/internal_call.py +5 -2
  43. package/plugin/src/claude_smart/optimizer_assistant.py +59 -13
  44. package/plugin/src/claude_smart/publish.py +59 -7
  45. package/plugin/src/claude_smart/reflexio_adapter.py +137 -14
  46. package/plugin/src/claude_smart/runtime.py +15 -6
  47. package/plugin/src/claude_smart/state.py +211 -52
  48. package/plugin/uv.lock +1 -1
  49. package/plugin/vendor/reflexio/.env.example +13 -0
  50. package/plugin/vendor/reflexio/reflexio/README.md +7 -3
  51. package/plugin/vendor/reflexio/reflexio/__init__.py +12 -0
  52. package/plugin/vendor/reflexio/reflexio/client/client.py +126 -3
  53. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +10 -3
  54. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +28 -0
  55. package/plugin/vendor/reflexio/reflexio/lib/_search.py +41 -0
  56. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +177 -25
  57. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +7 -1
  58. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +2 -1
  59. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +63 -4
  60. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +1 -0
  61. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +8 -1
  62. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  63. package/plugin/vendor/reflexio/reflexio/server/README.md +22 -4
  64. package/plugin/vendor/reflexio/reflexio/server/__init__.py +18 -8
  65. package/plugin/vendor/reflexio/reflexio/server/__main__.py +6 -0
  66. package/plugin/vendor/reflexio/reflexio/server/api.py +66 -3
  67. package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +263 -3
  68. package/plugin/vendor/reflexio/reflexio/server/callback_executor.py +164 -0
  69. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +81 -81
  70. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +28 -0
  71. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +62 -5
  72. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -52
  73. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +28 -0
  74. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +9 -1
  75. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedder_warmup.py +329 -0
  76. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +85 -10
  77. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +20 -5
  78. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +77 -9
  79. package/plugin/vendor/reflexio/reflexio/server/org_fanout.py +184 -0
  80. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +1 -1
  81. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.1.0.prompt.md +32 -0
  82. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +1 -1
  83. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.4.0.prompt.md +63 -0
  84. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +1 -1
  85. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v2.0.0.prompt.md +30 -0
  86. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_impact/v1.0.0.prompt.md +51 -0
  87. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_relevance/v1.0.0.prompt.md +39 -0
  88. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +1 -1
  89. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.1.0.prompt.md +43 -0
  90. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +3 -3
  91. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +122 -28
  92. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +22 -3
  93. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +64 -3
  94. package/plugin/vendor/reflexio/reflexio/server/services/README.md +6 -4
  95. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +3 -2
  96. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +41 -0
  97. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py +554 -0
  98. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +35 -1
  99. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/runner.py +253 -101
  100. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/scheduler.py +5 -7
  101. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/service.py +27 -0
  102. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +11 -5
  103. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +4 -4
  104. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +6 -2
  105. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +255 -74
  106. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +72 -0
  107. package/plugin/vendor/reflexio/reflexio/server/services/deferred_learning_plan.py +270 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +85 -16
  109. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/hero_state.py +2 -11
  110. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/rule_attribution.py +6 -2
  111. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +17 -13
  112. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +18 -3
  113. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +12 -1
  114. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +1 -1
  115. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +2 -1
  116. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +66 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +510 -63
  118. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +2 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +179 -99
  120. package/plugin/vendor/reflexio/reflexio/server/services/lineage/vector_backfill_sweep.py +139 -0
  121. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +66 -27
  122. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_trigger.py +177 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -2
  124. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +360 -49
  125. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/extractor.py +27 -30
  126. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +8 -1
  127. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +137 -69
  128. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +20 -1
  129. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +13 -6
  130. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +40 -25
  131. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/consolidator.py +12 -39
  132. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +30 -35
  133. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +122 -44
  134. package/plugin/vendor/reflexio/reflexio/server/services/reflection/service.py +457 -107
  135. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/session_dedup.py +127 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/temporal.py +104 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/dispatcher.py +139 -0
  138. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +16 -6
  139. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/worker.py +137 -0
  140. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +12 -0
  141. package/plugin/vendor/reflexio/reflexio/server/services/storage/lifecycle_filters.py +54 -0
  142. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +41 -3
  143. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +40 -2
  144. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +1 -1
  145. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +50 -0
  146. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +11 -4
  147. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +28 -1
  148. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +36 -1
  149. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +4 -0
  150. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +16 -5
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +104 -42
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +7 -1
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +43 -0
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +430 -5
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +45 -7
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +146 -6
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +42 -7
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +1 -1
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +9 -2
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +50 -0
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +7 -0
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +3 -1
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +4 -0
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +12 -1
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/evaluation_state_keys.py +78 -0
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +38 -0
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +171 -0
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +52 -1
  169. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +59 -0
  170. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +52 -2
  171. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/retrieved_learning_state.py +226 -0
  172. package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +5 -6
  173. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +209 -29
  174. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +3 -0
  175. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -0
  176. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +33 -0
  177. 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 (
@@ -31,6 +31,7 @@ from reflexio.server.services.agent_success_evaluation.runner import (
31
31
  from reflexio.server.services.agent_success_evaluation.scheduler import (
32
32
  GroupEvaluationScheduler,
33
33
  )
34
+ from reflexio.server.services.deferred_learning_plan import DeferredLearningPlan
34
35
  from reflexio.server.services.operation_state_utils import OperationStateManager
35
36
  from reflexio.server.services.playbook.playbook_service_utils import (
36
37
  PlaybookGenerationRequest,
@@ -48,6 +49,10 @@ from reflexio.server.services.reflection.reflection_service_utils import (
48
49
  ReflectionServiceRequest,
49
50
  )
50
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
+ )
51
56
  from reflexio.server.services.storage.retention import (
52
57
  delete_count_for_retention,
53
58
  get_row_retention_limits,
@@ -57,6 +62,7 @@ from reflexio.server.tracing import sentry_tags
57
62
  from reflexio.server.usage_metrics import record_usage_event
58
63
 
59
64
  if TYPE_CHECKING:
65
+ from reflexio.server.services.deferred_learning_plan import ReflectionWritePlan
60
66
  from reflexio.server.services.unified_search_service import UnifiedSearchService
61
67
 
62
68
  logger = logging.getLogger(__name__)
@@ -66,6 +72,24 @@ CLEANUP_STALE_LOCK_SECONDS = 600
66
72
  GENERATION_SERVICE_TIMEOUT_SECONDS = 600
67
73
  _STALL_WARNING_PREFIX = "Reflexio learning is paused"
68
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
+
69
93
 
70
94
  def _retention_cleanup_interval_seconds() -> float:
71
95
  raw = os.getenv("REFLEXIO_RETENTION_CLEANUP_INTERVAL_SECONDS", "300") or "300"
@@ -93,6 +117,22 @@ def _stable_group_sampling_fraction(
93
117
  return int.from_bytes(digest[:8], "big") / 2**64
94
118
 
95
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
+
96
136
  @dataclass
97
137
  class GenerationServiceResult:
98
138
  """Result of a GenerationService.run call.
@@ -257,7 +297,11 @@ class GenerationService:
257
297
  use_durable_queue = env_truthy(
258
298
  env_str("REFLEXIO_DURABLE_LEARNING_QUEUE", "false")
259
299
  )
260
- _durable_defer = defer_learning and use_durable_queue
300
+ _durable_defer = (
301
+ defer_learning
302
+ and use_durable_queue
303
+ and _org_in_durable_allowlist(self.org_id)
304
+ )
261
305
 
262
306
  if not _durable_defer:
263
307
  self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
@@ -276,24 +320,21 @@ class GenerationService:
276
320
  self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
277
321
  user_id=user_id, interactions=new_interactions
278
322
  )
279
- self._schedule_group_evaluation_if_needed(
323
+ self._schedule_post_publish_evaluations(
280
324
  new_request=new_request,
325
+ interactions=new_interactions,
281
326
  user_id=user_id,
282
327
  agent_version=agent_version,
283
328
  source=source,
284
329
  )
285
- record_usage_event(
286
- org_id=self.org_id,
330
+ self._emit_publish_success_events(
331
+ interactions=new_interactions,
287
332
  user_id=user_id,
288
333
  request_id=request_id,
289
334
  session_id=new_request.session_id,
290
335
  source=source,
291
336
  agent_version=agent_version,
292
337
  backend="evaluation_only",
293
- event_name="publish_request_succeeded",
294
- event_category="publish",
295
- outcome="success",
296
- count_value=len(new_interactions),
297
338
  duration_ms=int((time.perf_counter() - publish_start) * 1000),
298
339
  metadata={
299
340
  "evaluation_only": True,
@@ -314,17 +355,20 @@ class GenerationService:
314
355
  )
315
356
  result.warnings.append(stall_warning)
316
357
  logger.warning("%s; skipping automatic extraction", stall_warning)
317
- record_usage_event(
318
- 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,
319
367
  user_id=user_id,
320
368
  request_id=request_id,
321
369
  session_id=new_request.session_id,
322
370
  source=source,
323
371
  agent_version=agent_version,
324
- event_name="publish_request_succeeded",
325
- event_category="publish",
326
- outcome="success",
327
- count_value=len(new_interactions),
328
372
  duration_ms=int((time.perf_counter() - publish_start) * 1000),
329
373
  metadata={"warning_count": len(result.warnings)},
330
374
  )
@@ -352,24 +396,21 @@ class GenerationService:
352
396
  force_extraction=publish_user_interaction_request.force_extraction,
353
397
  skip_aggregation=publish_user_interaction_request.skip_aggregation,
354
398
  )
355
- self._schedule_group_evaluation_if_needed(
399
+ self._schedule_post_publish_evaluations(
356
400
  new_request=new_request,
401
+ interactions=new_interactions,
357
402
  user_id=user_id,
358
403
  agent_version=agent_version,
359
404
  source=source,
360
405
  )
361
- record_usage_event(
362
- org_id=self.org_id,
406
+ self._emit_publish_success_events(
407
+ interactions=new_interactions,
363
408
  user_id=user_id,
364
409
  request_id=request_id,
365
410
  session_id=new_request.session_id,
366
411
  source=source,
367
412
  agent_version=agent_version,
368
413
  backend="durable",
369
- event_name="publish_request_succeeded",
370
- event_category="publish",
371
- outcome="success",
372
- count_value=len(new_interactions),
373
414
  duration_ms=int((time.perf_counter() - publish_start) * 1000),
374
415
  metadata={
375
416
  "defer_learning": True,
@@ -384,8 +425,9 @@ class GenerationService:
384
425
  enqueue_publish_learning,
385
426
  )
386
427
 
387
- self._schedule_group_evaluation_if_needed(
428
+ self._schedule_post_publish_evaluations(
388
429
  new_request=new_request,
430
+ interactions=new_interactions,
389
431
  user_id=user_id,
390
432
  agent_version=agent_version,
391
433
  source=source,
@@ -402,18 +444,14 @@ class GenerationService:
402
444
  skip_aggregation=publish_user_interaction_request.skip_aggregation,
403
445
  )
404
446
  )
405
- record_usage_event(
406
- org_id=self.org_id,
447
+ self._emit_publish_success_events(
448
+ interactions=new_interactions,
407
449
  user_id=user_id,
408
450
  request_id=request_id,
409
451
  session_id=new_request.session_id,
410
452
  source=source,
411
453
  agent_version=agent_version,
412
454
  backend="classic",
413
- event_name="publish_request_succeeded",
414
- event_category="publish",
415
- outcome="success",
416
- count_value=len(new_interactions),
417
455
  duration_ms=int((time.perf_counter() - publish_start) * 1000),
418
456
  metadata={
419
457
  "defer_learning": True,
@@ -447,30 +485,26 @@ class GenerationService:
447
485
  result=result,
448
486
  )
449
487
 
450
- # Schedule delayed group evaluation for the required session.
451
- self._schedule_group_evaluation_if_needed(
452
- new_request=new_request,
453
- user_id=user_id,
454
- agent_version=agent_version,
455
- source=source,
456
- )
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
+ )
457
495
 
458
- record_usage_event(
459
- org_id=self.org_id,
460
- user_id=user_id,
461
- request_id=request_id,
462
- session_id=new_request.session_id,
463
- source=source,
464
- agent_version=agent_version,
465
- backend="classic",
466
- event_name="publish_request_succeeded",
467
- event_category="publish",
468
- outcome="success",
469
- count_value=len(new_interactions),
470
- duration_ms=int((time.perf_counter() - publish_start) * 1000),
471
- metadata={"warning_count": len(result.warnings)},
472
- )
473
- 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
474
508
 
475
509
  except Exception as e:
476
510
  record_usage_event(
@@ -500,6 +534,47 @@ class GenerationService:
500
534
  )
501
535
  raise
502
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
+
503
578
  # ===============================
504
579
  # deferred learning
505
580
  # ===============================
@@ -514,15 +589,13 @@ class GenerationService:
514
589
  agent_version: str,
515
590
  force_extraction: bool,
516
591
  skip_aggregation: bool,
517
- sequential: bool = False,
518
592
  ) -> GenerationServiceResult:
519
- """Run the learning steps for a deferred job.
593
+ """Run the learning steps for a deferred job (synchronous / non-durable
594
+ callers: ``PublishLearningWorker`` and manual reruns).
520
595
 
521
- Args:
522
- sequential: When True, run profile and playbook generation on the
523
- calling thread (no ThreadPoolExecutor). Required when the
524
- caller already holds the storage commit_scope lock — spawning
525
- worker threads inside that scope would deadlock on the RLock.
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).
526
599
  """
527
600
  result = GenerationServiceResult(request_id=request_id)
528
601
  self._run_learning_steps(
@@ -533,14 +606,353 @@ class GenerationService:
533
606
  skip_aggregation=skip_aggregation,
534
607
  source=source,
535
608
  result=result,
536
- parallel=not sequential,
609
+ parallel=True,
537
610
  )
538
611
  return result
539
612
 
613
+ # ===============================
614
+ # deferred learning — compute / persist / emit split (gate b)
615
+ # ===============================
616
+
617
+ def compute_deferred_learning(
618
+ self,
619
+ *,
620
+ user_id: str,
621
+ request_id: str,
622
+ session_id: str | None, # noqa: ARG002 - kept for worker call symmetry
623
+ source: str | None,
624
+ agent_version: str,
625
+ force_extraction: bool,
626
+ skip_aggregation: bool,
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(
675
+ user_id=user_id,
676
+ request_id=request_id,
677
+ agent_version=agent_version,
678
+ source=source,
679
+ warnings=warnings,
680
+ )
681
+
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(
686
+ llm_client=self.client, request_context=self.request_context
687
+ )
688
+ profile_request = ProfileGenerationRequest(
689
+ user_id=user_id,
690
+ request_id=request_id,
691
+ source=source,
692
+ force_extraction=force_extraction,
693
+ )
694
+ playbook_service = PlaybookGenerationService(
695
+ llm_client=self.client,
696
+ request_context=self.request_context,
697
+ skip_aggregation=skip_aggregation,
698
+ )
699
+ playbook_request = PlaybookGenerationRequest(
700
+ request_id=request_id,
701
+ agent_version=agent_version,
702
+ user_id=user_id,
703
+ source=source,
704
+ force_extraction=force_extraction,
705
+ )
706
+
707
+ profile_pair: tuple = None # type: ignore[assignment]
708
+ playbook_pair: tuple = None # type: ignore[assignment]
709
+ executor = ThreadPoolExecutor(max_workers=2)
710
+ try:
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
+ ):
725
+ try:
726
+ plan = future.result(timeout=GENERATION_SERVICE_TIMEOUT_SECONDS)
727
+ except FuturesTimeoutError: # noqa: PERF203
728
+ msg = (
729
+ f"{service_name} timed out after "
730
+ f"{GENERATION_SERVICE_TIMEOUT_SECONDS}s"
731
+ )
732
+ with sentry_tags(
733
+ subsystem="generation",
734
+ service=service_name,
735
+ request_id=request_id,
736
+ error_type="timeout",
737
+ ):
738
+ logger.error("%s for request %s", msg, request_id)
739
+ warnings.append(msg)
740
+ continue
741
+ except Exception as e:
742
+ msg = f"{service_name} failed: {e}"
743
+ with sentry_tags(
744
+ subsystem="generation",
745
+ service=service_name,
746
+ request_id=request_id,
747
+ error_type=type(e).__name__,
748
+ ):
749
+ logger.exception(
750
+ "Generation service failed for request %s",
751
+ request_id,
752
+ )
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)
761
+ finally:
762
+ executor.shutdown(wait=False, cancel_futures=True)
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
+
540
863
  # ===============================
541
864
  # private methods
542
865
  # ===============================
543
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
+
544
956
  def _run_learning_steps(
545
957
  self,
546
958
  *,
@@ -565,9 +977,10 @@ class GenerationService:
565
977
  profile_generation_service = ProfileGenerationService(
566
978
  llm_client=self.client, request_context=self.request_context
567
979
  )
980
+ source_request_id = request_id
568
981
  profile_generation_request = ProfileGenerationRequest(
569
982
  user_id=user_id,
570
- request_id=request_id,
983
+ request_id=source_request_id,
571
984
  source=source,
572
985
  force_extraction=force_extraction,
573
986
  )
@@ -578,7 +991,7 @@ class GenerationService:
578
991
  skip_aggregation=skip_aggregation,
579
992
  )
580
993
  playbook_generation_request = PlaybookGenerationRequest(
581
- request_id=request_id,
994
+ request_id=source_request_id,
582
995
  agent_version=agent_version,
583
996
  user_id=user_id,
584
997
  source=source,
@@ -679,6 +1092,39 @@ class GenerationService:
679
1092
  request_id,
680
1093
  )
681
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
+
682
1128
  def _schedule_group_evaluation_if_needed(
683
1129
  self,
684
1130
  *,
@@ -958,6 +1404,7 @@ class GenerationService:
958
1404
  expert_content=interaction_data.expert_content,
959
1405
  tools_used=interaction_data.tools_used,
960
1406
  citations=interaction_data.citations,
1407
+ retrieved_learnings=interaction_data.retrieved_learnings,
961
1408
  )
962
1409
  for interaction_data in interaction_data_list
963
1410
  ]